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(IConfiguration cfg) { var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; var client = new MongoClient(cs); var db = client.GetDatabase(dbName); _users = db.GetCollection("UserRatings"); _questions = db.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); return new RatingProfileResponse { OverallElo = rating.OverallElo, RatedQuestionCount = rating.RatedQuestionCount, CategoryElos = rating.CategoryElos }; } 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 }; } 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)); userRating.OverallElo = Math.Max(MinElo, oldOverall + overallDelta); userRating.CategoryElos[category] = Math.Max(MinElo, oldCategory + categoryDelta); userRating.RatedQuestionCount++; userRating.UpdatedUtc = DateTime.UtcNow; questionRating.Elo = Math.Max(MinElo, oldQuestion + questionDelta); questionRating.AttemptCount++; questionRating.UpdatedUtc = DateTime.UtcNow; await _users.ReplaceOneAsync(r => r.UserId == userRating.UserId, userRating, new ReplaceOptions { IsUpsert = true }); await _questions.ReplaceOneAsync(r => r.QuestionExternalId == questionRating.QuestionExternalId, questionRating, new ReplaceOptions { IsUpsert = true }); return new RatingChangeResponse { Rated = true, Category = category, UserOverallElo = userRating.OverallElo, UserOverallDelta = userRating.OverallElo - oldOverall, UserCategoryElo = userRating.CategoryElos[category], UserCategoryDelta = userRating.CategoryElos[category] - oldCategory, QuestionElo = questionRating.Elo, QuestionEloDelta = questionRating.Elo - oldQuestion }; } private async Task GetOrCreateUserRatingAsync(string userId) { var rating = await _users.Find(r => r.UserId == userId).FirstOrDefaultAsync(); if (rating is null) { rating = new UserRating { UserId = userId, CategoryElos = DefaultCategoryElos() }; await _users.InsertOneAsync(rating); return rating; } var changed = false; foreach (var category in RatingCategory.Categories) { if (!rating.CategoryElos.ContainsKey(category)) { rating.CategoryElos[category] = BaseElo; changed = true; } } if (changed) await _users.ReplaceOneAsync(r => r.UserId == userId, rating); return rating; } private async Task GetOrCreateQuestionRatingAsync(string questionExternalId) { var rating = await _questions.Find(r => r.QuestionExternalId == questionExternalId).FirstOrDefaultAsync(); if (rating is not null) return rating; rating = new QuestionRating { QuestionExternalId = questionExternalId }; await _questions.InsertOneAsync(rating); return rating; } 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)); }