Add server-side ELO ratings
This commit is contained in:
@@ -26,7 +26,7 @@ public class QuestionAttemptStore
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
}
|
||||
|
||||
public async Task RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect)
|
||||
public async Task<bool> RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect, bool isSkip = false)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
@@ -34,15 +34,18 @@ public class QuestionAttemptStore
|
||||
UserId = userId,
|
||||
ChoiceId = choiceId,
|
||||
IsCorrect = isCorrect,
|
||||
IsSkip = isSkip,
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _col.InsertOneAsync(attempt);
|
||||
return true;
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +61,7 @@ public class QuestionAttemptStore
|
||||
if (ids.Count == 0)
|
||||
return [];
|
||||
|
||||
var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId)).ToListAsync();
|
||||
var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId) && !a.IsSkip).ToListAsync();
|
||||
var output = new Dictionary<string, Dictionary<string, int>>();
|
||||
|
||||
foreach (var group in attempts.GroupBy(a => a.QuestionExternalId))
|
||||
|
||||
@@ -139,6 +139,7 @@ public class QuestionStore
|
||||
ChoiceSelectionPercentages = req.ChoiceSelectionPercentages,
|
||||
Discipline = req.Discipline.Trim(),
|
||||
System = req.System.Trim(),
|
||||
PerformanceCategory = RatingCategory.Normalize(req.PerformanceCategory, req.System, req.Discipline),
|
||||
Difficulty = req.Difficulty,
|
||||
Labs = req.Labs,
|
||||
Media = req.Media,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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(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<UserRating>("UserRatings");
|
||||
_questions = db.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);
|
||||
return new RatingProfileResponse
|
||||
{
|
||||
OverallElo = rating.OverallElo,
|
||||
RatedQuestionCount = rating.RatedQuestionCount,
|
||||
CategoryElos = rating.CategoryElos
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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<UserRating> 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<QuestionRating> 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<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));
|
||||
}
|
||||
Reference in New Issue
Block a user