using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public class RatingStore { private const int BaseElo = 1000; private const int MinElo = 100; private readonly IMongoCollection _users; private readonly IMongoCollection _questions; public RatingStore(MongoDatabaseProvider mongo) { _users = mongo.Database.GetCollection("UserRatings"); _questions = mongo.Database.GetCollection("QuestionRatings"); _users.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(r => r.UserId), new CreateIndexOptions { Unique = true })); _questions.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(r => r.QuestionExternalId), new CreateIndexOptions { Unique = true })); } public async Task GetProfileAsync(string userId) { var rating = await GetOrCreateUserRatingAsync(userId); var (tierName, tierDivision) = GetTier(rating.OverallElo); return new RatingProfileResponse { OverallElo = rating.OverallElo, RatedQuestionCount = rating.RatedQuestionCount, CategoryElos = rating.CategoryElos, Tier = tierName, TierDivision = tierDivision }; } private static readonly (string Name, int FloorElo)[] TierBands = [ ("Bronze", MinElo), ("Silver", 1000), ("Gold", 1300), ("Platinum", 1900), ("Diamond", 2200) ]; private static (string Name, int Division) GetTier(int elo) { var bandIndex = 0; for (var i = 0; i < TierBands.Length; i++) { if (elo >= TierBands[i].FloorElo) bandIndex = i; } var band = TierBands[bandIndex]; var nextFloor = bandIndex + 1 < TierBands.Length ? TierBands[bandIndex + 1].FloorElo : band.FloorElo + 300; var bandWidth = nextFloor - band.FloorElo; var progress = bandWidth > 0 ? (elo - band.FloorElo) / (double)bandWidth : 0; var division = 3 - Math.Clamp((int)(progress * 3), 0, 2); return (band.Name, division); } public async Task GetOrInitializeHistoryAsync(string userId, DateTime nowUtc) { var rating = await GetOrCreateUserRatingAsync(userId); return await EnsureHistoryBaselineAsync(rating, nowUtc); } public async Task> GetQuestionElosAsync(IEnumerable externalIds) { var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList(); if (ids.Count == 0) return []; var ratings = await _questions.Find(r => ids.Contains(r.QuestionExternalId)).ToListAsync(); return ids.ToDictionary( id => id, id => ratings.FirstOrDefault(r => r.QuestionExternalId == id)?.Elo ?? BaseElo); } public async Task GetQuestionEloAsync(string questionExternalId) { var rating = await GetOrCreateQuestionRatingAsync(questionExternalId); return rating.Elo; } public async Task ApplyFirstResultAsync(string userId, Question question, bool isCorrect, bool shouldRate) { var userRating = await GetOrCreateUserRatingAsync(userId); var questionRating = await GetOrCreateQuestionRatingAsync(question.ExternalId); var category = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline); if (!shouldRate) { return new RatingChangeResponse { Rated = false, Category = category, UserOverallElo = userRating.OverallElo, UserCategoryElo = userRating.CategoryElos.GetValueOrDefault(category, BaseElo), QuestionElo = questionRating.Elo }; } userRating = await EnsureHistoryBaselineAsync(userRating, DateTime.UtcNow); var actual = isCorrect ? 1.0 : 0.0; var userK = userRating.RatedQuestionCount < 20 ? 40 : 24; var questionK = questionRating.AttemptCount < 20 ? 32 : 24; var oldOverall = userRating.OverallElo; var oldCategory = userRating.CategoryElos.GetValueOrDefault(category, BaseElo); var oldQuestion = questionRating.Elo; var overallExpected = ExpectedScore(oldOverall, oldQuestion); var categoryExpected = ExpectedScore(oldCategory, oldQuestion); var overallDelta = (int)Math.Round(userK * (actual - overallExpected)); var categoryDelta = (int)Math.Round(userK * (actual - categoryExpected)); var questionDelta = (int)Math.Round(questionK * (overallExpected - actual)); var now = DateTime.UtcNow; var categoryPath = $"CategoryElos.{category}"; var userUpdate = Builders.Update .Inc(rating => rating.OverallElo, overallDelta) .Inc(categoryPath, categoryDelta) .Inc(rating => rating.RatedQuestionCount, 1) .Set(rating => rating.UpdatedUtc, now); await _users.UpdateOneAsync(rating => rating.UserId == userId, userUpdate); await _users.UpdateOneAsync( rating => rating.UserId == userId, Builders.Update .Max(rating => rating.OverallElo, MinElo) .Max(categoryPath, MinElo)); var questionUpdate = Builders.Update .Inc(rating => rating.Elo, questionDelta) .Inc(rating => rating.AttemptCount, 1) .Set(rating => rating.UpdatedUtc, now); await _questions.UpdateOneAsync(rating => rating.QuestionExternalId == question.ExternalId, questionUpdate); await _questions.UpdateOneAsync( rating => rating.QuestionExternalId == question.ExternalId, Builders.Update.Max(rating => rating.Elo, MinElo)); var updatedUser = await _users.Find(rating => rating.UserId == userId).FirstAsync(); var updatedQuestion = await _questions.Find(rating => rating.QuestionExternalId == question.ExternalId).FirstAsync(); return new RatingChangeResponse { Rated = true, Category = category, UserOverallElo = updatedUser.OverallElo, UserOverallDelta = overallDelta, UserCategoryElo = updatedUser.CategoryElos.GetValueOrDefault(category, BaseElo), UserCategoryDelta = categoryDelta, QuestionElo = updatedQuestion.Elo, QuestionEloDelta = questionDelta }; } private async Task GetOrCreateUserRatingAsync(string userId) { var create = Builders.Update .SetOnInsert(rating => rating.UserId, userId) .SetOnInsert(rating => rating.OverallElo, BaseElo) .SetOnInsert(rating => rating.RatedQuestionCount, 0) .SetOnInsert(rating => rating.CategoryElos, DefaultCategoryElos()) .SetOnInsert(rating => rating.UpdatedUtc, DateTime.UtcNow); UserRating rating; try { rating = await _users.FindOneAndUpdateAsync( item => item.UserId == userId, create, new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { rating = await _users.Find(item => item.UserId == userId).FirstAsync(); } var missingCategories = RatingCategory.Categories .Where(category => !rating.CategoryElos.ContainsKey(category)) .ToList(); if (missingCategories.Count > 0) { var updates = missingCategories .Select(category => Builders.Update.Set($"CategoryElos.{category}", BaseElo)) .ToList(); if (updates.Count > 0) await _users.UpdateOneAsync(item => item.UserId == userId, Builders.Update.Combine(updates)); rating = await _users.Find(item => item.UserId == userId).FirstAsync(); } return rating; } private async Task EnsureHistoryBaselineAsync(UserRating rating, DateTime nowUtc) { if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue) return rating; var result = await _users.UpdateOneAsync( value => value.UserId == rating.UserId && value.HistoryStartedUtc == null, Builders.Update .Set(value => value.HistoryBaselineElo, rating.OverallElo) .Set(value => value.HistoryStartedUtc, nowUtc)); if (result.ModifiedCount > 0) { rating.HistoryBaselineElo = rating.OverallElo; rating.HistoryStartedUtc = nowUtc; return rating; } return await _users.Find(value => value.UserId == rating.UserId).FirstAsync(); } private async Task GetOrCreateQuestionRatingAsync(string questionExternalId) { var create = Builders.Update .SetOnInsert(rating => rating.QuestionExternalId, questionExternalId) .SetOnInsert(rating => rating.Elo, BaseElo) .SetOnInsert(rating => rating.AttemptCount, 0) .SetOnInsert(rating => rating.UpdatedUtc, DateTime.UtcNow); try { return await _questions.FindOneAndUpdateAsync( rating => rating.QuestionExternalId == questionExternalId, create, new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { return await _questions.Find(rating => rating.QuestionExternalId == questionExternalId).FirstAsync(); } } private static Dictionary DefaultCategoryElos() => RatingCategory.Categories.ToDictionary(category => category, _ => BaseElo); private static double ExpectedScore(int playerElo, int questionElo) => 1.0 / (1.0 + Math.Pow(10.0, (questionElo - playerElo) / 400.0)); }