Support Starlight HPLP

This commit is contained in:
linmeideli committed 2026-07-09 15:30:27 +08:00
1 parent 24eb5d3371
commit 0a5e3b3173
20 files changed
+762 -345

No files matched your search

@@ -150,6 +150,7 @@
"System.Runtime.CompilerServices.Unsafe": "4.5.3"
}
}
}
},
".NETStandard,Version=v2.0/win-x64": {}
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace Empostor.Api.Config
{
/// <summary>
/// Configuration for the HPLP (HTTP Public Lobby List Protocol) endpoint,
/// which allows Starlight clients to discover games hosted on this server.
/// </summary>
public class HplpConfig
{
public const string Section = "HPLP";
/// <summary>
/// Whether the HPLP endpoint (<c>GET /x-api/games</c>) is enabled.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Region identifier reported to Starlight clients (HPLP <c>region_id</c>).
/// </summary>
public string RegionId { get; set; } = "default";
/// <summary>
/// Human-readable region name displayed in the Starlight lobby UI.
/// </summary>
public string RegionName { get; set; } = "Empostor Server";
/// <summary>
/// Public URL used by Starlight clients to connect to this server.
/// When empty, the URL is auto-constructed as <c>http://{PublicIp}:{HttpPort}</c>.
/// Set this to a custom value (e.g. <c>https://your-domain.com</c>) when
/// running behind a reverse proxy with TLS.
/// </summary>
public string PublicUrl { get; set; } = string.Empty;
}
}
+197
View File
@@ -0,0 +1,197 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Empostor.Api.Service;
/// <summary>
/// Abstract base class for JSON file-based data persistence.
/// Stores data under <c>./Data/{ClassName}Data.json</c>.
/// Subclasses only need to implement <see cref="GetSnapshot"/> and <see cref="ApplySnapshot"/>.
/// </summary>
/// <typeparam name="TData">The serializable data type.</typeparam>
public abstract class JsonDataStore<TData> : IDisposable
{
/// <summary>
/// JSON serialization options used for save/load.
/// Override in subclass constructor to customize (e.g. add <see cref="JsonSerializerOptions.DefaultIgnoreCondition"/>).
/// </summary>
protected JsonSerializerOptions JsonOpts { get; } = new() { WriteIndented = true };
/// <summary>
/// Full path to the data file.
/// Derived automatically from the subclass name: <c>./Data/{ClassName}Data.json</c>
/// </summary>
protected string FilePath { get; }
/// <summary>
/// Semaphore used to prevent concurrent writes.
/// </summary>
protected SemaphoreSlim SaveLock { get; } = new(1, 1);
/// <summary>
/// Logger instance for this store.
/// </summary>
protected ILogger Logger { get; }
/// <summary>
/// Creates a new instance.
/// </summary>
/// <param name="logger">Logger for this store.</param>
/// <param name="legacyPath">
/// If provided and the legacy file exists but the new path does not,
/// the legacy file is automatically copied to the new path (one-time migration).
/// </param>
protected JsonDataStore(ILogger logger, string? legacyPath = null)
{
Logger = logger;
// Derive file name from class name: "DiscordWebhookStore" → "DiscordWebhookData.json"
var name = GetType().Name;
if (name.EndsWith("Store", StringComparison.Ordinal))
{
name = name.Substring(0, name.Length - 5) + "Data";
}
FilePath = Path.Combine(Directory.GetCurrentDirectory(), "Data", name + ".json");
// One-time legacy migration
if (legacyPath != null && File.Exists(legacyPath) && !File.Exists(FilePath))
{
try
{
var dir = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.Copy(legacyPath, FilePath);
Logger.LogInformation("{Name} migrated from {Legacy} to {New}", GetType().Name, legacyPath, FilePath);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "{Name} failed to migrate {Legacy}", GetType().Name, legacyPath);
}
}
}
/// <summary>
/// Build a serializable snapshot from the current in-memory state.
/// </summary>
protected abstract TData GetSnapshot();
/// <summary>
/// Restore in-memory state from a previously-saved snapshot.
/// </summary>
protected abstract void ApplySnapshot(TData data);
/// <summary>
/// Load data from the file on disk. If the file does not exist or fails to parse,
/// the store remains in its default (constructor-initialized) state.
/// </summary>
protected void Load()
{
if (!File.Exists(FilePath))
{
Logger.LogDebug("{Name} no data file at {Path}, using defaults", GetType().Name, FilePath);
return;
}
try
{
var json = File.ReadAllText(FilePath);
var data = JsonSerializer.Deserialize<TData>(json, JsonOpts);
if (data != null)
{
ApplySnapshot(data);
Logger.LogInformation("{Name} loaded from {Path}", GetType().Name, FilePath);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "{Name} failed to load {Path}, using defaults", GetType().Name, FilePath);
}
}
/// <summary>
/// Save the current state to disk asynchronously, with a non-blocking write lock.
/// If a save is already in progress, this call is silently skipped.
/// </summary>
protected async ValueTask SaveAsync()
{
if (!await SaveLock.WaitAsync(0))
{
return;
}
try
{
var dir = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
var snapshot = GetSnapshot();
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
await File.WriteAllTextAsync(FilePath, json);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "{Name} failed to save {Path}", GetType().Name, FilePath);
}
finally
{
SaveLock.Release();
}
}
/// <summary>
/// Fire-and-forget save. Schedules a background save and returns immediately.
/// Useful for high-frequency writes where waiting is not needed.
/// </summary>
protected void SaveFireAndForget()
{
_ = Task.Run(SaveAsyncAsTask);
async Task SaveAsyncAsTask()
{
if (!await SaveLock.WaitAsync(0))
{
return;
}
try
{
var dir = Path.GetDirectoryName(FilePath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
var snapshot = GetSnapshot();
var json = JsonSerializer.Serialize(snapshot, JsonOpts);
await File.WriteAllTextAsync(FilePath, json);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "{Name} failed to save {Path}", GetType().Name, FilePath);
}
finally
{
SaveLock.Release();
}
}
}
/// <inheritdoc />
public void Dispose()
{
SaveLock.Dispose();
GC.SuppressFinalize(this);
}
}
+34 -9
View File
@@ -14,14 +14,37 @@ public sealed class PlayerConnectStore : IDisposable
private readonly ConcurrentDictionary<string, DateTime> _lastConnect;
private readonly Timer _saveTimer;
private readonly ILogger<PlayerConnectStore> _logger;
private readonly string _filePath;
private bool _dirty;
public PlayerConnectStore(ILogger<PlayerConnectStore> logger)
{
_logger = logger;
_filePath = Path.Combine(Directory.GetCurrentDirectory(), "Data", "PlayerConnectData.json");
_lastConnect = Load();
_saveTimer = new Timer(_ => SaveIfDirty(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
_instance = this;
// One-time migration from old path
var legacyPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Data", "PlayerConnect.json");
if (File.Exists(legacyPath) && !File.Exists(_filePath))
{
try
{
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
File.Copy(legacyPath, _filePath);
logger.LogInformation("PlayerConnectStore migrated from {Legacy} to {New}", legacyPath, _filePath);
}
catch (Exception ex)
{
logger.LogWarning(ex, "PlayerConnectStore failed to migrate {Legacy}", legacyPath);
}
}
}
public void RecordDisconnect(string productUserId)
@@ -57,18 +80,16 @@ public sealed class PlayerConnectStore : IDisposable
return local.ToString("yyyy-MM-dd HH:mm:ss");
}
private static ConcurrentDictionary<string, DateTime> Load()
private ConcurrentDictionary<string, DateTime> Load()
{
try
{
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Data");
var path = Path.Combine(dir, "PlayerConnect.json");
if (!File.Exists(path))
if (!File.Exists(_filePath))
{
return new ConcurrentDictionary<string, DateTime>();
}
var json = File.ReadAllText(path);
var json = File.ReadAllText(_filePath);
return JsonSerializer.Deserialize<ConcurrentDictionary<string, DateTime>>(json)
?? new ConcurrentDictionary<string, DateTime>();
}
@@ -87,11 +108,14 @@ public sealed class PlayerConnectStore : IDisposable
try
{
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Data");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "PlayerConnect.json");
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(_lastConnect);
File.WriteAllText(path, json);
File.WriteAllText(_filePath, json);
_dirty = false;
}
catch (Exception ex)
@@ -105,5 +129,6 @@ public sealed class PlayerConnectStore : IDisposable
_saveTimer.Dispose();
SaveIfDirty();
_instance = null;
GC.SuppressFinalize(this);
}
}
+8 -1
View File
@@ -34,6 +34,12 @@
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[8.0.28, )",
"resolved": "8.0.28",
"contentHash": "XMqgVjlLxLqWmEh3c49haXLQwsMNtvo6YscUaqfvEGfg1iA8hnYgkUVq3i9Zu9gKeNKMWiiZKVwZExc/qyEAsQ=="
},
"StyleCop.Analyzers": {
"type": "Direct",
"requested": "[1.2.0-beta.556, )",
@@ -139,6 +145,7 @@
"NextFast.Hazel.Abstractions": {
"type": "Project"
}
}
},
"net8.0/win-x64": {}
}
}
+70 -1
View File
@@ -49,6 +49,9 @@ namespace Empostor.Server.Http
private readonly PlayerStatsConfig _statsConfig;
private readonly ChatFilterStore _chatFilter;
private readonly DiscordWebhookStore _discordWebhook;
private readonly HplpStore _hplpStore;
private readonly IOptions<ServerConfig> _serverConfig;
private readonly IOptions<HttpServerConfig> _httpServerConfig;
private readonly IpGeolocationService _ipGeo;
private readonly string _passwordHash;
private static readonly ConcurrentDictionary<string, (int Count, DateTime FirstAttempt)> _loginFailures = new();
@@ -65,6 +68,9 @@ namespace Empostor.Server.Http
IOptions<PlayerStatsConfig> statsConfig,
ChatFilterStore chatFilter,
DiscordWebhookStore discordWebhook,
HplpStore hplpStore,
IOptions<ServerConfig> serverConfig,
IOptions<HttpServerConfig> httpServerConfig,
IpGeolocationService ipGeo)
{
_logger = logger;
@@ -78,6 +84,9 @@ namespace Empostor.Server.Http
_statsConfig = statsConfig.Value;
_chatFilter = chatFilter;
_discordWebhook = discordWebhook;
_hplpStore = hplpStore;
_serverConfig = serverConfig;
_httpServerConfig = httpServerConfig;
_ipGeo = ipGeo;
_passwordHash = ComputeHash(_config.Password);
@@ -672,7 +681,7 @@ namespace Empostor.Server.Http
}
[HttpPost("/api/admin/chatfilter/settings")]
public IActionResult UpdateChatFilterSettings([FromBody] ChatFilterSettingsReq req)
public async Task<IActionResult> UpdateChatFilterSettings([FromBody] ChatFilterSettingsReq req)
{
if (!IsAuthenticated())
{
@@ -699,6 +708,8 @@ namespace Empostor.Server.Http
_chatFilter.SpamWindowSeconds = req.SpamWindowSeconds.Value;
}
await _chatFilter.SaveAsync();
return Ok(new
{
enabled = _chatFilter.Enabled,
@@ -751,6 +762,62 @@ namespace Empostor.Server.Http
});
}
[HttpGet("/api/admin/hplp")]
public IActionResult GetHplp()
{
if (!IsAuthenticated())
{
return Unauthorized();
}
return Ok(new
{
enabled = _hplpStore.Enabled,
regionId = _hplpStore.RegionId,
regionName = _hplpStore.RegionName,
publicUrl = _hplpStore.PublicUrl,
});
}
[HttpPost("/api/admin/hplp")]
public async Task<IActionResult> UpdateHplp([FromBody] HplpSettingsReq req)
{
if (!IsAuthenticated())
{
return Unauthorized();
}
if (req.Enabled.HasValue)
{
_hplpStore.Enabled = req.Enabled.Value;
}
if (req.RegionId != null)
{
_hplpStore.RegionId = req.RegionId;
}
if (req.RegionName != null)
{
_hplpStore.RegionName = req.RegionName;
}
if (req.PublicUrl != null)
{
_hplpStore.PublicUrl = req.PublicUrl;
}
await _hplpStore.SaveAsync();
return Ok(new
{
enabled = _hplpStore.Enabled,
regionId = _hplpStore.RegionId,
regionName = _hplpStore.RegionName,
publicUrl = _hplpStore.PublicUrl,
});
}
private IGame? FindGame(string code)
{
try { return _gameManager.Find(new GameCode(code.ToUpperInvariant())); }
@@ -881,6 +948,8 @@ namespace Empostor.Server.Http
public sealed record DiscordWebhookSettingsReq(string? MatchmakerUrl, string? AdminUrl);
public sealed record HplpSettingsReq(bool? Enabled, string? RegionId, string? RegionName, string? PublicUrl);
// HTML pages are auto-generated to BaseDirectory/Pages/ on first access.
// Server operators can customize them by editing the files on disk.
private static string? _loginHtml;
@@ -694,6 +694,7 @@ internal static class AdminTemplateDefaults
<div class="ni" onclick="nav('st')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10M12 20V4M6 20v-6"/></svg><span data-i18n="nav.statistics">Statistics</span></div>
<div class="ni" onclick="nav('cf')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg><span data-i18n="nav.chat_filter">Chat Filter</span></div>
<div class="ni" onclick="nav('dw')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg><span data-i18n="nav.discord">Discord</span></div>
<div class="ni" onclick="nav('hp')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg><span data-i18n="nav.hplp">HPLP</span></div>
<div class="nsep"></div>
<div class="nlbl" data-i18n="nav.actions">Actions</div>
<div class="ni" onclick="nav('bc')"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 2H7a2 2 0 00-2 2v16l5-3 5 3V4a2 2 0 00-2-2z"/><path d="M10 9h4M10 13h4"/></svg><span data-i18n="nav.broadcast">Broadcast</span></div>
@@ -1059,6 +1060,32 @@ internal static class AdminTemplateDefaults
</div>
<div id="dw-r" class="msg"></div>
</div>
<div id="p-hp" class="pnl">
<div class="form">
<h2 style="margin:0"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg><span data-i18n="hplp.title">HPLP (Public Lobby List)</span></h2>
<button class="bp bsm" onclick="saveHplpSettings()"><span data-i18n="hplp.save">Save Settings</span></button>
<div style="margin-top:12px">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="hp-enabled" onchange="saveHplpSettings()">
<span data-i18n="hplp.enabled">Enable HPLP endpoint (GET /x-api/games)</span>
</label>
</div>
<div class="field" style="margin-top:10px">
<label data-i18n="hplp.region_id">Region ID</label>
<input type="text" id="hp-region-id" data-i18n-placeholder="hplp.region_id_placeholder" placeholder="e.g. meu, default" onchange="saveHplpSettings()">
</div>
<div class="field" style="margin-top:10px">
<label data-i18n="hplp.region_name">Region Name</label>
<input type="text" id="hp-region-name" data-i18n-placeholder="hplp.region_name_placeholder" placeholder="e.g. Modded EU" onchange="saveHplpSettings()">
</div>
<div class="field" style="margin-top:10px">
<label data-i18n="hplp.public_url">Public URL (optional)</label>
<input type="text" id="hp-public-url" data-i18n-placeholder="hplp.public_url_placeholder" placeholder="Leave empty to auto-generate from server config" onchange="saveHplpSettings()">
<div style="font-size:11px;color:var(--m);margin-top:4px"><span data-i18n="hplp.public_url_desc">Used by Starlight clients to connect. Auto-generated as http://ip:port if empty.</span></div>
</div>
</div>
<div id="hp-r" class="msg"></div>
</div>
</ct>
</main>
<!-- Client Detail Modal -->
@@ -1420,6 +1447,37 @@ internal static class AdminTemplateDefaults
}
}
async function fHplp() {
const { data } = await api('GET', '/api/admin/hplp');
document.getElementById('hp-enabled').checked = data.enabled;
const regionIdEl = document.getElementById('hp-region-id');
const regionNameEl = document.getElementById('hp-region-name');
const publicUrlEl = document.getElementById('hp-public-url');
if (document.activeElement !== regionIdEl) {
regionIdEl.value = data.regionId || '';
}
if (document.activeElement !== regionNameEl) {
regionNameEl.value = data.regionName || '';
}
if (document.activeElement !== publicUrlEl) {
publicUrlEl.value = data.publicUrl || '';
}
}
async function saveHplpSettings() {
const { ok, data } = await api('POST', '/api/admin/hplp', {
enabled: document.getElementById('hp-enabled').checked,
regionId: document.getElementById('hp-region-id').value.trim(),
regionName: document.getElementById('hp-region-name').value.trim(),
publicUrl: document.getElementById('hp-public-url').value.trim()
});
if (ok) {
msg('hp-r', true, _('hplp.saved', 'HPLP settings saved.'));
} else {
msg('hp-r', false, data.error ?? 'Error');
}
}
function refreshTab() {
if (cur === 'ov') fGames('ov-t', true);
if (cur === 'gm') fGames('gm-t', false);
@@ -1431,6 +1489,7 @@ internal static class AdminTemplateDefaults
if (cur === 'st') fStats();
if (cur === 'cf') fChatFilter();
if (cur === 'dw') fDiscordWebhook();
if (cur === 'hp') fHplp();
}
async function doBc() {
+121
View File
@@ -0,0 +1,121 @@
using System;
using System.Linq;
using Empostor.Api.Config;
using Empostor.Api.Games;
using Empostor.Api.Games.Managers;
using Empostor.Api.Innersloth;
using Empostor.Server.Service.Admin.Reactor;
using Empostor.Server.Service.Api;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace Empostor.Server.Http;
/// <summary>
/// Implements the HPLP (HTTP Public Lobby List Protocol) for Starlight client discovery.
/// Serves <c>GET /x-api/games</c> with active games and region metadata.
/// </summary>
[ApiController]
[Route("/x-api")]
public sealed class HplpController : ControllerBase
{
private readonly IGameManager _gameManager;
private readonly HplpStore _hplpStore;
private readonly ServerConfig _serverConfig;
private readonly HttpServerConfig _httpConfig;
public HplpController(
IGameManager gameManager,
HplpStore hplpStore,
IOptions<ServerConfig> serverConfig,
IOptions<HttpServerConfig> httpConfig)
{
_gameManager = gameManager;
_hplpStore = hplpStore;
_serverConfig = serverConfig.Value;
_httpConfig = httpConfig.Value;
}
[HttpGet("games")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public IActionResult GetGames()
{
if (!_hplpStore.Enabled)
{
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "HPLP is not enabled on this server." });
}
var regionUrl = ResolveRegionUrl();
var regionId = _hplpStore.RegionId;
var games = _gameManager.Games.Select(game => new
{
code = (string)game.Code,
host_name = game.DisplayName ?? game.Host?.Client.Name ?? "—",
status = MapStatus(game.GameState),
player_count = game.PlayerCount,
max_players = (int)game.Options.MaxPlayers,
chat_lang = (int)game.Options.Keywords,
map_id = (int)game.Options.Map,
region_id = regionId,
mods = GetMods(game),
}).ToList();
return Ok(new
{
games,
regions = new[]
{
new
{
id = regionId,
name = _hplpStore.RegionName,
url = regionUrl,
},
},
});
}
private string ResolveRegionUrl()
{
if (!string.IsNullOrWhiteSpace(_hplpStore.PublicUrl))
{
return _hplpStore.PublicUrl.TrimEnd('/');
}
var publicIp = _serverConfig.ResolvePublicIp();
return $"http://{publicIp}:{_httpConfig.ListenPort}";
}
private static string MapStatus(GameStates state)
{
return state switch
{
GameStates.NotStarted => "Lobby",
GameStates.Starting => "Lobby",
GameStates.Started => "Started",
GameStates.Ended => "Ended",
GameStates.Destroyed => "Ended",
_ => "Lobby",
};
}
private static object[] GetMods(IGame game)
{
// Try Reactor mods from the host's handshake first.
var reactorMods = game.Host?.Client.GetReactorMods();
if (reactorMods != null && reactorMods.Mods.Count > 0)
{
return reactorMods.Mods.Select(m => new
{
id = m.Id,
version = m.Version,
flags = m.RequiredOnAllClients ? 1 : 0,
}).ToArray<object>();
}
return Array.Empty<object>();
}
}
+1 -1
View File
@@ -514,7 +514,7 @@ namespace Empostor.Server.Net
outcome = reportEvent.Outcome;
}
_reportStore.Add(new ReportEntry
_reportStore.Add(new ReportStore.ReportEntry
{
GameCode = gameCode.ToString(),
ReporterName = Name,
+14 -1
View File
@@ -30,6 +30,7 @@
"nav.statistics": "Statistics",
"nav.chat_filter": "Chat Filter",
"nav.discord": "Discord",
"nav.hplp": "HPLP",
"nav.reload_lang": "Reload Lang",
"status.games": "Games",
@@ -224,5 +225,17 @@
"discord.admin_url_placeholder": "https://discord.com/api/webhooks/...",
"discord.admin_desc": "Player Banned, Player Reported",
"discord.hint": "Leave a URL empty to disable its notifications. Leave both empty to disable all webhooks.",
"discord.saved": "Discord webhook settings saved."
"discord.saved": "Discord webhook settings saved.",
"hplp.title": "HPLP (Public Lobby List)",
"hplp.save": "Save Settings",
"hplp.enabled": "Enable HPLP endpoint (GET /x-api/games)",
"hplp.region_id": "Region ID",
"hplp.region_id_placeholder": "e.g. meu, default",
"hplp.region_name": "Region Name",
"hplp.region_name_placeholder": "e.g. Modded EU",
"hplp.public_url": "Public URL (optional)",
"hplp.public_url_placeholder": "Leave empty to auto-generate from server config",
"hplp.public_url_desc": "Used by Starlight clients to connect. Auto-generated as http://ip:port if empty.",
"hplp.saved": "HPLP settings saved."
}
+2
View File
@@ -115,6 +115,7 @@ namespace Empostor.Server
services.Configure<HttpServerConfig>(host.Configuration.GetSection(HttpServerConfig.Section));
services.Configure<AdminConfig>(host.Configuration.GetSection(AdminConfig.Section));
services.Configure<DiscordWebhookConfig>(host.Configuration.GetSection(DiscordWebhookConfig.Section));
services.Configure<HplpConfig>(host.Configuration.GetSection(HplpConfig.Section));
services.Configure<PlayerStatsConfig>(host.Configuration.GetSection(PlayerStatsConfig.Section));
services.Configure<ChatFilterConfig>(host.Configuration.GetSection(ChatFilterConfig.Section));
services.Configure<AuthApiConfig>(host.Configuration.GetSection(AuthApiConfig.Section));
@@ -205,6 +206,7 @@ namespace Empostor.Server
services.AddSingleton<PlayerStatsStore>();
services.AddSingleton<StatCommand>();
services.AddSingleton<DiscordWebhookStore>();
services.AddSingleton<HplpStore>();
services.AddSingleton<IEventListener, DiscordWebhookListener>();
services.AddSingleton<IEventListener, PlayerStatsListener>();
services.AddSingleton<ChatFilterStore>();
@@ -1,33 +1,22 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
namespace Empostor.Server.Service.Admin.Ban
{
public sealed class BanStore : IDisposable
public sealed class BanStore : JsonDataStore<BanStore.BanData>
{
private static readonly string BanFile =
Path.Combine(Directory.GetCurrentDirectory(), "bans.json");
private static readonly JsonSerializerOptions JsonOpts =
new() { WriteIndented = true };
private readonly ILogger<BanStore> _logger;
private readonly SemaphoreSlim _saveLock = new(1, 1);
private ConcurrentDictionary<string, BanEntry> _ips = new();
private ConcurrentDictionary<string, BanEntry> _friendCodes = new();
public BanStore(ILogger<BanStore> logger)
: base(logger, legacyPath: "bans.json")
{
_logger = logger;
Load();
}
@@ -40,7 +29,7 @@ namespace Empostor.Server.Service.Admin.Ban
var key = Normalize(ip);
var entry = new BanEntry { Value = key, Reason = reason, BannedAt = DateTime.UtcNow };
_ips[key] = entry;
SaveAsync();
SaveFireAndForget();
return entry;
}
@@ -49,7 +38,7 @@ namespace Empostor.Server.Service.Admin.Ban
var r = _ips.TryRemove(key, out _);
if (r)
{
SaveAsync();
SaveFireAndForget();
}
return r;
@@ -59,7 +48,7 @@ namespace Empostor.Server.Service.Admin.Ban
{
var entry = new BanEntry { Value = fc, Reason = reason, BannedAt = DateTime.UtcNow };
_friendCodes[fc] = entry;
SaveAsync();
SaveFireAndForget();
return entry;
}
@@ -68,7 +57,7 @@ namespace Empostor.Server.Service.Admin.Ban
var r = _friendCodes.TryRemove(key, out _);
if (r)
{
SaveAsync();
SaveFireAndForget();
}
return r;
@@ -82,51 +71,22 @@ namespace Empostor.Server.Service.Admin.Ban
public (int IpCount, int FcCount) Stats() => (_ips.Count, _friendCodes.Count);
private void SaveAsync()
protected override BanData GetSnapshot() => new()
{
_ = System.Threading.Tasks.Task.Run(async () =>
{
if (!await _saveLock.WaitAsync(0))
{
return;
}
Ips = new(_ips),
FriendCodes = new(_friendCodes),
};
try
{
var data = new BanData { Ips = new(_ips), FriendCodes = new(_friendCodes) };
await File.WriteAllTextAsync(BanFile, JsonSerializer.Serialize(data, JsonOpts));
}
catch (Exception ex) { _logger.LogWarning(ex, "BanStoreFailed to save bans.json"); }
finally { _saveLock.Release(); }
});
}
private void Load()
protected override void ApplySnapshot(BanData data)
{
if (!File.Exists(BanFile))
{
return;
}
try
{
var data = JsonSerializer.Deserialize<BanData>(File.ReadAllText(BanFile));
if (data != null)
{
_ips = new(data.Ips ?? new());
_friendCodes = new(data.FriendCodes ?? new());
_logger.LogInformation("BanStoreLoaded {I} IP bans + {F} FC bans", _ips.Count, _friendCodes.Count);
}
}
catch (Exception ex) { _logger.LogWarning(ex, "BanStoreFailed to load bans.json"); }
_ips = new(data.Ips ?? new());
_friendCodes = new(data.FriendCodes ?? new());
}
private static string Normalize(IPAddress ip)
=> ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4().ToString() : ip.ToString();
public void Dispose() => _saveLock.Dispose();
private sealed class BanData
public sealed class BanData
{
[JsonPropertyName("ips")]
public Dictionary<string, BanEntry>? Ips { get; set; }
@@ -1,24 +1,34 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Empostor.Api.Config;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Empostor.Server.Service.Admin.Chat;
public sealed class ChatFilterStore
public sealed class ChatFilterStore : JsonDataStore<ChatFilterConfig>
{
private readonly object _wordsLock = new();
private List<string> _blockedWords;
private List<string> _blockedWords = new();
public ChatFilterStore(IOptions<ChatFilterConfig> config)
public ChatFilterStore(ILogger<ChatFilterStore> logger, IOptions<ChatFilterConfig> config)
: base(logger, legacyPath: null)
{
var cfg = config.Value;
Enabled = cfg.Enabled;
BlockMessage = cfg.BlockMessage;
SpamThreshold = cfg.SpamThreshold;
SpamWindowSeconds = cfg.SpamWindowSeconds;
_blockedWords = new List<string>(cfg.BlockedWords ?? new List<string>());
Load();
// If no persisted file was loaded, fall back to config.json defaults
if (_blockedWords.Count == 0 && !Enabled)
{
var cfg = config.Value;
Enabled = cfg.Enabled;
BlockMessage = cfg.BlockMessage;
SpamThreshold = cfg.SpamThreshold;
SpamWindowSeconds = cfg.SpamWindowSeconds;
_blockedWords = new List<string>(cfg.BlockedWords ?? new List<string>());
}
}
public bool Enabled { get; set; }
@@ -57,6 +67,8 @@ public sealed class ChatFilterStore
_blockedWords.Add(trimmed);
}
SaveFireAndForget();
}
public bool RemoveWord(string word)
@@ -66,10 +78,47 @@ public sealed class ChatFilterStore
return false;
}
var removed = false;
lock (_wordsLock)
{
return _blockedWords.RemoveAll(
removed = _blockedWords.RemoveAll(
w => w.Equals(word.Trim(), StringComparison.OrdinalIgnoreCase)) > 0;
}
if (removed)
{
SaveFireAndForget();
}
return removed;
}
public new async ValueTask SaveAsync() => await base.SaveAsync();
protected override ChatFilterConfig GetSnapshot()
{
lock (_wordsLock)
{
return new ChatFilterConfig
{
Enabled = Enabled,
BlockMessage = BlockMessage,
SpamThreshold = SpamThreshold,
SpamWindowSeconds = SpamWindowSeconds,
BlockedWords = new List<string>(_blockedWords),
};
}
}
protected override void ApplySnapshot(ChatFilterConfig data)
{
Enabled = data.Enabled;
BlockMessage = data.BlockMessage;
SpamThreshold = data.SpamThreshold;
SpamWindowSeconds = data.SpamWindowSeconds;
lock (_wordsLock)
{
_blockedWords = new List<string>(data.BlockedWords ?? new List<string>());
}
}
}
@@ -1,30 +1,20 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
namespace Empostor.Server.Service.Admin.Report
{
public sealed class ReportStore : IDisposable
public sealed class ReportStore : JsonDataStore<List<ReportStore.ReportEntry>>
{
private static readonly string DataDir = Path.Combine(Directory.GetCurrentDirectory(), "Data");
private static readonly string ReportsFile = Path.Combine(DataDir, "reports.json");
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
private readonly ILogger<ReportStore> _logger;
private readonly List<ReportEntry> _reports = new();
private readonly object _lock = new();
private readonly SemaphoreSlim _saveLock = new(1, 1);
private readonly List<ReportEntry> _reports = new();
private const int Max = 500;
public ReportStore(ILogger<ReportStore> logger)
: base(logger, legacyPath: "Data/reports.json")
{
_logger = logger;
Load();
}
@@ -39,7 +29,7 @@ namespace Empostor.Server.Service.Admin.Report
}
}
SaveAsync();
SaveFireAndForget();
}
public List<ReportEntry> GetRecent(int count = 100)
@@ -50,82 +40,40 @@ namespace Empostor.Server.Service.Admin.Report
}
}
private void SaveAsync()
protected override List<ReportEntry> GetSnapshot()
{
_ = Task.Run(async () =>
lock (_lock)
{
if (!await _saveLock.WaitAsync(0))
{
return;
}
try
{
Directory.CreateDirectory(DataDir);
List<ReportEntry> snapshot;
lock (_lock)
{
snapshot = _reports.ToList();
}
await File.WriteAllTextAsync(ReportsFile, JsonSerializer.Serialize(snapshot, JsonOpts));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ReportFailed to save reports");
}
finally
{
_saveLock.Release();
}
});
}
private void Load()
{
if (!File.Exists(ReportsFile))
{
return;
}
try
{
var list = JsonSerializer.Deserialize<List<ReportEntry>>(File.ReadAllText(ReportsFile));
if (list != null)
{
lock (_lock)
{
_reports.AddRange(list.Take(Max));
}
_logger.LogInformation("ReportLoaded {Count} report(s)", _reports.Count);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ReportFailed to load reports file");
return _reports.ToList();
}
}
public void Dispose() => _saveLock.Dispose();
}
protected override void ApplySnapshot(List<ReportEntry> data)
{
lock (_lock)
{
_reports.Clear();
_reports.AddRange(data.Take(Max));
}
}
public sealed class ReportEntry
{
public DateTime Time { get; init; } = DateTime.UtcNow;
public sealed class ReportEntry
{
public DateTime Time { get; init; } = DateTime.UtcNow;
public string GameCode { get; init; } = string.Empty;
public string GameCode { get; init; } = string.Empty;
public string ReporterName { get; init; } = string.Empty;
public string ReporterName { get; init; } = string.Empty;
public string? ReporterFriendCode { get; init; }
public string? ReporterFriendCode { get; init; }
public string? ReportedName { get; init; }
public string? ReportedName { get; init; }
public string? ReportedFriendCode { get; init; }
public string? ReportedFriendCode { get; init; }
public ReportReasons Reason { get; init; }
public ReportReasons Reason { get; init; }
public ReportOutcome Outcome { get; init; }
public ReportOutcome Outcome { get; init; }
}
}
}
@@ -1,58 +1,32 @@
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Empostor.Api.Config;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Empostor.Server.Service.Api;
public sealed class DiscordWebhookStore
public sealed class DiscordWebhookStore : JsonDataStore<DiscordWebhookConfig>
{
private static readonly string ConfigFile = Path.Combine(Directory.GetCurrentDirectory(), "discord_webhook.json");
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly ILogger<DiscordWebhookStore> _logger;
private readonly SemaphoreSlim _saveLock = new(1, 1);
public DiscordWebhookStore(ILogger<DiscordWebhookStore> logger, IOptions<DiscordWebhookConfig> config)
: base(logger, legacyPath: "discord_webhook.json")
{
_logger = logger;
JsonOpts.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
DiscordWebhookConfig? cfg = null;
// Try loading from persisted file (handled by base.Load if file exists)
Load();
if (File.Exists(ConfigFile))
// If no persisted data was loaded, fall back to config.json defaults
if (string.IsNullOrEmpty(MatchmakerUrl) && string.IsNullOrEmpty(AdminUrl))
{
try
{
var json = File.ReadAllText(ConfigFile);
cfg = JsonSerializer.Deserialize<DiscordWebhookConfig>(json);
if (cfg != null)
{
_logger.LogInformation("DiscordWebhookLoaded from {File}", ConfigFile);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DiscordWebhookFailed to load {File}, using defaults", ConfigFile);
}
var cfg = config.Value;
MatchmakerUrl = cfg.MatchmakerUrl;
AdminUrl = cfg.AdminUrl;
// Migrate legacy WebhookUrl if present
MigrateLegacy(cfg);
}
// Fall back to config.json values
cfg ??= config.Value;
// Migrate legacy WebhookUrl to new fields if present and new fields are empty
MigrateLegacy(cfg);
MatchmakerUrl = cfg.MatchmakerUrl;
AdminUrl = cfg.AdminUrl;
}
public string MatchmakerUrl { get; set; } = string.Empty;
@@ -65,29 +39,16 @@ public sealed class DiscordWebhookStore
AdminUrl = AdminUrl,
};
public async ValueTask SaveAsync()
{
if (!await _saveLock.WaitAsync(0))
{
return;
}
protected override DiscordWebhookConfig GetSnapshot() => Snapshot;
try
{
var json = JsonSerializer.Serialize(Snapshot, JsonOpts);
await File.WriteAllTextAsync(ConfigFile, json);
_logger.LogInformation("DiscordWebhookSaved to {File}", ConfigFile);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "DiscordWebhookFailed to save {File}", ConfigFile);
}
finally
{
_saveLock.Release();
}
protected override void ApplySnapshot(DiscordWebhookConfig data)
{
MatchmakerUrl = data.MatchmakerUrl;
AdminUrl = data.AdminUrl;
}
public new async ValueTask SaveAsync() => await base.SaveAsync();
private static void MigrateLegacy(DiscordWebhookConfig cfg)
{
if (!string.IsNullOrWhiteSpace(cfg.WebhookUrl))
@@ -0,0 +1,58 @@
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Empostor.Api.Config;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Empostor.Server.Service.Api;
public sealed class HplpStore : JsonDataStore<HplpConfig>
{
public HplpStore(ILogger<HplpStore> logger, IOptions<HplpConfig> config)
: base(logger, legacyPath: "hplp.json")
{
JsonOpts.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
// Try loading from persisted file
Load();
// If no persisted data, fall back to config.json defaults
if (string.IsNullOrEmpty(RegionId) && string.IsNullOrEmpty(RegionName) && string.IsNullOrEmpty(PublicUrl))
{
var cfg = config.Value;
Enabled = cfg.Enabled;
RegionId = cfg.RegionId;
RegionName = cfg.RegionName;
PublicUrl = cfg.PublicUrl;
}
}
public bool Enabled { get; set; }
public string RegionId { get; set; } = "default";
public string RegionName { get; set; } = "Empostor Server";
public string PublicUrl { get; set; } = string.Empty;
public HplpConfig Snapshot => new()
{
Enabled = Enabled,
RegionId = RegionId,
RegionName = RegionName,
PublicUrl = PublicUrl,
};
protected override HplpConfig GetSnapshot() => Snapshot;
protected override void ApplySnapshot(HplpConfig data)
{
Enabled = data.Enabled;
RegionId = data.RegionId;
RegionName = data.RegionName;
PublicUrl = data.PublicUrl;
}
public new async ValueTask SaveAsync() => await base.SaveAsync();
}
@@ -1,12 +1,11 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
namespace Empostor.Server.Service.Stat;
@@ -28,22 +27,15 @@ public sealed class PlayerLogEntry
public string? Detail { get; init; }
}
public sealed class PlayerLogStore : IDisposable
public sealed class PlayerLogStore : JsonDataStore<List<PlayerLogEntry>>
{
private static readonly string DataDir = Path.Combine(Directory.GetCurrentDirectory(), "Data");
private static readonly string LogsFile = Path.Combine(DataDir, "player_logs.json");
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
private const int MaxEntries = 10000;
private readonly ILogger<PlayerLogStore> _logger;
private readonly ConcurrentQueue<PlayerLogEntry> _entries = new();
private readonly SemaphoreSlim _saveLock = new(1, 1);
private int _count;
public PlayerLogStore(ILogger<PlayerLogStore> logger)
: base(logger, legacyPath: "Data/player_logs.json")
{
_logger = logger;
Load();
}
@@ -68,7 +60,7 @@ public sealed class PlayerLogStore : IDisposable
Interlocked.Decrement(ref _count);
}
SaveAsync();
SaveFireAndForget();
}
public List<PlayerLogEntry> GetAll() => _entries.ToList();
@@ -92,64 +84,22 @@ public sealed class PlayerLogStore : IDisposable
return Encoding.UTF8.GetBytes(json);
}
private void SaveAsync()
{
_ = Task.Run(async () =>
{
if (!await _saveLock.WaitAsync(0))
{
return;
}
protected override List<PlayerLogEntry> GetSnapshot() => _entries.ToList();
try
{
Directory.CreateDirectory(DataDir);
var list = _entries.ToList();
await File.WriteAllTextAsync(LogsFile, JsonSerializer.Serialize(list, JsonOpts));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "PlayerLogFailed to save logs");
}
finally
{
_saveLock.Release();
}
});
}
private void Load()
protected override void ApplySnapshot(List<PlayerLogEntry> data)
{
if (!File.Exists(LogsFile))
_entries.Clear();
_count = 0;
foreach (var entry in data)
{
return;
_entries.Enqueue(entry);
Interlocked.Increment(ref _count);
}
try
while (_count > MaxEntries)
{
var list = JsonSerializer.Deserialize<List<PlayerLogEntry>>(File.ReadAllText(LogsFile));
if (list != null)
{
foreach (var entry in list)
{
_entries.Enqueue(entry);
Interlocked.Increment(ref _count);
}
while (_count > MaxEntries)
{
_entries.TryDequeue(out _);
Interlocked.Decrement(ref _count);
}
_logger.LogInformation("PlayerLogLoaded {Count} log(s)", _count);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "PlayerLogFailed to load logs file");
_entries.TryDequeue(out _);
Interlocked.Decrement(ref _count);
}
}
public void Dispose() => _saveLock.Dispose();
}
@@ -1,11 +1,8 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Empostor.Api.Service;
using Microsoft.Extensions.Logging;
namespace Empostor.Server.Service.Stat;
@@ -37,24 +34,13 @@ public sealed class PlayerStatsEntry
public DateTime LastSeen { get; set; } = DateTime.UtcNow;
}
public sealed class PlayerStatsStore : IDisposable
public sealed class PlayerStatsStore : JsonDataStore<List<PlayerStatsEntry>>
{
private static readonly string DataDir =
Path.Combine(Directory.GetCurrentDirectory(), "Data");
private static readonly string StatsFile =
Path.Combine(DataDir, "player_stats.json");
private static readonly JsonSerializerOptions JsonOpts =
new() { WriteIndented = true };
private readonly ILogger<PlayerStatsStore> _logger;
private readonly SemaphoreSlim _saveLock = new(1, 1);
private ConcurrentDictionary<string, PlayerStatsEntry> _stats = new(StringComparer.OrdinalIgnoreCase);
public PlayerStatsStore(ILogger<PlayerStatsStore> logger)
: base(logger, legacyPath: "Data/player_stats.json")
{
_logger = logger;
Load();
}
@@ -78,28 +64,28 @@ public sealed class PlayerStatsStore : IDisposable
{
var e = GetOrCreate(friendCode);
e.Kills++;
SaveAsync();
SaveFireAndForget();
}
public void RecordDeath(string friendCode)
{
var e = GetOrCreate(friendCode);
e.Deaths++;
SaveAsync();
SaveFireAndForget();
}
public void RecordTaskCompleted(string friendCode)
{
var e = GetOrCreate(friendCode);
e.TasksCompleted++;
SaveAsync();
SaveFireAndForget();
}
public void RecordExile(string friendCode)
{
var e = GetOrCreate(friendCode);
e.TimesExiled++;
SaveAsync();
SaveFireAndForget();
}
public void RecordGameEnd(string friendCode, string? name, bool isCrewmateWin, bool wasImpostor)
@@ -125,7 +111,7 @@ public sealed class PlayerStatsStore : IDisposable
}
}
SaveAsync();
SaveFireAndForget();
}
public List<PlayerStatsEntry> GetAll()
@@ -134,52 +120,17 @@ public sealed class PlayerStatsStore : IDisposable
public void ClearAll()
{
_stats.Clear();
SaveAsync();
SaveFireAndForget();
}
private void SaveAsync()
protected override List<PlayerStatsEntry> GetSnapshot() => _stats.Values.ToList();
protected override void ApplySnapshot(List<PlayerStatsEntry> data)
{
_ = Task.Run(async () =>
{
if (!await _saveLock.WaitAsync(0))
{
return;
}
try
{
Directory.CreateDirectory(DataDir);
var list = _stats.Values.ToList();
await File.WriteAllTextAsync(StatsFile, JsonSerializer.Serialize(list, JsonOpts));
}
catch (Exception ex) { _logger.LogWarning(ex, "PlayerStatsFailed to save stats"); }
finally { _saveLock.Release(); }
});
}
private void Load()
{
if (!File.Exists(StatsFile))
{
_logger.LogDebug("PlayerStatsNo stats file found, starting fresh");
return;
}
try
{
var list = JsonSerializer.Deserialize<List<PlayerStatsEntry>>(File.ReadAllText(StatsFile));
if (list != null)
{
_stats = new ConcurrentDictionary<string, PlayerStatsEntry>(
list.ToDictionary(e => e.FriendCode, StringComparer.OrdinalIgnoreCase));
_logger.LogInformation("PlayerStatsLoaded {Count} player stats", _stats.Count);
}
}
catch (Exception ex) { _logger.LogWarning(ex, "PlayerStatsFailed to load stats file"); }
_stats = new ConcurrentDictionary<string, PlayerStatsEntry>(
data.ToDictionary(e => e.FriendCode, StringComparer.OrdinalIgnoreCase));
}
private static string Normalize(string friendCode)
=> (friendCode ?? string.Empty).Trim();
public void Dispose() => _saveLock.Dispose();
}
+6
View File
@@ -53,6 +53,12 @@
"Password": "",
"MarketplaceUrl": "https://raw.githubusercontent.com/Empostor/Empostor/master/marketplace/plugins.json"
},
"HPLP": {
"Enabled": false,
"RegionId": "default",
"RegionName": "Empostor Server",
"PublicUrl": ""
},
"DiscordWebhook": {
"MatchmakerUrl": "",
"AdminUrl": ""
+6
View File
@@ -58,6 +58,12 @@
"resolved": "8.0.1",
"contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[8.0.28, )",
"resolved": "8.0.28",
"contentHash": "XMqgVjlLxLqWmEh3c49haXLQwsMNtvo6YscUaqfvEGfg1iA8hnYgkUVq3i9Zu9gKeNKMWiiZKVwZExc/qyEAsQ=="
},
"Serilog.Extensions.Hosting": {
"type": "Direct",
"requested": "[8.0.0, )",