using LiteDB; using static Campus_live_rewrite.api.server.Classes.db_class.LeaderboardDBClasses; namespace Campus_live_rewrite.api.dbs { public class LeaderboardDB { private static readonly LiteDatabase DB = new LiteDatabase( Environment.CurrentDirectory + "/Data/DBs/Leaderboard.db" ); // Merge this store's write-ahead log back into the data file. // See DbMaintenance for why this must be called explicitly. public static void Checkpoint() => DB.Checkpoint(); private static ILiteCollection Entries => DB.GetCollection("leaderboard"); static LeaderboardDB() { // Short index names to avoid LiteDB 32-char name limit Entries.EnsureIndex("pid", x => x.PlayerId); Entries.EnsureIndex("sc", x => x.StatChannel); Entries.EnsureIndex("rid", x => x.RoomId); // Score index for sorting Entries.EnsureIndex("score", x => x.Score); } // ------------------------------------------ // Add or Update Stat // ------------------------------------------ // `expected` is the client's CurrentStatValue. The reference honours it as an optimistic // compare-and-set (only overwrite if the row still holds that value), but that silently // DROPPED real updates here: a returning player's client sends CurrentStatValue 0 (fresh // session, hasn't re-read the board) while the server still holds their old score, so the // compare failed and their new score got STUCK. We take the value UNCONDITIONALLY instead // (the reference's CurrentStatValue=null path) so a score always records; `expected` is only // logged when it disagreed. The endpoint answers 0 either way. public static Entry? GetEntry(ulong playerId, int statChannel, ulong roomId) => Entries.FindOne(x => x.PlayerId == (int)playerId && x.StatChannel == statChannel && x.RoomId == (long)roomId); public static RequestResult SetStat(ulong playerId, int statChannel, ulong roomId, int newValue, int? expected = null) { try { var entry = GetEntry(playerId, statChannel, roomId); var now = DateTime.UtcNow; if (entry == null) { entry = new Entry { PlayerId = (int)playerId, StatChannel = statChannel, RoomId = (long)roomId, Score = newValue, UpdatedAt = now, DayGainDate = now.Date, DayGain = Math.Abs((long)newValue) }; Entries.Insert(entry); } else { if (expected.HasValue && entry.Score != expected.Value) Console.WriteLine($"[leaderboard] {playerId} sc{statChannel} room{roomId}: CurrentStatValue {expected} != stored {entry.Score} - writing {newValue} anyway (would have stuck otherwise)"); long gain = Math.Abs((long)newValue - entry.Score); if (entry.DayGainDate != now.Date) { entry.DayGainDate = now.Date; entry.DayGain = 0; } entry.DayGain += gain; entry.UpdatedAt = now; entry.Score = newValue; Entries.Update(entry); } // Rank is computed at READ time now (see OrderedBoard), so there is nothing to // recompute on write - and doing it descending-only here was the bug. return RequestResult.Success; } catch { return RequestResult.InvalidStat; } } // ------------------------------------------ // The board's entries in RANK ORDER, computed for THIS read. // // Order is decided by the caller's SortAscending, exactly as RecFlare's leaderboard // does it: descending (highest first) by default, ascending (lowest first) for a // lowest-wins stat like a race time, with ties broken on the LOWER player id either // way so the order is stable. Rank (1-based) is assigned by position here, at READ // time - so the SAME stored board reads correctly in either direction. // // This replaced a Rank baked DESCENDING-ONLY into every row at write time: every // ascending stat (times, fewest-deaths) was ranked backwards - the WORST score shown // as #1 - and no read could get the right order because the direction was already // frozen the wrong way. private static List OrderedBoard(int statChannel, ulong roomId, bool sortAscending) { var list = Entries.Find(x => x.StatChannel == statChannel && x.RoomId == (long)roomId).ToList(); var ordered = (sortAscending ? list.OrderBy(x => x.Score) : list.OrderByDescending(x => x.Score)) .ThenBy(x => x.PlayerId) // ties: lower id first, either direction .ToList(); // Ranks are 0-BASED (RecFlare/live-service convention): the client ADDS ONE before it // draws, so Rank 0 renders as "#1". Assigning i+1 here made every board show everyone // one place too low (the winner as "#2"). for (int i = 0; i < ordered.Count; i++) ordered[i].Rank = i; return ordered; } // ------------------------------------------ // Get Player Rank // ------------------------------------------ public static Entry? GetPlayerRank(ulong playerId, int statChannel, ulong roomId, bool sortAscending = false) { return OrderedBoard(statChannel, roomId, sortAscending) .FirstOrDefault(x => x.PlayerId == (int)playerId); } // ------------------------------------------ // Nearby Scores Window - windowSize entries either side of the caller. // ------------------------------------------ public static List GetNearbyScores( ulong playerId, int statChannel, ulong roomId, int windowSize, bool sortAscending = false) { var board = OrderedBoard(statChannel, roomId, sortAscending); int myIndex = board.FindIndex(x => x.PlayerId == (int)playerId); if (myIndex < 0) return board.Take(windowSize * 2 + 1).ToList(); // caller not on the board yet: top slice int start = Math.Max(myIndex - windowSize, 0); int end = Math.Min(myIndex + windowSize, board.Count - 1); return board.GetRange(start, end - start + 1); } // ------------------------------------------ // Range Get - GetRanks API. RankStart/RankEnd are 0-based, inclusive (the client // sends RankStart=0, RankEnd=9 for the top ten). // ------------------------------------------ public static List GetRanks( int statChannel, ulong roomId, int rankStart, int rankEnd, bool sortAscending = false) { var board = OrderedBoard(statChannel, roomId, sortAscending); int start = Math.Max(rankStart, 0); int count = Math.Max(rankEnd - start + 1, 0); return board.Skip(start).Take(count).ToList(); } } }