using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using DorkNet.Server.Auth; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; using DorkNet.Server.Services; namespace DorkNet.Server.Controllers.API.Leaderboard; /// /// leaderboard.{rec.net,localhost}/leaderboard/* — per-stat-channel /// leaderboard reads + writes. URLs verified against /// Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:1934, 2416, 2700: /// /// POST leaderboard/SetStat body: SetStatRequestDTO /// {StatChannel(int), RoomId(long), StatValue(int)} /// POST leaderboard/GetPlayerRank body: GetPlayerRankData /// {PlayerId(int), StatChannel(int), RoomId(long)} /// /// **Subdomain matters**: the client uses /// Core.SendRequest(method, Service.Leaderboard, requestUri) /// where Service.Leaderboard = 9 resolves via the /// RecRoomConfig.ServiceUrls map (we serve via /// api/config/v2) to https://leaderboard.{apex}. So /// the actual outbound URL is leaderboard.localhost/leaderboard/SetStat /// — not api.localhost/api/leaderboard/.... /// /// Response shapes: /// SetStat → RequestResult ack {success:true,error:""} /// GetPlayerRank → FullLeaderboard.Entry — camelCase per /// FullLeaderboard_NestedType_Entry.txt:111-121: /// {playerId(int), score(long), rank(int)}. /// /// Without explicit subdomain routing the request fell through to /// which returns []; /// the client's Util.Deserialize<Entry> tried to cast /// list-as-dictionary → /// InvalidCastException: Unable to cast 'List' to 'Dictionary' /// → "Failed to retrieve leaderboard: Malformed Response". /// /// We bind both the leaderboard subdomain (canonical) AND the api /// host (defensive — some 2020 builds may proxy via api/) so either /// path lands here. /// [ApiController] public class LeaderboardController( DorkNetDbContext db, PlayerPresenceService playerPresence) : ControllerBase { public sealed class SetStatRequest { public int StatChannel { get; set; } public long RoomId { get; set; } public int StatValue { get; set; } } /// POST /leaderboard/SetStat on the leaderboard /// subdomain (canonical client path) AND /api/leaderboard/SetStat /// on the api host (legacy / proxy fallback). /// /// **Wire semantics**: the watch's /// Leaderboards.ConditionalSetStat_ReturningNewValue /// (Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:645) handles the /// per-channel direction (high-score vs low-time-better) LOCALLY /// via a SetMode enum and only POSTs to SetStat when the /// new value should replace the cached one. The wire request /// itself carries no direction hint — it's just /// {StatChannel, RoomId, StatValue}. So the server must /// trust the client and assign unconditionally; an earlier /// Math.Max here silently kept the LARGEST value, which /// broke channels like StuntRunner (channel 7, low elapsed time /// wins) where the watch posts the new best and we'd discard it /// for any prior worse value. [HttpPost("/leaderboard/SetStat")] [HttpPost("/api/leaderboard/SetStat")] [Authorize] public async Task SetStat([FromBody] SetStatRequest req) { var pid = this.RequireCurrentPlayerId(); var roomId = ResolveRoomId(req.RoomId, pid); var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel); if (row is null) { row = new LeaderboardStatEntity { PlayerId = pid, RoomId = roomId, StatChannel = req.StatChannel, Value = req.StatValue, }; db.LeaderboardStats.Add(row); } else { row.Value = req.StatValue; row.UpdatedAt = DateTime.UtcNow; } // Auto-attach the channel to whichever room the score came // from so every room implicitly gets its own leaderboards. // The watch's SetStatRequestDTO carries // RoomId (see Cpp2IL_ISIL .../SetStatRequestDTO); // when it's zero (rare — direct stat report outside a room // context) fall back to the player's current presence so a // stray score still gets bucketed somewhere visible. if (roomId > 0) { var meta = await db.LeaderboardChannelMeta .FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == req.StatChannel); if (meta is null) { // First time this channel reports. Stamp a meta row so // the admin SPA's per-room Leaderboards tab surfaces it // immediately, with a placeholder name the admin can // rename later. Direction defaults to higher-is-better; // admin flips the toggle for time-trial channels. db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity { Channel = req.StatChannel, RoomId = roomId, Name = $"Channel {req.StatChannel}", LowerIsBetter = false, ValueFormat = "count", }); } } await db.SaveChangesAsync(); return Ok(new { success = true, error = "" }); } /// POST /leaderboard/CheckAndSetStat — the compare-and-set /// form of , and the ONLY stat-write route the /// 2023-03-21 client has: that build contains no SetStat literal at /// all. Nothing registered this route, so every leaderboard score written /// by a 2023 client fell through to the catch-all and was lost. /// /// The request is RecNet.SetStatRequestDTO, whose property names are /// NOT obfuscated in the dump: StatChannel (Int32), RoomId /// (Int64), StatValue (Int32) and the extra /// CurrentStatValue (Int32?) that distinguishes this route from /// plain SetStat. /// /// CurrentStatValue is the value the client believes is currently /// stored. It is an optimistic-concurrency guard: the write applies only if /// the server agrees, which stops a stale session from clobbering a newer /// score. A null means "no expectation" and writes unconditionally, and a /// mismatch is reported rather than silently dropped so the client can /// re-read. /// /// As with SetStat, the client has already decided the new value should win /// (it applies the per-channel higher/lower-is-better rule locally), so the /// server does not second-guess the direction. [HttpPost("/leaderboard/CheckAndSetStat")] [HttpPost("/api/leaderboard/CheckAndSetStat")] [Authorize] public async Task CheckAndSetStat([FromBody] CheckAndSetStatRequest req) { var pid = this.RequireCurrentPlayerId(); var roomId = ResolveRoomId(req.RoomId, pid); var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel); if (req.CurrentStatValue is int expected && row is not null && row.Value != expected) { return Ok(new { success = false, error = "stat_value_mismatch", StatValue = row.Value, CurrentStatValue = row.Value, }); } if (row is null) { row = new LeaderboardStatEntity { PlayerId = pid, RoomId = roomId, StatChannel = req.StatChannel, Value = req.StatValue, }; db.LeaderboardStats.Add(row); } else { row.Value = req.StatValue; row.UpdatedAt = DateTime.UtcNow; } await EnsureChannelMetaAsync(roomId, req.StatChannel); await db.SaveChangesAsync(); return Ok(new { success = true, error = "", StatValue = row.Value, CurrentStatValue = row.Value, }); } public sealed class CheckAndSetStatRequest { public int StatChannel { get; set; } public long RoomId { get; set; } public int StatValue { get; set; } /// The value the client believes is stored. Null = write /// unconditionally. public int? CurrentStatValue { get; set; } } /// Stamp a placeholder channel-meta row the first time a channel /// reports, so the admin SPA's per-room Leaderboards tab surfaces it. private async Task EnsureChannelMetaAsync(long roomId, int statChannel) { if (roomId <= 0) return; var meta = await db.LeaderboardChannelMeta .FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == statChannel); if (meta is not null) return; db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity { Channel = statChannel, RoomId = roomId, Name = $"Channel {statChannel}", LowerIsBetter = false, ValueFormat = "count", }); } public sealed class GetPlayerRankRequest { public int PlayerId { get; set; } public int StatChannel { get; set; } public long RoomId { get; set; } } [HttpPost("/leaderboard/GetPlayerRank")] [HttpPost("/api/leaderboard/GetPlayerRank")] public async Task GetPlayerRank([FromBody] GetPlayerRankRequest req) { var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0; // Empty/no-row case: return a zero entry. The client tolerates // this — `Entry.Deserialize` reads playerId/score/rank as // primitives with defaults. if (pid == 0) return Ok(new { playerId = 0, score = 0L, rank = 0 }); var roomId = ResolveRoomId(req.RoomId, pid); var row = await db.LeaderboardStats.FirstOrDefaultAsync(s => s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel); if (row is null) return Ok(new { playerId = (int)pid, score = 0L, rank = 0 }); var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel); var betterCount = await db.LeaderboardStats .CountAsync(s => s.RoomId == roomId && s.StatChannel == req.StatChannel && (lowerIsBetter ? s.Value < row.Value : s.Value > row.Value)); return Ok(new { playerId = (int)pid, score = row.Value, rank = betterCount + 1, }); } public sealed class GetNearbyScoresRequest { public int PlayerId { get; set; } public int StatChannel { get; set; } public long RoomId { get; set; } public int WindowSize { get; set; } } /// POST /leaderboard/GetNearbyScores — returns the /// N entries on either side of the caller's rank. Wire shape per /// GetNearbyScoresRequestDTO.cs: PlayerId/StatChannel/RoomId /// inherited from GetRankRequestDTO + WindowSize. [HttpPost("/leaderboard/GetNearbyScores")] [HttpPost("/api/leaderboard/GetNearbyScores")] public async Task GetNearbyScores([FromBody] GetNearbyScoresRequest req) { var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0; var window = Math.Clamp(req.WindowSize, 1, 50); var roomId = ResolveRoomId(req.RoomId, pid); var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel); var query = db.LeaderboardStats .Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel); query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value); var all = await query.Select(s => new { s.PlayerId, s.Value }).ToListAsync(); if (all.Count == 0) { // Empty case must still come back as a SingleLeaderboard // Dictionary, not a bare array — the 2020 watch's // SingleLeaderboard.Deserialize reads dict["rows"] // (Cpp2IL_ISIL/.../RecNet/SingleLeaderboard.txt:60). // Returning `[]` cast-fails as List→Dictionary inside // RecNet.Util.Deserialize, logged repeatedly as // "Received malformed RecNet response: '[]'" and // eventually starves the StuntRunner UI of a usable // leaderboard. return Ok(new { rows = Array.Empty() }); } var ranked = all.Select((r, i) => new { playerId = (int)r.PlayerId, score = r.Value, rank = i + 1, }).ToList(); var myIdx = ranked.FindIndex(r => r.playerId == (int)pid); if (myIdx < 0) { return Ok(new { rows = ranked.Take(window * 2 + 1).ToArray() }); } var lo = Math.Max(0, myIdx - window); var hi = Math.Min(ranked.Count - 1, myIdx + window); return Ok(new { rows = ranked.GetRange(lo, hi - lo + 1).ToArray() }); } public sealed class GetRanksRequest { public int PlayerId { get; set; } public int StatChannel { get; set; } public long RoomId { get; set; } public int RankStart { get; set; } public int RankEnd { get; set; } } /// POST /leaderboard/GetRanks — returns a slice of /// the leaderboard between RankStart and RankEnd (inclusive, /// 1-indexed). Wire shape per GetRanksRequestDTO.cs. [HttpPost("/leaderboard/GetRanks")] [HttpPost("/api/leaderboard/GetRanks")] public async Task GetRanks([FromBody] GetRanksRequest req) { var start = Math.Max(1, req.RankStart); var end = Math.Max(start, req.RankEnd); var skip = start - 1; var take = Math.Clamp(end - skip, 1, 200); var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0; var roomId = ResolveRoomId(req.RoomId, pid); var lowerIsBetter = await IsLowerIsBetterAsync(roomId, req.StatChannel); var query = db.LeaderboardStats .Where(s => s.RoomId == roomId && s.StatChannel == req.StatChannel); query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value); var rows = await query.Skip(skip).Take(take).ToListAsync(); var entries = rows.Select((s, i) => new { playerId = (int)s.PlayerId, score = s.Value, rank = start + i, }).ToArray(); // SingleLeaderboard wrapper — see GetNearbyScores above. return Ok(new { rows = entries }); } /// Top-N for a channel. Used by the admin tooling and /// by the watch's full-leaderboard scoreboard view. [HttpGet("/leaderboard/Top")] [HttpGet("/api/leaderboard/Top")] public async Task Top([FromQuery] int channel, [FromQuery] long roomId = 0, [FromQuery] int take = 50) { take = Math.Clamp(take, 1, 200); var resolvedRoomId = ResolveRoomId(roomId, this.CurrentPlayerId() ?? 0); var lowerIsBetter = await IsLowerIsBetterAsync(resolvedRoomId, channel); var query = db.LeaderboardStats .Where(s => s.RoomId == resolvedRoomId && s.StatChannel == channel); query = lowerIsBetter ? query.OrderBy(s => s.Value) : query.OrderByDescending(s => s.Value); var rows = await query.Take(take).ToListAsync(); // FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic / // FriendsOverall / FriendsPeriodic + NextResetUTC. Use the // same Entry list for all four buckets — friends-vs-global // can come later. var entries = rows.Select((s, i) => new { playerId = (int)s.PlayerId, score = s.Value, rank = i + 1, }).ToArray(); return Ok(new { GlobalOverall = entries, GlobalPeriodic = entries, FriendsOverall = Array.Empty(), FriendsPeriodic = Array.Empty(), NextResetUTC = DateTime.UtcNow.AddDays(7), }); } private long ResolveRoomId(long requestedRoomId, long playerId) { if (requestedRoomId > 0) return requestedRoomId; if (playerId > 0) return playerPresence.GetRoom(playerId)?.RoomId ?? 0; return 0; } private async Task IsLowerIsBetterAsync(long roomId, int channel) { var live = await db.LeaderboardChannelMeta .Where(c => c.RoomId == roomId && c.Channel == channel) .Select(c => (bool?)c.LowerIsBetter) .FirstOrDefaultAsync(); return live ?? false; } }