using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class CcsRatingStore { private const int BaseElo = 1000; private const int MinimumElo = 100; private readonly IMongoCollection _users; private readonly IMongoCollection _cases; private readonly IMongoCollection _results; public CcsRatingStore(MongoDatabaseProvider mongo) { _users = mongo.Database.GetCollection("CcsUserRatings"); _cases = mongo.Database.GetCollection("CcsCaseRatings"); _results = mongo.Database.GetCollection("CcsRatedCaseResults"); _users.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Unique = true })); _cases.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.CaseExternalId), new CreateIndexOptions { Unique = true })); _results.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId), new CreateIndexOptions { Unique = true })); } public async Task GetProfileAsync(string userId) { var rating = await GetOrCreateUserAsync(userId); return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount }; } public async Task ApplyAsync(CcsAttempt attempt) { var actual = Math.Clamp((attempt.Score?.Percent ?? 0) / 100.0, 0, 1); if (!attempt.RatingEligible || attempt.Mode != CcsAttemptModes.Timed) return Unrated("not_eligible", actual); try { await _results.InsertOneAsync(new CcsRatedCaseResult { UserId = attempt.UserId, CaseExternalId = attempt.CaseExternalId, AttemptId = attempt.Id ?? "" }); } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { return Unrated("repeat_case", actual); } var user = await GetOrCreateUserAsync(attempt.UserId); var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId); var expected = ExpectedScore(user.Elo, caseRating.Elo); var userDelta = (int)Math.Round(UserK(user.RatedCaseCount) * (actual - expected), MidpointRounding.AwayFromZero); var caseDelta = (int)Math.Round(CaseK(caseRating.RatedAttemptCount) * (expected - actual), MidpointRounding.AwayFromZero); var updatedUser = await UpdateUserAsync(user, userDelta); var updatedCase = await UpdateCaseAsync(caseRating, caseDelta); return new CcsRatingChange { Rated = true, Reason = "first_timed_attempt", UserEloBefore = user.Elo, UserElo = updatedUser.Elo, UserEloDelta = updatedUser.Elo - user.Elo, CaseEloBefore = caseRating.Elo, CaseElo = updatedCase.Elo, CaseEloDelta = updatedCase.Elo - caseRating.Elo, ActualScore = actual, AppliedUtc = DateTime.UtcNow }; } public static double ExpectedScore(int userElo, int caseElo) => 1.0 / (1.0 + Math.Pow(10.0, (caseElo - userElo) / 400.0)); private static int UserK(int count) => count < 20 ? 40 : 24; private static int CaseK(int count) => count < 20 ? 32 : 24; private static CcsRatingChange Unrated(string reason, double actual) => new() { Rated = false, Reason = reason, ActualScore = actual }; private async Task GetOrCreateUserAsync(string userId) { try { await _users.UpdateOneAsync(value => value.UserId == userId, Builders.Update .SetOnInsert(value => value.UserId, userId) .SetOnInsert(value => value.Elo, BaseElo) .SetOnInsert(value => value.RatedCaseCount, 0) .SetOnInsert(value => value.UpdatedUtc, DateTime.UtcNow), new UpdateOptions { IsUpsert = true }); } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { // Another API replica created the initial rating first. } return await _users.Find(value => value.UserId == userId).FirstAsync(); } private async Task GetOrCreateCaseAsync(string externalId) { try { await _cases.UpdateOneAsync(value => value.CaseExternalId == externalId, Builders.Update .SetOnInsert(value => value.CaseExternalId, externalId) .SetOnInsert(value => value.Elo, BaseElo) .SetOnInsert(value => value.RatedAttemptCount, 0) .SetOnInsert(value => value.UpdatedUtc, DateTime.UtcNow), new UpdateOptions { IsUpsert = true }); } catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { // Another API replica created the initial rating first. } return await _cases.Find(value => value.CaseExternalId == externalId).FirstAsync(); } private async Task UpdateUserAsync(CcsUserRating current, int delta) { while (true) { var nextElo = Math.Max(MinimumElo, current.Elo + delta); var updated = await _users.FindOneAndUpdateAsync( value => value.UserId == current.UserId && value.Elo == current.Elo && value.RatedCaseCount == current.RatedCaseCount, Builders.Update.Set(value => value.Elo, nextElo) .Inc(value => value.RatedCaseCount, 1).Set(value => value.UpdatedUtc, DateTime.UtcNow), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (updated is not null) return updated; current = await _users.Find(value => value.UserId == current.UserId).FirstAsync(); } } private async Task UpdateCaseAsync(CcsCaseRating current, int delta) { while (true) { var nextElo = Math.Max(MinimumElo, current.Elo + delta); var updated = await _cases.FindOneAndUpdateAsync( value => value.CaseExternalId == current.CaseExternalId && value.Elo == current.Elo && value.RatedAttemptCount == current.RatedAttemptCount, Builders.Update.Set(value => value.Elo, nextElo) .Inc(value => value.RatedAttemptCount, 1).Set(value => value.UpdatedUtc, DateTime.UtcNow), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); if (updated is not null) return updated; current = await _cases.Find(value => value.CaseExternalId == current.CaseExternalId).FirstAsync(); } } }