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 row = await db.LeaderboardStats.FirstOrDefaultAsync(s => s.PlayerId == pid && s.StatChannel == req.StatChannel); if (row is null) { row = new LeaderboardStatEntity { PlayerId = pid, 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. var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0); if (roomId > 0) { var meta = await db.LeaderboardChannelMeta .FirstOrDefaultAsync(c => 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", }); } else if (meta.RoomId == 0) { // Channel registered globally (e.g. from the hardcoded // KnownStatChannels fallback or an unscoped admin // entry). Bind it to this room now that we know which // room it came from. Don't re-bind if it already // points at a different room — the admin must be the // one to reassign once a channel has a home. meta.RoomId = roomId; meta.UpdatedAt = DateTime.UtcNow; } } await db.SaveChangesAsync(); return Ok(new { success = true, error = "" }); } 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 row = await db.LeaderboardStats.FirstOrDefaultAsync(s => s.PlayerId == pid && s.StatChannel == req.StatChannel); if (row is null) return Ok(new { playerId = (int)pid, score = 0L, rank = 0 }); var higherCount = await db.LeaderboardStats .CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value); return Ok(new { playerId = (int)pid, score = row.Value, rank = higherCount + 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 all = await db.LeaderboardStats .Where(s => s.StatChannel == req.StatChannel) .OrderByDescending(s => s.Value) .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 rows = await db.LeaderboardStats .Where(s => s.StatChannel == req.StatChannel) .OrderByDescending(s => s.Value) .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] int take = 50) { take = Math.Clamp(take, 1, 200); var rows = await db.LeaderboardStats .Where(s => s.StatChannel == channel) .OrderByDescending(s => s.Value) .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), }); } }