using Mocha2023.Auth; using LiteDB; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; namespace Mocha2023.Controllers { [ApiController] [Mocha2023.Classes.ApiProtection] public sealed class LegacyLeaderboardController : ControllerBase { private static readonly object DbLock = new(); private static readonly LiteDatabase LeaderboardDb = OpenDatabase(); private static readonly ILiteCollection Stats = LeaderboardDb.GetCollection("LeaderboardStats"); static LegacyLeaderboardController() { Stats.EnsureIndex(x => x.RoomId); Stats.EnsureIndex(x => x.StatChannel); Stats.EnsureIndex(x => x.AccountId); } [HttpPost("/leaderboard/CheckAndSetStat")] [HttpPost("/api/Leaderboard/CheckAndSetStat")] public async Task CheckAndSetStat() { long? authenticatedAccountId = AuthStuff.GetPlayerId(Request); if (authenticatedAccountId == null) return Unauthorized(); RequestValues values; try { values = await ReadRequestValuesAsync(); } catch (JsonException exception) { Console.WriteLine( $"[LEADERBOARD] Invalid CheckAndSetStat JSON: {exception.Message}"); return BadRequest((int)LeaderboardResult.InvalidStat); } int statChannel = values.GetInt32("StatChannel"); long roomId = values.GetInt64("RoomId"); int statValue = values.GetInt32("StatValue"); int? expectedCurrentValue = values.GetNullableInt32("CurrentStatValue"); if (statChannel < 0 || roomId < 0) return Ok((int)LeaderboardResult.InvalidStat); long accountId = authenticatedAccountId.Value; string id = CreateStatId(roomId, statChannel, accountId); lock (DbLock) { LeaderboardStat? existing = Stats.FindById(id); int currentValue = existing?.Score ?? 0; if (existing != null && expectedCurrentValue.HasValue && expectedCurrentValue.Value != currentValue) { Console.WriteLine( $"[LEADERBOARD CAS] rejected stale update " + $"account={accountId} room={roomId} channel={statChannel} " + $"expected={expectedCurrentValue.Value} actual={currentValue}"); return Ok((int)LeaderboardResult.Success); } var row = existing ?? new LeaderboardStat { Id = id, AccountId = accountId, RoomId = roomId, StatChannel = statChannel }; row.Score = statValue; row.UpdatedAt = DateTime.UtcNow; Stats.Upsert(row); } Console.WriteLine( $"[LEADERBOARD SET] account={accountId} room={roomId} " + $"channel={statChannel} score={statValue}"); return Ok((int)LeaderboardResult.Success); } [HttpPost("/leaderboard/GetPlayerRank")] [HttpPost("/api/Leaderboard/GetPlayerRank")] public async Task GetPlayerRank() { long? authenticatedAccountId = AuthStuff.GetPlayerId(Request); if (authenticatedAccountId == null) return Unauthorized(); RankRequest request; try { request = RankRequest.From(await ReadRequestValuesAsync()); } catch (JsonException exception) { Console.WriteLine( $"[LEADERBOARD] Invalid GetPlayerRank JSON: {exception.Message}"); return BadRequest(); } if (request.StatChannel < 0 || request.RoomId < 0) return BadRequest(); long requestedPlayerId = request.PlayerId > 0 ? request.PlayerId : authenticatedAccountId.Value; List ranked = GetRankedStats(request); RankedStat? player = ranked.FirstOrDefault( x => x.AccountId == requestedPlayerId); return Ok(new PlayerRankResponse { Rank = player?.Rank ?? 0, Score = player?.Score ?? 0 }); } [HttpPost("/leaderboard/GetNearbyScores")] [HttpPost("/api/Leaderboard/GetNearbyScores")] public async Task GetNearbyScores() { long? authenticatedAccountId = AuthStuff.GetPlayerId(Request); if (authenticatedAccountId == null) return Unauthorized(); RankRequest request; try { RequestValues values = await ReadRequestValuesAsync(); request = RankRequest.From(values); request.WindowSize = Math.Clamp( values.GetInt32("WindowSize", 32), 1, 100); } catch (JsonException exception) { Console.WriteLine( $"[LEADERBOARD] Invalid GetNearbyScores JSON: {exception.Message}"); return BadRequest(); } if (request.StatChannel < 0 || request.RoomId < 0) return BadRequest(); long requestedPlayerId = request.PlayerId > 0 ? request.PlayerId : authenticatedAccountId.Value; List ranked = GetRankedStats(request); int playerIndex = ranked.FindIndex( x => x.AccountId == requestedPlayerId); if (playerIndex < 0) { return Ok(new NearbyScoresResponse { Results = new List() }); } int halfWindow = request.WindowSize / 2; int start = Math.Max(0, playerIndex - halfWindow); if (start + request.WindowSize > ranked.Count) start = Math.Max(0, ranked.Count - request.WindowSize); List results = ranked .Skip(start) .Take(request.WindowSize) .Select(ToEntry) .ToList(); return Ok(new NearbyScoresResponse { Results = results }); } [HttpPost("/leaderboard/GetRanks")] [HttpPost("/api/Leaderboard/GetRanks")] public async Task GetRanks() { long? authenticatedAccountId = AuthStuff.GetPlayerId(Request); if (authenticatedAccountId == null) return Unauthorized(); RankRequest request; int rankStart; int rankEnd; try { RequestValues values = await ReadRequestValuesAsync(); request = RankRequest.From(values); rankStart = Math.Max(1, values.GetInt32("RankStart", 1)); rankEnd = Math.Max(rankStart, values.GetInt32("RankEnd", rankStart + 49)); } catch (JsonException exception) { Console.WriteLine( $"[LEADERBOARD] Invalid GetRanks JSON: {exception.Message}"); return BadRequest(); } if (request.StatChannel < 0 || request.RoomId < 0) return BadRequest(); rankEnd = Math.Min(rankEnd, rankStart + 99); List results = GetRankedStats(request) .Where(x => x.Rank >= rankStart && x.Rank <= rankEnd) .Select(ToEntry) .ToList(); return Ok(new NearbyScoresResponse { Results = results }); } private static List GetRankedStats(RankRequest request) { List rows; lock (DbLock) { rows = Stats.Find(x => x.RoomId == request.RoomId && x.StatChannel == request.StatChannel) .ToList(); } IOrderedEnumerable ordered = request.SortAscending ? rows.OrderBy(x => x.Score) .ThenBy(x => x.UpdatedAt) .ThenBy(x => x.AccountId) : rows.OrderByDescending(x => x.Score) .ThenBy(x => x.UpdatedAt) .ThenBy(x => x.AccountId); return ordered .Select((row, index) => new RankedStat { AccountId = row.AccountId, Rank = index + 1, Score = row.Score }) .ToList(); } private static LeaderboardEntry ToEntry(RankedStat stat) => new() { PlayerId = SafePlayerId(stat.AccountId), Rank = stat.Rank, Score = stat.Score }; private static int SafePlayerId(long accountId) { if (accountId <= 0) return 0; return accountId > int.MaxValue ? int.MaxValue : (int)accountId; } private static string CreateStatId( long roomId, int statChannel, long accountId) => FormattableString.Invariant( $"{roomId}:{statChannel}:{accountId}"); private static LiteDatabase OpenDatabase() { string dbDirectory = Path.Combine(Program.dataDir, "DBs"); Directory.CreateDirectory(dbDirectory); return new LiteDatabase( Path.Combine(dbDirectory, "Leaderboards.db")); } private async Task ReadRequestValuesAsync() { var values = new Dictionary( StringComparer.OrdinalIgnoreCase); if (Request.HasFormContentType) { IFormCollection form = await Request.ReadFormAsync(); foreach (string key in form.Keys) values[key] = form[key].FirstOrDefault() ?? string.Empty; return new RequestValues(values); } using var reader = new StreamReader(Request.Body); string rawBody = await reader.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(rawBody)) return new RequestValues(values); using JsonDocument document = JsonDocument.Parse(rawBody); if (document.RootElement.ValueKind != JsonValueKind.Object) throw new JsonException("Expected a JSON object."); foreach (JsonProperty property in document.RootElement.EnumerateObject()) { values[property.Name] = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : property.Value.GetRawText(); } return new RequestValues(values); } private enum LeaderboardResult { Success = 0, InvalidStat = 1, RedisConnectionError = 2 } private sealed class LeaderboardStat { [BsonId] public string Id { get; set; } = string.Empty; public long AccountId { get; set; } public long RoomId { get; set; } public int StatChannel { get; set; } public int Score { get; set; } public DateTime UpdatedAt { get; set; } } private sealed class RankRequest { public long PlayerId { get; set; } public int StatChannel { get; set; } public long RoomId { get; set; } public int FilterType { get; set; } public bool SortAscending { get; set; } public int WindowSize { get; set; } = 32; public static RankRequest From(RequestValues values) => new() { PlayerId = values.GetInt64("PlayerId"), StatChannel = values.GetInt32("StatChannel"), RoomId = values.GetInt64("RoomId"), FilterType = values.GetInt32("FilterType"), SortAscending = values.GetBoolean("SortAscending") }; } private sealed class RankedStat { public long AccountId { get; set; } public int Rank { get; set; } public int Score { get; set; } } private sealed class PlayerRankResponse { public int Rank { get; set; } public int Score { get; set; } } private sealed class NearbyScoresResponse { public List Results { get; set; } = new(); } private sealed class LeaderboardEntry { public int PlayerId { get; set; } public int Rank { get; set; } public int Score { get; set; } } private sealed class RequestValues { private readonly IReadOnlyDictionary _values; public RequestValues( IReadOnlyDictionary values) { _values = values; } public int GetInt32(string key, int fallback = 0) { return _values.TryGetValue(key, out string? value) && int.TryParse( value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) ? parsed : fallback; } public long GetInt64(string key, long fallback = 0) { return _values.TryGetValue(key, out string? value) && long.TryParse( value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed) ? parsed : fallback; } public int? GetNullableInt32(string key) { if (!_values.TryGetValue(key, out string? value) || string.IsNullOrWhiteSpace(value) || string.Equals( value, "null", StringComparison.OrdinalIgnoreCase)) { return null; } return int.TryParse( value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) ? parsed : null; } public bool GetBoolean(string key, bool fallback = false) { if (!_values.TryGetValue(key, out string? value)) return fallback; if (bool.TryParse(value, out bool parsed)) return parsed; return value switch { "1" => true, "0" => false, _ => fallback }; } } } }