155 lines
7.2 KiB
C#
155 lines
7.2 KiB
C#
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<CcsUserRating> _users;
|
|
private readonly IMongoCollection<CcsCaseRating> _cases;
|
|
private readonly IMongoCollection<CcsRatedCaseResult> _results;
|
|
|
|
public CcsRatingStore(MongoDatabaseProvider mongo)
|
|
{
|
|
_users = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
|
_cases = mongo.Database.GetCollection<CcsCaseRating>("CcsCaseRatings");
|
|
_results = mongo.Database.GetCollection<CcsRatedCaseResult>("CcsRatedCaseResults");
|
|
_users.Indexes.CreateOne(new CreateIndexModel<CcsUserRating>(
|
|
Builders<CcsUserRating>.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Unique = true }));
|
|
_cases.Indexes.CreateOne(new CreateIndexModel<CcsCaseRating>(
|
|
Builders<CcsCaseRating>.IndexKeys.Ascending(value => value.CaseExternalId), new CreateIndexOptions { Unique = true }));
|
|
_results.Indexes.CreateOne(new CreateIndexModel<CcsRatedCaseResult>(
|
|
Builders<CcsRatedCaseResult>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId),
|
|
new CreateIndexOptions { Unique = true }));
|
|
}
|
|
|
|
public async Task<CcsRatingProfileResponse> GetProfileAsync(string userId)
|
|
{
|
|
var rating = await GetOrCreateUserAsync(userId);
|
|
return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount };
|
|
}
|
|
|
|
public async Task<CcsRatingChange> 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<CcsUserRating> GetOrCreateUserAsync(string userId)
|
|
{
|
|
try
|
|
{
|
|
await _users.UpdateOneAsync(value => value.UserId == userId,
|
|
Builders<CcsUserRating>.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<CcsCaseRating> GetOrCreateCaseAsync(string externalId)
|
|
{
|
|
try
|
|
{
|
|
await _cases.UpdateOneAsync(value => value.CaseExternalId == externalId,
|
|
Builders<CcsCaseRating>.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<CcsUserRating> 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<CcsUserRating>.Update.Set(value => value.Elo, nextElo)
|
|
.Inc(value => value.RatedCaseCount, 1).Set(value => value.UpdatedUtc, DateTime.UtcNow),
|
|
new FindOneAndUpdateOptions<CcsUserRating> { ReturnDocument = ReturnDocument.After });
|
|
if (updated is not null) return updated;
|
|
current = await _users.Find(value => value.UserId == current.UserId).FirstAsync();
|
|
}
|
|
}
|
|
|
|
private async Task<CcsCaseRating> 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<CcsCaseRating>.Update.Set(value => value.Elo, nextElo)
|
|
.Inc(value => value.RatedAttemptCount, 1).Set(value => value.UpdatedUtc, DateTime.UtcNow),
|
|
new FindOneAndUpdateOptions<CcsCaseRating> { ReturnDocument = ReturnDocument.After });
|
|
if (updated is not null) return updated;
|
|
current = await _cases.Find(value => value.CaseExternalId == current.CaseExternalId).FirstAsync();
|
|
}
|
|
}
|
|
}
|