Files
2026-08-01 04:02:24 -05:00

204 lines
12 KiB
C#

using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class DailyMissionStore
{
private readonly IMongoCollection<DailyMissionSet> _sets;
private readonly CreditStore _credits;
private readonly TimeProvider _clock;
private readonly ServiceMetrics _metrics;
public DailyMissionStore(MongoDatabaseProvider mongo, CreditStore credits, TimeProvider clock, ServiceMetrics metrics)
{
_sets = mongo.Database.GetCollection<DailyMissionSet>("DailyMissionSets");
_credits = credits;
_clock = clock;
_metrics = metrics;
_sets.Indexes.CreateOne(new CreateIndexModel<DailyMissionSet>(
Builders<DailyMissionSet>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.DateUtc),
new CreateIndexOptions { Unique = true, Name = "uq_daily_mission_user_date" }));
}
public async Task<DailyMissionEnvelope> GetOrCreateAsync(
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
{
var now = _clock.GetUtcNow().UtcDateTime;
await RecordExpiredAsync(userId, now);
var key = DateKey(now);
var existing = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
if (existing is not null) return Envelope(existing);
var candidates = BuildCandidates(dashboard, published, now);
if (candidates.Count < 3) throw new InvalidOperationException("Not enough eligible question-bank coverage to create daily missions.");
var set = new DailyMissionSet
{
Id = $"{userId}|{key}", UserId = userId, DateUtc = key,
Missions = candidates.Take(3).ToList(), ReplacementCandidates = candidates.Skip(3).ToList(),
CreatedUtc = now, ExpiresUtc = now.Date.AddDays(1)
};
try
{
await _sets.InsertOneAsync(set);
foreach (var mission in set.Missions) _metrics.DailyMissionAssigned(mission.Type);
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstAsync();
}
return Envelope(set);
}
public async Task<DailyMissionEnvelope?> ReplaceAsync(string userId, string missionId)
{
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
if (set is null) return null;
if (set.ReplacementUsed) throw new InvalidOperationException("Today's mission replacement has already been used.");
var index = set.Missions.FindIndex(value => value.Id == missionId);
if (index < 0) throw new ArgumentException("Mission not found.");
if (set.Missions[index].CompletedUtc.HasValue) throw new InvalidOperationException("Completed missions cannot be replaced.");
if (set.ReplacementCandidates.Count == 0) throw new InvalidOperationException("No replacement mission is available.");
var replacement = set.ReplacementCandidates[0];
replacement.StartsUtc = _clock.GetUtcNow().UtcDateTime;
set.Missions[index] = replacement;
set.ReplacementCandidates.RemoveAt(0);
set.ReplacementUsed = true;
var changed = await _sets.ReplaceOneAsync(value => value.Id == set.Id && !value.ReplacementUsed, set);
if (changed.ModifiedCount != 1) throw new InvalidOperationException("Today's mission replacement has already been used.");
_metrics.DailyMissionReplaced();
return Envelope(set);
}
public Task<DailyMissionEnvelope?> RecordQuestionAsync(string userId, QuestionAttempt attempt, Question question) =>
!attempt.IsCorrect || attempt.IsSkip ? Task.FromResult<DailyMissionEnvelope?>(null) :
RecordAsync(userId, $"question:{attempt.Id}", attempt.CreatedUtc,
mission => QualifiesQuestion(mission, attempt, question));
public Task<DailyMissionEnvelope?> RecordReviewAsync(string userId, QuestionReviewAttempt attempt) =>
!attempt.IsCorrect ? Task.FromResult<DailyMissionEnvelope?>(null) :
RecordAsync(userId, $"review:{attempt.Id}", attempt.CreatedUtc,
mission => QualifiesReview(mission, attempt));
public static bool QualifiesQuestion(DailyMission mission, QuestionAttempt attempt, Question question)
{
if (!attempt.IsCorrect || attempt.IsSkip || attempt.CreatedUtc < mission.StartsUtc) return false;
var system = Step3SystemCatalog.Normalize(question);
return mission.Type == DailyMissionTypes.WeakSystemCorrect && mission.Step3System == system
|| mission.Type == DailyMissionTypes.DifficultyFourCorrect
&& mission.Step3System == system && question.Difficulty >= 4;
}
public static bool QualifiesReview(DailyMission mission, QuestionReviewAttempt attempt) =>
attempt.IsCorrect && attempt.CreatedUtc >= mission.StartsUtc && mission.Type == DailyMissionTypes.DueReviews;
private async Task<DailyMissionEnvelope?> RecordAsync(
string userId, string eventId, DateTime eventUtc, Func<DailyMission, bool> qualifies)
{
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
if (set is null) return null;
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null
&& value.StartsUtc <= eventUtc && qualifies(value)))
{
var missionFilter = Builders<DailyMission>.Filter.Eq(value => value.Id, mission.Id)
& Builders<DailyMission>.Filter.Eq(value => value.CompletedUtc, null)
& Builders<DailyMission>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions, missionFilter);
await _sets.UpdateOneAsync(filter, Builders<DailyMissionSet>.Update
.Inc("Missions.$.Progress", 1).AddToSet("Missions.$.AppliedEventIds", eventId));
}
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
var awards = new List<CreditAward>();
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null && value.Progress >= value.Target))
{
var now = _clock.GetUtcNow().UtcDateTime;
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions,
item => item.Id == mission.Id && item.CompletedUtc == null && item.Progress >= item.Target);
var completed = await _sets.UpdateOneAsync(filter,
Builders<DailyMissionSet>.Update.Set("Missions.$.CompletedUtc", now));
if (completed.ModifiedCount != 1) continue;
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
$"mission:{mission.Id}:complete", 3, CreditReasonCodes.DailyMissionComplete,
$"Completed daily mission: {mission.Title}", "daily_mission", mission.Id, true));
if (award.Award is not null) awards.Add(award.Award);
_metrics.DailyMissionCompleted(mission.Type);
}
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
return new DailyMissionEnvelope { State = Response(set), CreditAwards = awards };
}
public static List<DailyMission> BuildCandidates(
DashboardResponse dashboard, IReadOnlyCollection<Question> published, DateTime now)
{
var availability = published.GroupBy(Step3SystemCatalog.Normalize)
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
.ToDictionary(group => group.Key!, group => (All: group.Count(), Hard: group.Count(q => q.Difficulty >= 4)));
var systems = dashboard.Systems.Where(value => availability.ContainsKey(value.System))
.OrderBy(value => MasteryRank(value.MasteryState)).ThenBy(value => value.MasteryScore)
.ThenBy(value => value.DistinctQuestions).ThenBy(value => value.System).ToList();
var result = new List<DailyMission>();
if (dashboard.MissedQuestionCount > 0)
result.Add(New(DailyMissionTypes.DueReviews,
$"Complete {Math.Min(10, dashboard.MissedQuestionCount)} scheduled reviews", "", 0,
Math.Min(10, dashboard.MissedQuestionCount), now));
var weak = systems.Where(value => availability[value.System].All >= 3).Select(system =>
New(DailyMissionTypes.WeakSystemCorrect,
$"Get 3 first-attempt answers correct in {system.System}", system.System, 0, 3, now));
var hard = systems.Where(value => availability[value.System].Hard >= 3)
.OrderBy(value => value.Difficulty4PlusSuccessful).ThenBy(value => value.MasteryScore)
.Select(system => New(DailyMissionTypes.DifficultyFourCorrect,
$"Get 3 difficulty 4+ answers correct in {system.System}", system.System, 4, 3, now));
var weakList = weak.ToList();
var hardList = hard.ToList();
if (weakList.Count > 0) result.Add(weakList[0]);
var firstHard = hardList.FirstOrDefault(value => value.Step3System != weakList.FirstOrDefault()?.Step3System)
?? hardList.FirstOrDefault();
if (firstHard is not null) result.Add(firstHard);
result.AddRange(weakList.Skip(1));
result.AddRange(hardList.Where(value => value.Id != firstHard?.Id));
return result.GroupBy(value => $"{value.Type}|{value.Step3System}").Select(group => group.First()).ToList();
}
private static DailyMission New(string type, string title, string system, int difficulty, int target, DateTime now) =>
new() { Type = type, Title = title, Step3System = system, DifficultyMin = difficulty,
Target = target, StartsUtc = now };
private static int MasteryRank(string state) => state switch
{
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
MasteryStates.Strong => 3, _ => 4
};
private static string DateKey(DateTime now) => now.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
private async Task RecordExpiredAsync(string userId, DateTime now)
{
var expired = await _sets.Find(value => value.UserId == userId && value.ExpiresUtc <= now
&& !value.ExpirationRecorded).ToListAsync();
foreach (var set in expired)
{
var changed = await _sets.UpdateOneAsync(value => value.Id == set.Id && !value.ExpirationRecorded,
Builders<DailyMissionSet>.Update.Set(value => value.ExpirationRecorded, true));
if (changed.ModifiedCount != 1) continue;
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null))
_metrics.DailyMissionExpired(mission.Type);
}
}
private static DailyMissionEnvelope Envelope(DailyMissionSet set) => new() { State = Response(set) };
private static DailyMissionSetResponse Response(DailyMissionSet set) => new()
{
DateUtc = set.DateUtc, ExpiresUtc = set.ExpiresUtc,
ReplacementAvailable = !set.ReplacementUsed && set.ReplacementCandidates.Count > 0
&& set.Missions.Any(value => value.CompletedUtc is null),
Missions = set.Missions.Select(value => new DailyMissionResponse
{
Id = value.Id, Type = value.Type, Title = value.Title, Step3System = value.Step3System,
DifficultyMin = value.DifficultyMin, Target = value.Target,
Progress = Math.Min(value.Progress, value.Target), Completed = value.CompletedUtc.HasValue
}).ToList()
};
}