Files
Mocha-2023-RecRoom-Server/Controllers/LegacyLeaderboardController.cs
T
mochaisvibecoded 42f7f57b72 :3
2026-08-24 17:00:26 -04:00

498 lines
16 KiB
C#

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<LeaderboardStat> Stats =
LeaderboardDb.GetCollection<LeaderboardStat>("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<IActionResult> 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<IActionResult> 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<RankedStat> 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<IActionResult> 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<RankedStat> ranked = GetRankedStats(request);
int playerIndex = ranked.FindIndex(
x => x.AccountId == requestedPlayerId);
if (playerIndex < 0)
{
return Ok(new NearbyScoresResponse
{
Results = new List<LeaderboardEntry>()
});
}
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<LeaderboardEntry> 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<IActionResult> 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<LeaderboardEntry> results = GetRankedStats(request)
.Where(x => x.Rank >= rankStart && x.Rank <= rankEnd)
.Select(ToEntry)
.ToList();
return Ok(new NearbyScoresResponse
{
Results = results
});
}
private static List<RankedStat> GetRankedStats(RankRequest request)
{
List<LeaderboardStat> rows;
lock (DbLock)
{
rows = Stats.Find(x =>
x.RoomId == request.RoomId &&
x.StatChannel == request.StatChannel)
.ToList();
}
IOrderedEnumerable<LeaderboardStat> 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<RequestValues> ReadRequestValuesAsync()
{
var values = new Dictionary<string, string>(
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<LeaderboardEntry> 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<string, string> _values;
public RequestValues(
IReadOnlyDictionary<string, string> 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
};
}
}
}
}