From ecd5da91628a82f28f0ca5e68291f18d5d4c54f6 Mon Sep 17 00:00:00 2001 From: hz Date: Sat, 1 Aug 2026 04:02:24 -0500 Subject: [PATCH] Add daily study missions --- .../DailyMissionPolicyTests.cs | 78 +++++++ .../Controllers/DailyMissionsController.cs | 36 ++++ .../Controllers/QuestionsController.cs | 10 +- .../Controllers/ReviewQueueController.cs | 8 +- api/QuestionsApi/Models/CreditModels.cs | 1 + api/QuestionsApi/Models/DailyMissionModels.cs | 64 ++++++ api/QuestionsApi/Models/QuestionResponse.cs | 1 + api/QuestionsApi/Models/ReviewQueueModels.cs | 1 + api/QuestionsApi/Program.cs | 1 + .../Services/DailyMissionStore.cs | 203 ++++++++++++++++++ api/QuestionsApi/Services/ServiceMetrics.cs | 21 ++ api/QuestionsApi/Services/UserProfileStore.cs | 2 +- docs/QUESTION_BANK_GAMIFICATION.md | 2 +- project.godot | 1 + scripts/autoload/daily_mission_service.gd | 88 ++++++++ scripts/autoload/daily_mission_service.gd.uid | 1 + scripts/autoload/question_bank_service.gd | 1 + scripts/autoload/review_queue_service.gd | 1 + scripts/ui/dashboard_screen.gd | 93 ++++++++ tests/dashboard_metric_ui_test.gd | 12 ++ 20 files changed, 619 insertions(+), 6 deletions(-) create mode 100644 api/QuestionsApi.Tests/DailyMissionPolicyTests.cs create mode 100644 api/QuestionsApi/Controllers/DailyMissionsController.cs create mode 100644 api/QuestionsApi/Models/DailyMissionModels.cs create mode 100644 api/QuestionsApi/Services/DailyMissionStore.cs create mode 100644 scripts/autoload/daily_mission_service.gd create mode 100644 scripts/autoload/daily_mission_service.gd.uid diff --git a/api/QuestionsApi.Tests/DailyMissionPolicyTests.cs b/api/QuestionsApi.Tests/DailyMissionPolicyTests.cs new file mode 100644 index 0000000..690fcd3 --- /dev/null +++ b/api/QuestionsApi.Tests/DailyMissionPolicyTests.cs @@ -0,0 +1,78 @@ +using QuestionsApi.Models; +using QuestionsApi.Services; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class DailyMissionPolicyTests +{ + private static readonly DateTime Now = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void DueReviewsArePrioritizedAndCappedAtTen() + { + var candidates = DailyMissionStore.BuildCandidates(Dashboard(17), Questions(), Now); + Assert.Equal(DailyMissionTypes.DueReviews, candidates[0].Type); + Assert.Equal(10, candidates[0].Target); + } + + [Fact] + public void NoReviewsFallsBackToCoverageGaps() + { + var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), Questions(), Now); + Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DueReviews); + Assert.True(candidates.Count >= 3); + Assert.Contains(candidates.Take(3), value => value.Type == DailyMissionTypes.DifficultyFourCorrect); + Assert.All(candidates.Where(value => value.Type == DailyMissionTypes.WeakSystemCorrect), + value => Assert.Equal(3, value.Target)); + } + + [Fact] + public void HardMissionRequiresThreeEligiblePublishedQuestions() + { + var questions = Questions(); + questions.RemoveAll(value => value.Step3System == Step3SystemCatalog.All[1] && value.Difficulty >= 4); + var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), questions, Now); + Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DifficultyFourCorrect + && value.Step3System == Step3SystemCatalog.All[1]); + } + + [Fact] + public void QuestionProgressRequiresCorrectEligibleActivityAfterMissionStart() + { + var system = Step3SystemCatalog.All[0]; + var mission = new DailyMission + { + Type = DailyMissionTypes.DifficultyFourCorrect, Step3System = system, StartsUtc = Now + }; + var question = new Question { Step3System = system, Difficulty = 4 }; + Assert.True(DailyMissionStore.QualifiesQuestion(mission, + new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question)); + Assert.False(DailyMissionStore.QualifiesQuestion(mission, + new QuestionAttempt { IsCorrect = false, CreatedUtc = Now }, question)); + Assert.False(DailyMissionStore.QualifiesQuestion(mission, + new QuestionAttempt { IsCorrect = true, IsSkip = true, CreatedUtc = Now }, question)); + Assert.False(DailyMissionStore.QualifiesQuestion(mission, + new QuestionAttempt { IsCorrect = true, CreatedUtc = Now.AddSeconds(-1) }, question)); + question.Difficulty = 3; + Assert.False(DailyMissionStore.QualifiesQuestion(mission, + new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question)); + } + + private static DashboardResponse Dashboard(int pending) => new() + { + MissedQuestionCount = pending, + Systems = Step3SystemCatalog.All.Take(4).Select((system, index) => new DashboardSystemPerformance + { + System = system, MasteryState = index == 0 ? MasteryStates.Unseen : MasteryStates.Learning, + MasteryScore = index * 10, DistinctQuestions = index, Difficulty4PlusSuccessful = index + }).ToList() + }; + + private static List Questions() => Step3SystemCatalog.All.Take(4) + .SelectMany(system => Enumerable.Range(0, 6).Select(index => new Question + { + ExternalId = $"{system}-{index}", Step3System = system, Difficulty = index < 3 ? 4 : 2, + IsPublished = true + })).ToList(); +} diff --git a/api/QuestionsApi/Controllers/DailyMissionsController.cs b/api/QuestionsApi/Controllers/DailyMissionsController.cs new file mode 100644 index 0000000..1c2f89a --- /dev/null +++ b/api/QuestionsApi/Controllers/DailyMissionsController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/daily-missions")] +[Authorize(Roles = "USER,SUPER")] +public sealed class DailyMissionsController( + DailyMissionStore missions, DashboardStore dashboard, QuestionStore questions) : ControllerBase +{ + [HttpGet] + public async Task Get() + { + var now = DateTime.UtcNow; + return Ok(await missions.GetOrCreateAsync(UserId(), await dashboard.GetAsync(UserId(), now), + await questions.GetPublishedAsync())); + } + + [HttpPost("{missionId}/replace")] + public async Task Replace(string missionId) + { + try + { + var result = await missions.ReplaceAsync(UserId(), missionId); + return result is null ? NotFound() : Ok(result); + } + catch (ArgumentException exception) { return NotFound(exception.Message); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException(); +} diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 283aea1..137c248 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -22,6 +22,7 @@ public class QuestionsController : ControllerBase private readonly CreditStore _credits; private readonly MasteryAwardService _masteryAwards; private readonly StudySessionStore _studySessions; + private readonly DailyMissionStore _dailyMissions; public QuestionsController( QuestionStore questions, @@ -34,7 +35,8 @@ public class QuestionsController : ControllerBase ReviewQueueStore reviewQueue, CreditStore credits, MasteryAwardService masteryAwards, - StudySessionStore studySessions) + StudySessionStore studySessions, + DailyMissionStore dailyMissions) { _questions = questions; _attempts = attempts; @@ -47,6 +49,7 @@ public class QuestionsController : ControllerBase _credits = credits; _masteryAwards = masteryAwards; _studySessions = studySessions; + _dailyMissions = dailyMissions; } [HttpGet("systems")] @@ -232,6 +235,8 @@ public class QuestionsController : ControllerBase creditAwards.AddRange(studySession.Summary.CoinAwards.Where(value => value.ReasonCode == CreditReasonCodes.SessionGoalComplete && creditAwards.All(existing => existing.ReasonCode != value.ReasonCode))); + var dailyMissions = await _dailyMissions.RecordQuestionAsync(userId, record.Attempt, question); + if (dailyMissions is not null) creditAwards.AddRange(dailyMissions.CreditAwards); return Ok(new FirstQuestionAttemptResponse { QuestionExternalId = question.ExternalId, @@ -243,7 +248,8 @@ public class QuestionsController : ControllerBase Credits = await _credits.GetStatusAsync(userId), CreditAwards = creditAwards, StudySessionProgress = studySession?.Progress, - SessionSummary = studySession?.Summary + SessionSummary = studySession?.Summary, + DailyMissions = dailyMissions?.State }); } diff --git a/api/QuestionsApi/Controllers/ReviewQueueController.cs b/api/QuestionsApi/Controllers/ReviewQueueController.cs index a215498..08f10f3 100644 --- a/api/QuestionsApi/Controllers/ReviewQueueController.cs +++ b/api/QuestionsApi/Controllers/ReviewQueueController.cs @@ -17,7 +17,8 @@ public sealed class ReviewQueueController( QuestionBookmarkStore bookmarks, CreditStore credits, MasteryAwardService masteryAwards, - StudySessionStore studySessions) : ControllerBase + StudySessionStore studySessions, + DailyMissionStore dailyMissions) : ControllerBase { [HttpPost("next")] public async Task Next([FromBody] ReviewQueueNextRequest req) @@ -107,6 +108,8 @@ public sealed class ReviewQueueController( awards.AddRange(studySession.Summary.CoinAwards.Where(value => value.ReasonCode == CreditReasonCodes.SessionGoalComplete && awards.All(existing => existing.ReasonCode != value.ReasonCode))); + var missionResult = await dailyMissions.RecordReviewAsync(userId, result.Attempt); + if (missionResult is not null) awards.AddRange(missionResult.CreditAwards); return Ok(new ReviewQueueAttemptResponse { QuestionExternalId = question.ExternalId, @@ -116,7 +119,8 @@ public sealed class ReviewQueueController( Credits = await credits.GetStatusAsync(userId), CreditAwards = awards, StudySessionProgress = studySession?.Progress, - SessionSummary = studySession?.Summary + SessionSummary = studySession?.Summary, + DailyMissions = missionResult?.State }); } diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs index 8f438ff..87ca738 100644 --- a/api/QuestionsApi/Models/CreditModels.cs +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -110,6 +110,7 @@ public static class CreditReasonCodes public const string MasteryMastered = "mastery_mastered"; public const string ExamSimulationComplete = "exam_simulation_complete"; public const string SessionGoalComplete = "session_goal_complete"; + public const string DailyMissionComplete = "daily_mission_complete"; public const string Purchase = "purchase"; } diff --git a/api/QuestionsApi/Models/DailyMissionModels.cs b/api/QuestionsApi/Models/DailyMissionModels.cs new file mode 100644 index 0000000..c5ac780 --- /dev/null +++ b/api/QuestionsApi/Models/DailyMissionModels.cs @@ -0,0 +1,64 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public static class DailyMissionTypes +{ + public const string DueReviews = "due_reviews"; + public const string WeakSystemCorrect = "weak_system_correct"; + public const string DifficultyFourCorrect = "difficulty_four_correct"; +} + +public sealed class DailyMission +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Type { get; set; } = ""; + public string Title { get; set; } = ""; + public string Step3System { get; set; } = ""; + public int DifficultyMin { get; set; } + public int Target { get; set; } + public int Progress { get; set; } + public DateTime StartsUtc { get; set; } + public DateTime? CompletedUtc { get; set; } + public List AppliedEventIds { get; set; } = []; +} + +public sealed class DailyMissionSet +{ + [BsonId] + public string Id { get; set; } = ""; + public string UserId { get; set; } = ""; + public string DateUtc { get; set; } = ""; + public List Missions { get; set; } = []; + public List ReplacementCandidates { get; set; } = []; + public bool ReplacementUsed { get; set; } + public bool ExpirationRecorded { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime ExpiresUtc { get; set; } +} + +public sealed class DailyMissionResponse +{ + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public string Title { get; set; } = ""; + public string Step3System { get; set; } = ""; + public int DifficultyMin { get; set; } + public int Target { get; set; } + public int Progress { get; set; } + public bool Completed { get; set; } +} + +public sealed class DailyMissionSetResponse +{ + public string DateUtc { get; set; } = ""; + public DateTime ExpiresUtc { get; set; } + public bool ReplacementAvailable { get; set; } + public List Missions { get; set; } = []; +} + +public sealed class DailyMissionEnvelope +{ + public DailyMissionSetResponse State { get; set; } = new(); + public List CreditAwards { get; set; } = []; +} diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 7468ae2..4dd65d3 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -149,6 +149,7 @@ public class FirstQuestionAttemptResponse public List CreditAwards { get; set; } = []; public StudySessionProgress? StudySessionProgress { get; set; } public StudySessionSummary? SessionSummary { get; set; } + public DailyMissionSetResponse? DailyMissions { get; set; } } public class SkipQuestionResponse diff --git a/api/QuestionsApi/Models/ReviewQueueModels.cs b/api/QuestionsApi/Models/ReviewQueueModels.cs index e98be67..8b559be 100644 --- a/api/QuestionsApi/Models/ReviewQueueModels.cs +++ b/api/QuestionsApi/Models/ReviewQueueModels.cs @@ -86,6 +86,7 @@ public sealed class ReviewQueueAttemptResponse public List CreditAwards { get; set; } = []; public StudySessionProgress? StudySessionProgress { get; set; } public StudySessionSummary? SessionSummary { get; set; } + public DailyMissionSetResponse? DailyMissions { get; set; } } public sealed class ReviewQueueRecordResult diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 3eff679..4543eda 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -112,6 +112,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/Services/DailyMissionStore.cs b/api/QuestionsApi/Services/DailyMissionStore.cs new file mode 100644 index 0000000..3b94e1b --- /dev/null +++ b/api/QuestionsApi/Services/DailyMissionStore.cs @@ -0,0 +1,203 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class DailyMissionStore +{ + private readonly IMongoCollection _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("DailyMissionSets"); + _credits = credits; + _clock = clock; + _metrics = metrics; + _sets.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.DateUtc), + new CreateIndexOptions { Unique = true, Name = "uq_daily_mission_user_date" })); + } + + public async Task GetOrCreateAsync( + string userId, DashboardResponse dashboard, IReadOnlyCollection 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 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 RecordQuestionAsync(string userId, QuestionAttempt attempt, Question question) => + !attempt.IsCorrect || attempt.IsSkip ? Task.FromResult(null) : + RecordAsync(userId, $"question:{attempt.Id}", attempt.CreatedUtc, + mission => QualifiesQuestion(mission, attempt, question)); + + public Task RecordReviewAsync(string userId, QuestionReviewAttempt attempt) => + !attempt.IsCorrect ? Task.FromResult(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 RecordAsync( + string userId, string eventId, DateTime eventUtc, Func 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.Filter.Eq(value => value.Id, mission.Id) + & Builders.Filter.Eq(value => value.CompletedUtc, null) + & Builders.Filter.AnyNin(value => value.AppliedEventIds, [eventId]); + var filter = Builders.Filter.Eq(value => value.Id, set.Id) + & Builders.Filter.ElemMatch(value => value.Missions, missionFilter); + await _sets.UpdateOneAsync(filter, Builders.Update + .Inc("Missions.$.Progress", 1).AddToSet("Missions.$.AppliedEventIds", eventId)); + } + set = await _sets.Find(value => value.Id == set.Id).FirstAsync(); + var awards = new List(); + foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null && value.Progress >= value.Target)) + { + var now = _clock.GetUtcNow().UtcDateTime; + var filter = Builders.Filter.Eq(value => value.Id, set.Id) + & Builders.Filter.ElemMatch(value => value.Missions, + item => item.Id == mission.Id && item.CompletedUtc == null && item.Progress >= item.Target); + var completed = await _sets.UpdateOneAsync(filter, + Builders.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 BuildCandidates( + DashboardResponse dashboard, IReadOnlyCollection 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(); + 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.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() + }; +} diff --git a/api/QuestionsApi/Services/ServiceMetrics.cs b/api/QuestionsApi/Services/ServiceMetrics.cs index 00985d1..c1a88b8 100644 --- a/api/QuestionsApi/Services/ServiceMetrics.cs +++ b/api/QuestionsApi/Services/ServiceMetrics.cs @@ -21,6 +21,10 @@ public sealed class ServiceMetrics private readonly System.Collections.Concurrent.ConcurrentDictionary _creditCoins = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary _creditDuplicates = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary _creditCapped = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dailyMissionAssigned = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dailyMissionCompleted = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dailyMissionExpired = new(); + private long _dailyMissionReplaced; public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated); public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates); @@ -54,6 +58,10 @@ public sealed class ServiceMetrics } public void CreditDuplicate(string reason) => _creditDuplicates.AddOrUpdate(reason, 1, (_, value) => value + 1); public void CreditCapped(string reason) => _creditCapped.AddOrUpdate(reason, 1, (_, value) => value + 1); + public void DailyMissionAssigned(string type) => _dailyMissionAssigned.AddOrUpdate(type, 1, (_, value) => value + 1); + public void DailyMissionCompleted(string type) => _dailyMissionCompleted.AddOrUpdate(type, 1, (_, value) => value + 1); + public void DailyMissionExpired(string type) => _dailyMissionExpired.AddOrUpdate(type, 1, (_, value) => value + 1); + public void DailyMissionReplaced() => Interlocked.Increment(ref _dailyMissionReplaced); public string RenderPrometheus() { @@ -85,6 +93,16 @@ public sealed class ServiceMetrics AppendReasonSample(output, "step3_credit_award_duplicates_total", reason, _creditDuplicates.GetValueOrDefault(reason)); AppendReasonSample(output, "step3_credit_award_capped_total", reason, _creditCapped.GetValueOrDefault(reason)); } + output.AppendLine("# TYPE step3_daily_missions_assigned_total counter"); + output.AppendLine("# TYPE step3_daily_missions_completed_total counter"); + output.AppendLine("# TYPE step3_daily_missions_expired_incomplete_total counter"); + foreach (var type in _dailyMissionAssigned.Keys.Concat(_dailyMissionCompleted.Keys).Concat(_dailyMissionExpired.Keys).Distinct().Order()) + { + AppendMissionSample(output, "step3_daily_missions_assigned_total", type, _dailyMissionAssigned.GetValueOrDefault(type)); + AppendMissionSample(output, "step3_daily_missions_completed_total", type, _dailyMissionCompleted.GetValueOrDefault(type)); + AppendMissionSample(output, "step3_daily_missions_expired_incomplete_total", type, _dailyMissionExpired.GetValueOrDefault(type)); + } + AppendCounter(output, "step3_daily_missions_replaced_total", Interlocked.Read(ref _dailyMissionReplaced)); output.AppendLine("# TYPE step3_http_request_duration_seconds summary"); output.Append("step3_http_request_duration_seconds_count ") .Append(Interlocked.Read(ref _httpRequests)) @@ -116,4 +134,7 @@ public sealed class ServiceMetrics private static void AppendReasonSample(StringBuilder output, string name, string reason, long value) => output.Append(name).Append("{reason=\"").Append(reason.Replace("\"", "")).Append("\"} ").Append(value).AppendLine(); + + private static void AppendMissionSample(StringBuilder output, string name, string type, long value) => + output.Append(name).Append("{type=\"").Append(type.Replace("\"", "")).Append("\"} ").Append(value).AppendLine(); } diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 1f1d91b..4a3fa7d 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -20,7 +20,7 @@ public sealed class UserProfileStore "ExternalIdentities", "ExternalAuthTransactions", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", - "UserCreditAccounts", "CreditLedgerEntries", "StudySessions" + "UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets" ]; public UserProfileStore(MongoDatabaseProvider mongo) diff --git a/docs/QUESTION_BANK_GAMIFICATION.md b/docs/QUESTION_BANK_GAMIFICATION.md index c140a7b..0cadf94 100644 --- a/docs/QUESTION_BANK_GAMIFICATION.md +++ b/docs/QUESTION_BANK_GAMIFICATION.md @@ -158,7 +158,7 @@ social notifications. - [x] Define the mastery model and expose topic-level progress. - [x] Add server-authoritative RZ Coin awards for approved learning events. - [x] Add user-selected session goals and an end-of-session summary. -- [ ] Add daily missions with one replacement per day. +- [x] Add daily missions with one replacement per day. ### Phase 2: progression diff --git a/project.godot b/project.godot index d718e84..2046ba8 100644 --- a/project.godot +++ b/project.godot @@ -41,6 +41,7 @@ ReviewQueueService="*res://scripts/autoload/review_queue_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd" CreditsService="*res://scripts/autoload/credits_service.gd" StudySessionService="*res://scripts/autoload/study_session_service.gd" +DailyMissionService="*res://scripts/autoload/daily_mission_service.gd" FlashcardService="*res://scripts/autoload/flashcard_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd" DonationService="*res://scripts/autoload/donation_service.gd" diff --git a/scripts/autoload/daily_mission_service.gd b/scripts/autoload/daily_mission_service.gd new file mode 100644 index 0000000..21c0245 --- /dev/null +++ b/scripts/autoload/daily_mission_service.gd @@ -0,0 +1,88 @@ +extends Node + +signal missions_changed +signal missions_failed(message: String) + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_URL := "https://us3q.ranaze.com/api/daily-missions" + +var api_url := DEFAULT_URL +var request: HTTPRequest +var state: Dictionary = {} +var loading := false +var pending_action := "" + + +func _ready() -> void: + api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/daily-missions") + request = HTTPRequest.new() + request.timeout = 20.0 + request.request_completed.connect(_on_completed) + add_child(request) + AuthState.auth_changed.connect(_on_auth_changed) + set_process(true) + + +func refresh() -> void: + if loading or not AuthState.can_send_authenticated_request(): + return + _send("refresh", HTTPClient.METHOD_GET, "") + + +func replace(mission_id: String) -> void: + if loading or mission_id.is_empty() or not AuthState.can_send_authenticated_request(): + return + _send("replace", HTTPClient.METHOD_POST, "/%s/replace" % mission_id.uri_encode()) + + +func apply_response(parsed: Dictionary) -> void: + var next_state: Variant = parsed.get("dailyMissions", null) + if typeof(next_state) == TYPE_DICTIONARY: + state = next_state + missions_changed.emit() + + +func _send(action: String, method: int, suffix: String) -> void: + loading = true + pending_action = action + var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]) + var error := request.request(api_url + suffix, headers, method) + if error != OK: + loading = false + pending_action = "" + missions_failed.emit("Daily missions could not be loaded.") + + +func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + loading = false + pending_action = "" + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + missions_failed.emit("Daily mission request failed.") + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY or typeof(parsed.get("state", null)) != TYPE_DICTIONARY: + missions_failed.emit("The daily mission response was invalid.") + return + state = parsed.get("state", {}) + CreditsService.apply_award_response(parsed) + missions_changed.emit() + + +func _process(_delta: float) -> void: + if state.is_empty() or loading or not AuthState.can_send_authenticated_request(): + return + var expires := Time.get_unix_time_from_datetime_string(str(state.get("expiresUtc", "")).trim_suffix("Z")) + if expires > 0 and Time.get_unix_time_from_system() >= expires: + state = {} + refresh() + + +func _on_auth_changed() -> void: + if not AuthState.is_logged_in: + state = {} + loading = false + if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + request.cancel_request() diff --git a/scripts/autoload/daily_mission_service.gd.uid b/scripts/autoload/daily_mission_service.gd.uid new file mode 100644 index 0000000..d2415bd --- /dev/null +++ b/scripts/autoload/daily_mission_service.gd.uid @@ -0,0 +1 @@ +uid://bwafdsrqdxleo diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index ec328fc..4a1752e 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -552,6 +552,7 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void: _update_question_elo(question_id, question_elo) _apply_rating_change(rating) CreditsService.apply_award_response(parsed) + DailyMissionService.apply_response(parsed) # Let Tutor users inspect the final answer before the summary replaces the player. StudySessionService.apply_response(parsed, true) choice_percentages_updated.emit(question_id, percentages) diff --git a/scripts/autoload/review_queue_service.gd b/scripts/autoload/review_queue_service.gd index 919180f..ad552ab 100644 --- a/scripts/autoload/review_queue_service.gd +++ b/scripts/autoload/review_queue_service.gd @@ -198,6 +198,7 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack var synced_is_correct := false if typeof(parsed) == TYPE_DICTIONARY: CreditsService.apply_award_response(parsed) + DailyMissionService.apply_response(parsed) StudySessionService.apply_response(parsed, true) pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count))) synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index c7f4b3c..95adde6 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -23,6 +23,7 @@ var focus_content: VBoxContainer var review_list: VBoxContainer var recent_list: VBoxContainer var weekly_summary: VBoxContainer +var mission_content: VBoxContainer var metric_values: Dictionary = {} var metric_details: Dictionary = {} var metric_trends: Dictionary = {} @@ -34,6 +35,7 @@ var pending_review_launch := false var review_queue_service: Variant var trend_mode := "rating" var mastery_rows: Array[BoxContainer] = [] +var mission_rows: Array[BoxContainer] = [] func _ready() -> void: @@ -41,6 +43,8 @@ func _ready() -> void: review_queue_service = get_node("/root/ReviewQueueService") DashboardService.dashboard_changed.connect(_render) DashboardService.dashboard_failed.connect(_show_error) + DailyMissionService.missions_changed.connect(_render_missions) + DailyMissionService.missions_failed.connect(_show_mission_error) review_queue_service.question_loaded.connect(_on_review_question_loaded) review_queue_service.question_failed.connect(_on_review_question_failed) review_queue_service.pass_completed.connect(_on_review_pass_completed) @@ -55,6 +59,7 @@ func on_show(_payload: Variant = null) -> void: else: _render() DashboardService.refresh() + DailyMissionService.refresh() func _build() -> void: @@ -65,6 +70,7 @@ func _build() -> void: metric_details.clear() metric_trends.clear() mastery_rows.clear() + mission_rows.clear() scroll = ScrollContainer.new() scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED @@ -82,6 +88,9 @@ func _build() -> void: stat_card_grid.add_theme_constant_override("h_separation", 12) stat_card_grid.add_theme_constant_override("v_separation", 12) content.add_child(stat_card_grid) + mission_content = VBoxContainer.new() + mission_content.add_theme_constant_override("separation", 10) + content.add_child(_card(mission_content, 16)) qbank_sparkline = DashboardSparkline.new() qbank_sparkline.custom_minimum_size = Vector2(0, 34) @@ -211,6 +220,88 @@ func _render() -> void: activity_chart.set_days(weekly, UIThemeService.is_dark_mode()) _render_weekly(data, weekly) _render_recent(data.get("recentActivity", [])) + _render_missions() + + +func _render_missions() -> void: + if mission_content == null: + return + _clear(mission_content) + mission_rows.clear() + var header := HBoxContainer.new() + header.add_child(_section_title("Daily missions")) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(spacer) + header.add_child(_label("Resets at 00:00 UTC", 11, Brand.muted_color(UIThemeService.is_dark_mode()))) + mission_content.add_child(header) + var missions: Array = DailyMissionService.state.get("missions", []) + if missions.is_empty(): + mission_content.add_child(_label("Loading today's focused study missions...", 14, Brand.muted_color(UIThemeService.is_dark_mode()))) + return + var replacement_available := bool(DailyMissionService.state.get("replacementAvailable", false)) + for mission_value in missions: + var mission: Dictionary = mission_value + var row := BoxContainer.new() + row.vertical = size.x < 700.0 + mission_rows.append(row) + row.add_theme_constant_override("separation", 10) + var details := VBoxContainer.new() + details.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var completed := bool(mission.get("completed", false)) + var title := _label(str(mission.get("title", "Study mission")), 14, + Brand.SUCCESS if completed else Brand.text_color(UIThemeService.is_dark_mode()), true) + title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + details.add_child(title) + var progress_value := mini(int(mission.get("progress", 0)), int(mission.get("target", 1))) + var target := maxi(1, int(mission.get("target", 1))) + var progress := ProgressBar.new() + progress.show_percentage = false + progress.max_value = target + progress.value = progress_value + progress.custom_minimum_size.y = 7 + _style_progress(progress, Brand.SUCCESS if completed else Brand.ROYAL_BLUE) + details.add_child(progress) + details.add_child(_label("Complete - 3 RZ Coins earned" if completed else "%d of %d" % [progress_value, target], + 11, Brand.muted_color(UIThemeService.is_dark_mode()))) + row.add_child(details) + var launch := _button("Review" if str(mission.get("type", "")) == "due_reviews" else "Start") + launch.disabled = completed + launch.pressed.connect(_launch_mission.bind(mission)) + row.add_child(launch) + if replacement_available and not completed: + var replace := Button.new() + replace.text = "Replace" + replace.flat = true + replace.pressed.connect(_replace_mission.bind(str(mission.get("id", "")))) + row.add_child(replace) + mission_content.add_child(row) + + +func _launch_mission(mission: Dictionary) -> void: + if str(mission.get("type", "")) == "due_reviews": + _start_review_queue() + return + main.show_screen("setup", { + "mastery_target": true, + "step3_system": str(mission.get("step3System", "")), + "question_count": 10, + "difficulty_min": int(mission.get("difficultyMin", 0)), + "difficulty_max": 5 if int(mission.get("difficultyMin", 0)) > 0 else 0 + }) + + +func _replace_mission(mission_id: String) -> void: + DailyMissionService.replace(mission_id) + _render_missions() + + +func _show_mission_error(message: String) -> void: + if mission_content == null: + return + _clear(mission_content) + mission_content.add_child(_section_title("Daily missions")) + mission_content.add_child(_label(message, 13, Brand.MAGENTA)) func _trend_series(trend: Array, field: String) -> Array: @@ -837,6 +928,8 @@ func _update_responsive() -> void: for child in row.get_children(): if child is Button: child.custom_minimum_size.x = 0.0 if narrow else 155.0 + for row in mission_rows: + row.vertical = narrow func _clear(container: Container) -> void: diff --git a/tests/dashboard_metric_ui_test.gd b/tests/dashboard_metric_ui_test.gd index d0a934c..a7c7fe1 100644 --- a/tests/dashboard_metric_ui_test.gd +++ b/tests/dashboard_metric_ui_test.gd @@ -9,8 +9,15 @@ func _initialize() -> void: func _run() -> void: var dashboard_service = root.get_node("DashboardService") + var mission_service = root.get_node("DailyMissionService") var prior_data: Dictionary = dashboard_service.data + var prior_missions: Dictionary = mission_service.state dashboard_service.data = _dashboard_data() + mission_service.state = {"replacementAvailable": true, "missions": [ + {"id": "m1", "type": "due_reviews", "title": "Complete 3 scheduled reviews", "target": 3, "progress": 1, "completed": false}, + {"id": "m2", "type": "weak_system_correct", "title": "Get 3 correct", "target": 3, "progress": 3, "completed": true}, + {"id": "m3", "type": "difficulty_four_correct", "title": "Get 3 hard correct", "target": 3, "progress": 0, "completed": false} + ]} var dashboard = load("res://scripts/ui/dashboard_screen.gd").new() dashboard.size = Vector2(1280, 900) root.add_child(dashboard) @@ -22,9 +29,13 @@ func _run() -> void: _assert(dashboard.mastery_rows.size() == 14, "dashboard should render one mastery action row per system") var first_mastery_row: BoxContainer = dashboard.mastery_rows[0] _assert(not first_mastery_row.vertical, "desktop mastery rows should remain horizontal") + _assert(dashboard.mission_rows.size() == 3, "dashboard should render all three daily missions") + var first_mission_row: BoxContainer = dashboard.mission_rows[0] + _assert(not first_mission_row.vertical, "desktop mission rows should remain horizontal") dashboard.size = Vector2(640, 900) dashboard._update_responsive() _assert(first_mastery_row.vertical, "narrow mastery rows should stack to avoid clipping") + _assert(first_mission_row.vertical, "narrow mission rows should stack to avoid clipping") _assert(dashboard.rating_toggle.button_pressed, "rating should be the default trend mode") _assert(dashboard.trend_chart.mode == "rating", "chart should initially render rating history") dashboard._set_trend_mode("accuracy") @@ -33,6 +44,7 @@ func _run() -> void: dashboard.queue_free() dashboard_service.data = prior_data + mission_service.state = prior_missions await process_frame quit(1 if failures > 0 else 0)