Files
step3/api/QuestionsApi/Services/RatingStore.cs
T
hz 845907e825 Show rank tier and ELO alongside RZ points in the coins badge
Adds a server-computed tier/division (Bronze..Diamond, III-I) to the
ratings profile endpoint and surfaces it next to the numeric ELO in the
shared header badge, matching the coin balance display already there.
2026-07-29 00:25:26 -05:00

253 lines
10 KiB
C#

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<UserRating> _users;
private readonly IMongoCollection<QuestionRating> _questions;
public RatingStore(MongoDatabaseProvider mongo)
{
_users = mongo.Database.GetCollection<UserRating>("UserRatings");
_questions = mongo.Database.GetCollection<QuestionRating>("QuestionRatings");
_users.Indexes.CreateOne(new CreateIndexModel<UserRating>(
Builders<UserRating>.IndexKeys.Ascending(r => r.UserId),
new CreateIndexOptions { Unique = true }));
_questions.Indexes.CreateOne(new CreateIndexModel<QuestionRating>(
Builders<QuestionRating>.IndexKeys.Ascending(r => r.QuestionExternalId),
new CreateIndexOptions { Unique = true }));
}
public async Task<RatingProfileResponse> 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<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserRatingAsync(userId);
return await EnsureHistoryBaselineAsync(rating, nowUtc);
}
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> 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<int> GetQuestionEloAsync(string questionExternalId)
{
var rating = await GetOrCreateQuestionRatingAsync(questionExternalId);
return rating.Elo;
}
public async Task<RatingChangeResponse> 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<UserRating>.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<UserRating>.Update
.Max(rating => rating.OverallElo, MinElo)
.Max(categoryPath, MinElo));
var questionUpdate = Builders<QuestionRating>.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<QuestionRating>.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<UserRating> GetOrCreateUserRatingAsync(string userId)
{
var create = Builders<UserRating>.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<UserRating>
{
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<UserRating>.Update.Set($"CategoryElos.{category}", BaseElo))
.ToList();
if (updates.Count > 0)
await _users.UpdateOneAsync(item => item.UserId == userId, Builders<UserRating>.Update.Combine(updates));
rating = await _users.Find(item => item.UserId == userId).FirstAsync();
}
return rating;
}
private async Task<UserRating> 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<UserRating>.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<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
{
var create = Builders<QuestionRating>.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<QuestionRating>
{
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<string, int> 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));
}