Files
step3/api/QuestionsApi/Services/RatingStore.cs
T
2026-07-11 02:05:43 -05:00

158 lines
6.4 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(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));
}