From 8c5aa17d89520213132eed6aa38e97c31814e0fd Mon Sep 17 00:00:00 2001 From: hz Date: Sat, 1 Aug 2026 15:17:28 -0500 Subject: [PATCH] Add clinical progression ranks and records --- .../ProgressionPolicyTests.cs | 57 ++++ .../Controllers/DashboardController.cs | 6 +- .../Controllers/ExamSimulationsController.cs | 8 +- .../Controllers/ProgressionController.cs | 18 ++ .../Controllers/QuestionsController.cs | 17 +- .../Controllers/ReviewQueueController.cs | 9 +- api/QuestionsApi/Models/CreditModels.cs | 1 + api/QuestionsApi/Models/DashboardModels.cs | 1 + api/QuestionsApi/Models/ExamSimulation.cs | 1 + api/QuestionsApi/Models/ProgressionModels.cs | 98 +++++++ api/QuestionsApi/Models/QuestionResponse.cs | 2 + api/QuestionsApi/Models/ReviewQueueModels.cs | 1 + api/QuestionsApi/Program.cs | 1 + .../Services/ProgressionPolicy.cs | 42 +++ api/QuestionsApi/Services/ProgressionStore.cs | 253 ++++++++++++++++++ api/QuestionsApi/Services/UserProfileStore.cs | 3 +- docs/QUESTION_BANK_GAMIFICATION.md | 2 +- scripts/autoload/question_bank_service.gd | 6 + scripts/autoload/review_queue_service.gd | 3 + scripts/ui/dashboard_screen.gd | 39 +++ scripts/ui/progress_profile.gd | 73 +++++ 21 files changed, 631 insertions(+), 10 deletions(-) create mode 100644 api/QuestionsApi.Tests/ProgressionPolicyTests.cs create mode 100644 api/QuestionsApi/Controllers/ProgressionController.cs create mode 100644 api/QuestionsApi/Models/ProgressionModels.cs create mode 100644 api/QuestionsApi/Services/ProgressionPolicy.cs create mode 100644 api/QuestionsApi/Services/ProgressionStore.cs diff --git a/api/QuestionsApi.Tests/ProgressionPolicyTests.cs b/api/QuestionsApi.Tests/ProgressionPolicyTests.cs new file mode 100644 index 0000000..579b8b2 --- /dev/null +++ b/api/QuestionsApi.Tests/ProgressionPolicyTests.cs @@ -0,0 +1,57 @@ +using QuestionsApi.Models; +using QuestionsApi.Services; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class ProgressionPolicyTests +{ + [Fact] + public void New_learner_starts_as_case_scout_three() + { + Assert.Equal(0, ProgressionPolicy.Qualified([], 0)); + Assert.Equal("Case Scout", ProgressionPolicy.Name(0)); + Assert.Equal("III", ProgressionPolicy.Division(0)); + } + + [Fact] + public void Pattern_hunter_requires_every_visible_gate() + { + var systems = Systems(learning: 6, practicing: 2, strong: 0, mastered: 0, hard: 6); + Assert.Equal(1, ProgressionPolicy.Qualified(systems, 0)); + Assert.Equal(3, ProgressionPolicy.Qualified(systems, 1)); + Assert.Equal("Pattern Hunter", ProgressionPolicy.Name(3)); + Assert.Equal("III", ProgressionPolicy.Division(3)); + } + + [Fact] + public void Highest_division_requires_breadth_challenge_and_retention() + { + var systems = Systems(learning: 14, practicing: 14, strong: 14, mastered: 12, hard: 105); + Assert.Equal(13, ProgressionPolicy.Qualified(systems, 20)); + Assert.Equal(14, ProgressionPolicy.Qualified(systems, 21)); + Assert.Equal("Clinical Vanguard", ProgressionPolicy.Name(14)); + Assert.Equal("I", ProgressionPolicy.Division(14)); + } + + [Theory] + [InlineData("unseen", 0)] [InlineData("learning", 1)] [InlineData("practicing", 2)] + [InlineData("strong", 3)] [InlineData("mastered", 4)] + public void Mastery_states_have_stable_order(string state, int expected) => + Assert.Equal(expected, ProgressionPolicy.State(state)); + + private static List Systems(int learning, int practicing, int strong, int mastered, int hard) + { + var result = new List(); + for (var i = 0; i < 14; i++) + { + var state = i < mastered ? MasteryStates.Mastered + : i < strong ? MasteryStates.Strong + : i < practicing ? MasteryStates.Practicing + : i < learning ? MasteryStates.Learning : MasteryStates.Unseen; + result.Add(new DashboardSystemPerformance { System = $"s{i}", MasteryState = state }); + } + for (var i = 0; i < hard; i++) result[i % result.Count].Difficulty4PlusSuccessful++; + return result; + } +} diff --git a/api/QuestionsApi/Controllers/DashboardController.cs b/api/QuestionsApi/Controllers/DashboardController.cs index 9854828..f8f5452 100644 --- a/api/QuestionsApi/Controllers/DashboardController.cs +++ b/api/QuestionsApi/Controllers/DashboardController.cs @@ -8,7 +8,7 @@ namespace QuestionsApi.Controllers; [ApiController] [Route("api/dashboard")] [Authorize(Roles = "USER,SUPER")] -public sealed class DashboardController(DashboardStore dashboard) : ControllerBase +public sealed class DashboardController(DashboardStore dashboard, ProgressionStore progression) : ControllerBase { [HttpGet] public async Task Get() @@ -18,6 +18,8 @@ public sealed class DashboardController(DashboardStore dashboard) : ControllerBa ?? User.Identity?.Name ?? ""; if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); - return Ok(await dashboard.GetAsync(userId, DateTime.UtcNow)); + var response = await dashboard.GetAsync(userId, DateTime.UtcNow); + response.Progression = await progression.GetAsync(userId); + return Ok(response); } } diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index 5acc085..afbd492 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -13,7 +13,8 @@ public class ExamSimulationsController( ExamSimulationStore simulations, CreditStore credits, MasteryAwardService masteryAwards, - StudySessionStore studySessions) : ControllerBase + StudySessionStore studySessions, + ProgressionStore progression) : ControllerBase { [HttpGet("readiness")] public async Task Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); @@ -145,6 +146,7 @@ public class ExamSimulationsController( { var userId = UserId(); await masteryAwards.PrepareAsync(userId); + await progression.PrepareAsync(userId); simulation = await simulations.SubmitBlockAsync(simulation); var response = await simulations.ToResponseAsync(simulation); var session = await studySessions.CompleteSimulationBlockAsync(userId, simulation.Id ?? id, simulation.CurrentBlock); @@ -164,6 +166,10 @@ public class ExamSimulationsController( } else if (session.Summary is not null) response.Credits = await credits.GetStatusAsync(userId); + var progressionResult = await progression.RefreshAsync(userId); + response.Progression = progressionResult.State; + response.CreditAwards.AddRange(progressionResult.Awards); + if (progressionResult.Awards.Count > 0) response.Credits = await credits.GetStatusAsync(userId); return Ok(response); } catch (InvalidOperationException exception) { return Conflict(exception.Message); } diff --git a/api/QuestionsApi/Controllers/ProgressionController.cs b/api/QuestionsApi/Controllers/ProgressionController.cs new file mode 100644 index 0000000..820868a --- /dev/null +++ b/api/QuestionsApi/Controllers/ProgressionController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController, Route("api/progression"), Authorize(Roles = "USER,SUPER")] +public sealed class ProgressionController(ProgressionStore progression) : ControllerBase +{ + [HttpGet] + public async Task Get() + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name ?? ""; + return string.IsNullOrWhiteSpace(userId) ? Unauthorized() : Ok(await progression.GetAsync(userId)); + } +} diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 137c248..bc00137 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -23,6 +23,7 @@ public class QuestionsController : ControllerBase private readonly MasteryAwardService _masteryAwards; private readonly StudySessionStore _studySessions; private readonly DailyMissionStore _dailyMissions; + private readonly ProgressionStore _progression; public QuestionsController( QuestionStore questions, @@ -36,7 +37,8 @@ public class QuestionsController : ControllerBase CreditStore credits, MasteryAwardService masteryAwards, StudySessionStore studySessions, - DailyMissionStore dailyMissions) + DailyMissionStore dailyMissions, + ProgressionStore progression) { _questions = questions; _attempts = attempts; @@ -50,6 +52,7 @@ public class QuestionsController : ControllerBase _masteryAwards = masteryAwards; _studySessions = studySessions; _dailyMissions = dailyMissions; + _progression = progression; } [HttpGet("systems")] @@ -162,6 +165,7 @@ public class QuestionsController : ControllerBase return Unauthorized(); await _masteryAwards.PrepareAsync(userId); + await _progression.PrepareAsync(userId); var record = await _attempts.RecordFirstAttemptAsync( question.ExternalId, @@ -237,6 +241,8 @@ public class QuestionsController : ControllerBase && 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); + var progression = await _progression.RefreshAsync(userId); + creditAwards.AddRange(progression.Awards); return Ok(new FirstQuestionAttemptResponse { QuestionExternalId = question.ExternalId, @@ -249,7 +255,8 @@ public class QuestionsController : ControllerBase CreditAwards = creditAwards, StudySessionProgress = studySession?.Progress, SessionSummary = studySession?.Summary, - DailyMissions = dailyMissions?.State + DailyMissions = dailyMissions?.State, + Progression = progression.State }); } @@ -310,6 +317,8 @@ public class QuestionsController : ControllerBase if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + await _progression.PrepareAsync(userId); + var record = await _attempts.RecordFirstAttemptAsync( question.ExternalId, userId, @@ -342,6 +351,7 @@ public class QuestionsController : ControllerBase throw; } + var progression = await _progression.RefreshAsync(userId); return Ok(new SkipQuestionResponse { QuestionExternalId = question.ExternalId, @@ -350,7 +360,8 @@ public class QuestionsController : ControllerBase question, await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), QuestionElo = rating.QuestionElo, - Rating = rating + Rating = rating, + Progression = progression.State }); } diff --git a/api/QuestionsApi/Controllers/ReviewQueueController.cs b/api/QuestionsApi/Controllers/ReviewQueueController.cs index 08f10f3..b6f447b 100644 --- a/api/QuestionsApi/Controllers/ReviewQueueController.cs +++ b/api/QuestionsApi/Controllers/ReviewQueueController.cs @@ -18,7 +18,8 @@ public sealed class ReviewQueueController( CreditStore credits, MasteryAwardService masteryAwards, StudySessionStore studySessions, - DailyMissionStore dailyMissions) : ControllerBase + DailyMissionStore dailyMissions, + ProgressionStore progression) : ControllerBase { [HttpPost("next")] public async Task Next([FromBody] ReviewQueueNextRequest req) @@ -76,6 +77,7 @@ public sealed class ReviewQueueController( } await masteryAwards.PrepareAsync(userId); + await progression.PrepareAsync(userId); var result = await queue.RecordReviewAsync( userId, req.ClientAttemptId, @@ -110,6 +112,8 @@ public sealed class ReviewQueueController( && awards.All(existing => existing.ReasonCode != value.ReasonCode))); var missionResult = await dailyMissions.RecordReviewAsync(userId, result.Attempt); if (missionResult is not null) awards.AddRange(missionResult.CreditAwards); + var progressionResult = await progression.RefreshAsync(userId); + awards.AddRange(progressionResult.Awards); return Ok(new ReviewQueueAttemptResponse { QuestionExternalId = question.ExternalId, @@ -120,7 +124,8 @@ public sealed class ReviewQueueController( CreditAwards = awards, StudySessionProgress = studySession?.Progress, SessionSummary = studySession?.Summary, - DailyMissions = missionResult?.State + DailyMissions = missionResult?.State, + Progression = progressionResult.State }); } diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs index 7620fb0..d96fab1 100644 --- a/api/QuestionsApi/Models/CreditModels.cs +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -112,6 +112,7 @@ public static class CreditReasonCodes public const string SessionGoalComplete = "session_goal_complete"; public const string DailyMissionComplete = "daily_mission_complete"; public const string WeeklyGoalComplete = "weekly_goal_complete"; + public const string RankPromotion = "rank_promotion"; public const string Purchase = "purchase"; } diff --git a/api/QuestionsApi/Models/DashboardModels.cs b/api/QuestionsApi/Models/DashboardModels.cs index ce6aec5..061c3ff 100644 --- a/api/QuestionsApi/Models/DashboardModels.cs +++ b/api/QuestionsApi/Models/DashboardModels.cs @@ -31,6 +31,7 @@ public sealed class DashboardResponse public List Systems { get; set; } = []; public List WeeklyActivity { get; set; } = []; public List RecentActivity { get; set; } = []; + public ProgressionResponse? Progression { get; set; } } public sealed class DashboardTrendPoint diff --git a/api/QuestionsApi/Models/ExamSimulation.cs b/api/QuestionsApi/Models/ExamSimulation.cs index 1ae35a6..c242faa 100644 --- a/api/QuestionsApi/Models/ExamSimulation.cs +++ b/api/QuestionsApi/Models/ExamSimulation.cs @@ -121,6 +121,7 @@ public class ExamSimulationStateResponse public List CreditAwards { get; set; } = []; public StudySessionProgress? StudySessionProgress { get; set; } public StudySessionSummary? SessionSummary { get; set; } + public ProgressionResponse? Progression { get; set; } } public class ExamSimulationReadinessResponse diff --git a/api/QuestionsApi/Models/ProgressionModels.cs b/api/QuestionsApi/Models/ProgressionModels.cs new file mode 100644 index 0000000..5f3f712 --- /dev/null +++ b/api/QuestionsApi/Models/ProgressionModels.cs @@ -0,0 +1,98 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public sealed class UserProgression +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public int HighestRankOrdinal { get; set; } + public DateTime TrackingStartedUtc { get; set; } + public bool BaselineApplied { get; set; } + public Dictionary EarnedRanksUtc { get; set; } = []; + public int CurrentHardStreak { get; set; } + public int BestHardStreak { get; set; } + public DateTime? BestHardStreakUtc { get; set; } + public PersonalRecordValue BestTimedBlock { get; set; } = new(); + public PersonalRecordValue SystemsAdvancedWeek { get; set; } = new(); + public PersonalRecordValue ReviewQueueReduction { get; set; } = new(); + public PersonalRecordValue WeakSystemImprovement { get; set; } = new(); + public DateTime UpdatedUtc { get; set; } +} + +public sealed class PersonalRecordValue +{ + public int Value { get; set; } + public int SampleSize { get; set; } + public string Detail { get; set; } = ""; + public DateTime? AchievedUtc { get; set; } +} + +public sealed class ProgressionWeeklySnapshot +{ + [BsonId] public string Id { get; set; } = ""; + public string UserId { get; set; } = ""; + public DateTime WeekStartUtc { get; set; } + public List OpeningSystems { get; set; } = []; + public List LatestSystems { get; set; } = []; + public int OpeningPendingReviews { get; set; } + public int LatestPendingReviews { get; set; } + public DateTime UpdatedUtc { get; set; } +} + +public sealed class ProgressionSystemSnapshot +{ + public string System { get; set; } = ""; + public int StateOrdinal { get; set; } + public int MasteryScore { get; set; } + public int DistinctQuestions { get; set; } +} + +public sealed class ProgressionRequirementResponse +{ + public string Key { get; set; } = ""; + public string Label { get; set; } = ""; + public int Current { get; set; } + public int Required { get; set; } + public bool Complete => Current >= Required; +} + +public sealed class ProgressionRecordResponse +{ + public string Key { get; set; } = ""; + public string Label { get; set; } = ""; + public string Definition { get; set; } = ""; + public int Value { get; set; } + public int SampleSize { get; set; } + public string Detail { get; set; } = ""; + public DateTime? AchievedUtc { get; set; } + public bool TrackingFromLaunch { get; set; } +} + +public sealed class ProgressionResponse +{ + public int RankOrdinal { get; set; } + public int QualifiedRankOrdinal { get; set; } + public string RankName { get; set; } = "Case Scout"; + public string Division { get; set; } = "III"; + public string DisplayName => $"{RankName} {Division}"; + public DateTime TrackingStartedUtc { get; set; } + public List EarnedBadges { get; set; } = []; + public Dictionary EarnedRanksUtc { get; set; } = []; + public List Ladder { get; set; } = []; + public string? NextRankName { get; set; } + public string? NextDivision { get; set; } + public List NextRequirements { get; set; } = []; + public List Records { get; set; } = []; +} + +public sealed class ProgressionRankResponse +{ + public int Ordinal { get; set; } + public string RankName { get; set; } = ""; + public string Division { get; set; } = ""; + public bool Earned { get; set; } + public DateTime? EarnedUtc { get; set; } + public string RequirementSummary { get; set; } = ""; +} diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 4dd65d3..2b30b4f 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -150,6 +150,7 @@ public class FirstQuestionAttemptResponse public StudySessionProgress? StudySessionProgress { get; set; } public StudySessionSummary? SessionSummary { get; set; } public DailyMissionSetResponse? DailyMissions { get; set; } + public ProgressionResponse? Progression { get; set; } } public class SkipQuestionResponse @@ -159,6 +160,7 @@ public class SkipQuestionResponse public Dictionary ChoiceSelectionPercentages { get; set; } = []; public int QuestionElo { get; set; } = 1000; public RatingChangeResponse? Rating { get; set; } + public ProgressionResponse? Progression { get; set; } } public static class RatingCategory diff --git a/api/QuestionsApi/Models/ReviewQueueModels.cs b/api/QuestionsApi/Models/ReviewQueueModels.cs index 8b559be..f4dc4eb 100644 --- a/api/QuestionsApi/Models/ReviewQueueModels.cs +++ b/api/QuestionsApi/Models/ReviewQueueModels.cs @@ -87,6 +87,7 @@ public sealed class ReviewQueueAttemptResponse public StudySessionProgress? StudySessionProgress { get; set; } public StudySessionSummary? SessionSummary { get; set; } public DailyMissionSetResponse? DailyMissions { get; set; } + public ProgressionResponse? Progression { get; set; } } public sealed class ReviewQueueRecordResult diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 0b89c30..27cc489 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -114,6 +114,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/ProgressionPolicy.cs b/api/QuestionsApi/Services/ProgressionPolicy.cs new file mode 100644 index 0000000..816a702 --- /dev/null +++ b/api/QuestionsApi/Services/ProgressionPolicy.cs @@ -0,0 +1,42 @@ +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public static class ProgressionPolicy +{ + public sealed record Threshold(int L, int P, int S, int M, int H, int R); + public static readonly Threshold[] Thresholds = + [ + new(0,0,0,0,0,0), new(2,0,0,0,1,0), new(4,1,0,0,3,1), + new(6,2,0,0,6,1), new(8,4,0,0,10,2), new(10,6,0,0,15,3), + new(0,8,1,0,20,4), new(0,9,2,0,28,5), new(0,10,4,0,36,7), + new(0,11,5,1,45,9), new(0,12,7,2,55,11), new(0,13,9,4,65,13), + new(0,14,10,6,75,15), new(0,14,12,9,90,18), new(0,14,14,12,105,21) + ]; + public static readonly string[] Names = ["Case Scout", "Pattern Hunter", "Diagnostic Tactician", "Management Strategist", "Clinical Vanguard"]; + public static readonly string[] Divisions = ["III", "II", "I"]; + + public static int Qualified(IReadOnlyCollection systems, int retention) + { + var l = systems.Count(x => State(x.MasteryState) >= 1); + var p = systems.Count(x => State(x.MasteryState) >= 2); + var s = systems.Count(x => State(x.MasteryState) >= 3); + var m = systems.Count(x => State(x.MasteryState) >= 4); + var h = systems.Sum(x => x.Difficulty4PlusSuccessful); + for (var i = Thresholds.Length - 1; i >= 0; i--) + { + var t = Thresholds[i]; + if (l >= t.L && p >= t.P && s >= t.S && m >= t.M && h >= t.H && retention >= t.R) return i; + } + return 0; + } + + public static int State(string state) => state switch + { + MasteryStates.Learning => 1, MasteryStates.Practicing => 2, + MasteryStates.Strong => 3, MasteryStates.Mastered => 4, _ => 0 + }; + public static string Name(int ordinal) => Names[Math.Clamp(ordinal / 3, 0, 4)]; + public static string Division(int ordinal) => Divisions[Math.Clamp(ordinal % 3, 0, 2)]; + public static int TierStart(int ordinal) => ordinal / 3 * 3; +} diff --git a/api/QuestionsApi/Services/ProgressionStore.cs b/api/QuestionsApi/Services/ProgressionStore.cs new file mode 100644 index 0000000..26b96e0 --- /dev/null +++ b/api/QuestionsApi/Services/ProgressionStore.cs @@ -0,0 +1,253 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class ProgressionStore +{ + private readonly IMongoCollection _progressions; + private readonly IMongoCollection _snapshots; + private readonly IMongoCollection _attempts; + private readonly IMongoCollection _reviews; + private readonly IMongoCollection _queue; + private readonly IMongoCollection _sessions; + private readonly QuestionStore _questions; + private readonly CreditStore _credits; + private readonly TimeProvider _clock; + + public ProgressionStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock) + { + var db = mongo.Database; + _progressions = db.GetCollection("UserProgressions"); + _snapshots = db.GetCollection("ProgressionWeeklySnapshots"); + _attempts = db.GetCollection("QuestionAttempts"); + _reviews = db.GetCollection("QuestionReviewAttempts"); + _queue = db.GetCollection("ReviewQueueItems"); + _sessions = db.GetCollection("StudySessions"); + _questions = questions; + _credits = credits; + _clock = clock; + _progressions.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.UserId), new CreateIndexOptions { Unique = true })); + _snapshots.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.UserId).Descending(x => x.WeekStartUtc))); + } + + public async Task PrepareAsync(string userId) + { + if (await _progressions.Find(x => x.UserId == userId).AnyAsync()) + { + await EnsureWeeklyOpeningAsync(userId); + return; + } + var evidence = await EvidenceAsync(userId); + var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention); + var now = _clock.GetUtcNow().UtcDateTime; + var progression = new UserProgression + { + UserId = userId, HighestRankOrdinal = qualified, BaselineApplied = true, + TrackingStartedUtc = now, UpdatedUtc = now, + EarnedRanksUtc = Enumerable.Range(0, qualified + 1).ToDictionary(x => x.ToString(), _ => now) + }; + ApplyHistoricalRecords(progression, evidence); + try { await _progressions.InsertOneAsync(progression); } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { } + await EnsureWeeklyOpeningAsync(userId, evidence); + } + + public async Task<(ProgressionResponse State, List Awards)> RefreshAsync(string userId) + { + await PrepareAsync(userId); + var evidence = await EvidenceAsync(userId); + var progression = await _progressions.Find(x => x.UserId == userId).FirstAsync(); + var oldHighest = progression.HighestRankOrdinal; + var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention); + var now = _clock.GetUtcNow().UtcDateTime; + ApplyHistoricalRecords(progression, evidence); + if (qualified > progression.HighestRankOrdinal) + { + progression.HighestRankOrdinal = qualified; + for (var i = oldHighest + 1; i <= qualified; i++) progression.EarnedRanksUtc.TryAdd(i.ToString(), now); + } + await UpdateWeeklyAsync(progression, evidence, now); + progression.UpdatedUtc = now; + var update = Builders.Update.Max(x => x.HighestRankOrdinal, qualified) + .Set(x => x.CurrentHardStreak, progression.CurrentHardStreak) + .Set(x => x.BestHardStreak, progression.BestHardStreak) + .Set(x => x.BestHardStreakUtc, progression.BestHardStreakUtc) + .Set(x => x.BestTimedBlock, progression.BestTimedBlock) + .Set(x => x.SystemsAdvancedWeek, progression.SystemsAdvancedWeek) + .Set(x => x.ReviewQueueReduction, progression.ReviewQueueReduction) + .Set(x => x.WeakSystemImprovement, progression.WeakSystemImprovement) + .Set(x => x.UpdatedUtc, now); + for (var i = oldHighest + 1; i <= qualified; i++) update = update.Set($"EarnedRanksUtc.{i}", now); + await _progressions.UpdateOneAsync(x => x.UserId == userId, update); + progression = await _progressions.Find(x => x.UserId == userId).FirstAsync(); + + var awards = new List(); + foreach (var tier in new[] { 3, 6, 9, 12 }.Where(x => oldHighest < x && qualified >= x)) + { + var amount = tier switch { 3 => 5, 6 => 10, 9 => 15, _ => 25 }; + var result = await _credits.AwardAsync(userId, new CreditAwardRequest( + $"progression:rank:{tier}", amount, CreditReasonCodes.RankPromotion, + $"Advanced to {ProgressionPolicy.Name(tier)}", "progression_rank", tier.ToString(), false)); + if (result.Award is not null) awards.Add(result.Award); + } + return (Response(progression, qualified, evidence), awards); + } + + public async Task GetAsync(string userId) => (await RefreshAsync(userId)).State; + + private async Task EnsureWeeklyOpeningAsync(string userId, Evidence? supplied = null) + { + var now = _clock.GetUtcNow().UtcDateTime; + var week = WeekStart(now); + var id = $"{userId}:{week:yyyyMMdd}"; + if (await _snapshots.Find(x => x.Id == id).AnyAsync()) return; + var evidence = supplied ?? await EvidenceAsync(userId); + var values = Snapshots(evidence.Systems); + var snapshot = new ProgressionWeeklySnapshot + { + Id = id, UserId = userId, WeekStartUtc = week, + OpeningSystems = values, LatestSystems = values, + OpeningPendingReviews = evidence.Pending, LatestPendingReviews = evidence.Pending, UpdatedUtc = now + }; + try { await _snapshots.InsertOneAsync(snapshot); } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { } + } + + private async Task UpdateWeeklyAsync(UserProgression progression, Evidence evidence, DateTime now) + { + await EnsureWeeklyOpeningAsync(progression.UserId, evidence); + var week = WeekStart(now); + var id = $"{progression.UserId}:{week:yyyyMMdd}"; + await _snapshots.UpdateOneAsync(x => x.Id == id, Builders.Update + .Set(x => x.LatestSystems, Snapshots(evidence.Systems)).Set(x => x.LatestPendingReviews, evidence.Pending) + .Set(x => x.UpdatedUtc, now)); + var all = await _snapshots.Find(x => x.UserId == progression.UserId).ToListAsync(); + foreach (var snapshot in all) + { + var opening = snapshot.OpeningSystems.ToDictionary(x => x.System); + var advanced = snapshot.LatestSystems.Count(x => opening.TryGetValue(x.System, out var before) && x.StateOrdinal > before.StateOrdinal); + SetRecordIfBetter(progression.SystemsAdvancedWeek, advanced, 0, $"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc); + var reduced = Math.Max(0, snapshot.OpeningPendingReviews - snapshot.LatestPendingReviews); + SetRecordIfBetter(progression.ReviewQueueReduction, reduced, snapshot.OpeningPendingReviews, + $"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc); + foreach (var after in snapshot.LatestSystems) + { + if (!opening.TryGetValue(after.System, out var before) || before.MasteryScore >= 75 || before.DistinctQuestions < 5) continue; + SetRecordIfBetter(progression.WeakSystemImprovement, Math.Max(0, after.MasteryScore - before.MasteryScore), + after.DistinctQuestions, after.System, snapshot.UpdatedUtc); + } + } + } + + private async Task EvidenceAsync(string userId) + { + var attempts = await _attempts.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync(); + var reviews = await _reviews.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync(); + var queue = await _queue.Find(x => x.UserId == userId).ToListAsync(); + var ids = attempts.Select(x => x.QuestionExternalId).Concat(reviews.Select(x => x.QuestionExternalId)).Concat(queue.Select(x => x.QuestionExternalId)); + var questions = await _questions.GetByExternalIdsAsync(ids); + var systems = MasteryCalculator.Calculate(attempts, reviews, queue, questions, _clock.GetUtcNow().UtcDateTime); + var delayed = reviews.Where(x => x.IsCorrect).Select(x => new + { + Review = x, Item = queue.FirstOrDefault(q => q.QuestionExternalId == x.QuestionExternalId) + }).Where(x => x.Item is not null && x.Review.CreatedUtc - x.Item.CreatedUtc >= TimeSpan.FromHours(24)) + .Select(x => x.Review.QuestionExternalId).Distinct().Count(); + var directHard = attempts.Where(x => x.IsCorrect && !x.IsSkip && questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4) + .Select(x => x.QuestionExternalId).Distinct().Count(); + var sessions = await _sessions.Find(x => x.UserId == userId && x.State == StudySessionStates.Completed + && x.Mode == "Timed" && x.GoalType != StudyGoalTypes.SimulationBlock).ToListAsync(); + return new Evidence(attempts, reviews, queue, sessions, questions, systems, delayed + directHard / 5, + queue.Count(x => x.State == ReviewQueueStates.Pending)); + } + + private void ApplyHistoricalRecords(UserProgression progression, Evidence evidence) + { + var streak = 0; + foreach (var attempt in evidence.Attempts.Where(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4)) + { + streak = attempt.IsCorrect && !attempt.IsSkip ? streak + 1 : 0; + if (streak > progression.BestHardStreak) + { + progression.BestHardStreak = streak; + progression.BestHardStreakUtc = attempt.CreatedUtc; + } + } + progression.CurrentHardStreak = streak; + + var attemptsById = evidence.Attempts.Where(x => x.Id is not null).ToDictionary(x => x.Id!); + foreach (var session in evidence.Sessions) + { + var sessionAttempts = session.AppliedEventIds.Where(attemptsById.ContainsKey).Select(x => attemptsById[x]).ToList(); + if (sessionAttempts.Count < 20 || sessionAttempts.Count != session.AppliedEventIds.Count) continue; + var hard = sessionAttempts.Count(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4); + if (hard * 2 < sessionAttempts.Count) continue; + var accuracy = (int)Math.Round(sessionAttempts.Count(x => x.IsCorrect && !x.IsSkip) * 100d / sessionAttempts.Count); + if (accuracy > progression.BestTimedBlock.Value + || accuracy == progression.BestTimedBlock.Value && sessionAttempts.Count > progression.BestTimedBlock.SampleSize) + progression.BestTimedBlock = new PersonalRecordValue { Value = accuracy, SampleSize = sessionAttempts.Count, + Detail = $"{hard} difficulty 4–5 questions", AchievedUtc = session.CompletedUtc ?? session.UpdatedUtc }; + } + } + + private static void SetRecordIfBetter(PersonalRecordValue record, int value, int sample, string detail, DateTime date) + { + if (value <= record.Value) return; + record.Value = value; record.SampleSize = sample; record.Detail = detail; record.AchievedUtc = date; + } + + private static ProgressionResponse Response(UserProgression p, int qualified, Evidence evidence) + { + var earnedTierNames = Enumerable.Range(0, p.HighestRankOrdinal / 3 + 1).Select(x => ProgressionPolicy.Names[x]).ToList(); + var response = new ProgressionResponse + { + RankOrdinal = p.HighestRankOrdinal, QualifiedRankOrdinal = qualified, + RankName = ProgressionPolicy.Name(p.HighestRankOrdinal), Division = ProgressionPolicy.Division(p.HighestRankOrdinal), + TrackingStartedUtc = p.TrackingStartedUtc, EarnedBadges = earnedTierNames, EarnedRanksUtc = p.EarnedRanksUtc, + Records = Records(p), Ladder = Enumerable.Range(0, ProgressionPolicy.Thresholds.Length).Select(i => + { + var t = ProgressionPolicy.Thresholds[i]; + var parts = new List(); + if (t.L > 0) parts.Add($"Learning+ {t.L}"); if (t.P > 0) parts.Add($"Practicing+ {t.P}"); + if (t.S > 0) parts.Add($"Strong+ {t.S}"); if (t.M > 0) parts.Add($"Mastered {t.M}"); + if (t.H > 0) parts.Add($"Hard successes {t.H}"); if (t.R > 0) parts.Add($"Retention {t.R}"); + return new ProgressionRankResponse { Ordinal=i, RankName=ProgressionPolicy.Name(i), Division=ProgressionPolicy.Division(i), + Earned=i <= p.HighestRankOrdinal, EarnedUtc=p.EarnedRanksUtc.GetValueOrDefault(i.ToString()), + RequirementSummary=parts.Count == 0 ? "Starting rank" : string.Join(" · ", parts) }; + }).ToList() + }; + if (p.HighestRankOrdinal >= ProgressionPolicy.Thresholds.Length - 1) return response; + var next = p.HighestRankOrdinal + 1; + response.NextRankName = ProgressionPolicy.Name(next); response.NextDivision = ProgressionPolicy.Division(next); + var threshold = ProgressionPolicy.Thresholds[next]; + var values = new Dictionary + { + ["learning"] = ("Systems at Learning+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 1), threshold.L), + ["practicing"] = ("Systems at Practicing+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 2), threshold.P), + ["strong"] = ("Systems at Strong+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 3), threshold.S), + ["mastered"] = ("Systems Mastered", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 4), threshold.M), + ["hard"] = ("Difficulty 4+ successes", evidence.Systems.Sum(x => x.Difficulty4PlusSuccessful), threshold.H), + ["retention"] = ("Retention evidence", evidence.Retention, threshold.R) + }; + response.NextRequirements = values.Where(x => x.Value.Required > 0).Select(x => new ProgressionRequirementResponse + { Key = x.Key, Label = x.Value.Label, Current = x.Value.Current, Required = x.Value.Required }).ToList(); + return response; + } + + private static List Records(UserProgression p) => + [ + new() { Key="timed_block", Label="Advanced Timed Block", Definition="Best accuracy in a completed 20+ question Timed block with at least 50% difficulty 4–5.", Value=p.BestTimedBlock.Value, SampleSize=p.BestTimedBlock.SampleSize, Detail=p.BestTimedBlock.Detail, AchievedUtc=p.BestTimedBlock.AchievedUtc }, + new() { Key="hard_streak", Label="Hard-Question Streak", Definition="Consecutive correct first attempts at difficulty 4–5; easier questions are ignored.", Value=p.BestHardStreak, AchievedUtc=p.BestHardStreakUtc }, + new() { Key="systems_week", Label="Systems Advanced in a Week", Definition="Unique systems whose mastery state increased in one UTC Monday–Sunday week.", Value=p.SystemsAdvancedWeek.Value, Detail=p.SystemsAdvancedWeek.Detail, AchievedUtc=p.SystemsAdvancedWeek.AchievedUtc, TrackingFromLaunch=true }, + new() { Key="reviews_week", Label="Review Queue Reduction", Definition="Largest weekly decrease in pending reviews.", Value=p.ReviewQueueReduction.Value, SampleSize=p.ReviewQueueReduction.SampleSize, Detail=p.ReviewQueueReduction.Detail, AchievedUtc=p.ReviewQueueReduction.AchievedUtc, TrackingFromLaunch=true }, + new() { Key="weak_gain", Label="Weak-System Improvement", Definition="Largest weekly mastery-score gain in a system that began below 75 with at least five questions of evidence.", Value=p.WeakSystemImprovement.Value, SampleSize=p.WeakSystemImprovement.SampleSize, Detail=p.WeakSystemImprovement.Detail, AchievedUtc=p.WeakSystemImprovement.AchievedUtc, TrackingFromLaunch=true } + ]; + + private static List Snapshots(IEnumerable systems) => systems.Select(x => new ProgressionSystemSnapshot + { System=x.System, StateOrdinal=ProgressionPolicy.State(x.MasteryState), MasteryScore=x.MasteryScore, DistinctQuestions=x.DistinctQuestions }).ToList(); + private static DateTime WeekStart(DateTime value) => value.Date.AddDays(-((7 + (int)value.DayOfWeek - (int)DayOfWeek.Monday) % 7)); + private sealed record Evidence(List Attempts, List Reviews, List Queue, List Sessions, + IReadOnlyDictionary Questions, List Systems, int Retention, int Pending); +} diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 8a937da..0757507 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -20,7 +20,8 @@ public sealed class UserProfileStore "ExternalIdentities", "ExternalAuthTransactions", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", - "UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals" + "UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals", + "UserProgressions", "ProgressionWeeklySnapshots" ]; public UserProfileStore(MongoDatabaseProvider mongo) diff --git a/docs/QUESTION_BANK_GAMIFICATION.md b/docs/QUESTION_BANK_GAMIFICATION.md index 8be5768..36b8b31 100644 --- a/docs/QUESTION_BANK_GAMIFICATION.md +++ b/docs/QUESTION_BANK_GAMIFICATION.md @@ -163,7 +163,7 @@ social notifications. ### Phase 2: progression 1. [x] Add weekly consistency goals. -2. Add breadth-based ranks and personal records. +2. [x] Add breadth-based ranks and personal records. 3. Expand the RZ Coin store with cosmetic and utility items. 4. Add limited streak protection and recovery days. diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 4a1752e..8c95319 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -553,6 +553,9 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void: _apply_rating_change(rating) CreditsService.apply_award_response(parsed) DailyMissionService.apply_response(parsed) + if typeof(parsed.get("progression")) == TYPE_DICTIONARY: + DashboardService.data["progression"] = parsed.get("progression") + DashboardService.dashboard_changed.emit() # 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) @@ -560,6 +563,9 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void: func _apply_skip_response(parsed: Dictionary) -> void: + if typeof(parsed.get("progression")) == TYPE_DICTIONARY: + DashboardService.data["progression"] = parsed.get("progression") + DashboardService.dashboard_changed.emit() var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) var correct_choice_id := str(parsed.get("correctChoiceId", parsed.get("correct_choice_id", ""))) var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {})) diff --git a/scripts/autoload/review_queue_service.gd b/scripts/autoload/review_queue_service.gd index ad552ab..18802c4 100644 --- a/scripts/autoload/review_queue_service.gd +++ b/scripts/autoload/review_queue_service.gd @@ -199,6 +199,9 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack if typeof(parsed) == TYPE_DICTIONARY: CreditsService.apply_award_response(parsed) DailyMissionService.apply_response(parsed) + if typeof(parsed.get("progression")) == TYPE_DICTIONARY: + DashboardService.data["progression"] = parsed.get("progression") + DashboardService.dashboard_changed.emit() 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 d0b2794..1cea3e1 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -25,6 +25,7 @@ var recent_list: VBoxContainer var weekly_summary: VBoxContainer var mission_content: VBoxContainer var weekly_goal_content: VBoxContainer +var rank_content: VBoxContainer var metric_values: Dictionary = {} var metric_details: Dictionary = {} var metric_trends: Dictionary = {} @@ -165,6 +166,9 @@ func _build() -> void: right_column.add_theme_constant_override("separation", 12) right_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL analytics_grid.add_child(right_column) + rank_content = VBoxContainer.new() + rank_content.add_theme_constant_override("separation", 9) + right_column.add_child(_card(rank_content, 16)) focus_content = VBoxContainer.new() focus_content.add_theme_constant_override("separation", 10) right_column.add_child(_card(focus_content, 16)) @@ -224,6 +228,7 @@ func _render() -> void: _render_systems(data.get("systems", [])) _render_focus(data.get("systems", [])) _render_review(data) + _render_rank(data.get("progression", {})) var weekly: Array = data.get("weeklyActivity", []) activity_chart.set_days(weekly, UIThemeService.is_dark_mode()) _render_weekly(data, weekly) @@ -232,6 +237,40 @@ func _render() -> void: _render_weekly_goal() +func _render_rank(value: Variant) -> void: + if rank_content == null: + return + _clear(rank_content) + rank_content.add_child(_section_title("Clinical progression")) + var progression: Dictionary = value if typeof(value) == TYPE_DICTIONARY else {} + if progression.is_empty(): + rank_content.add_child(_label("Loading rank progress...", 13, Brand.muted_color(UIThemeService.is_dark_mode()))) + return + var title := _label(str(progression.get("displayName", "Case Scout III")), 23, Brand.PURPLE, true) + rank_content.add_child(title) + var next_name = progression.get("nextRankName") + if next_name == null: + rank_content.add_child(_label("Highest division earned", 12, Brand.SUCCESS, true)) + else: + rank_content.add_child(_label("Next: %s %s" % [str(next_name), str(progression.get("nextDivision", ""))], 12, Brand.muted_color(UIThemeService.is_dark_mode()))) + var requirements: Array = progression.get("nextRequirements", []) + for requirement_value in requirements.slice(0, mini(3, requirements.size())): + var requirement: Dictionary = requirement_value + var row := HBoxContainer.new() + var label := _label(str(requirement.get("label", "Progress")), 11, Brand.text_color(UIThemeService.is_dark_mode())) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(label) + row.add_child(_label("%d / %d" % [int(requirement.get("current", 0)), int(requirement.get("required", 0))], 11, + Brand.SUCCESS if bool(requirement.get("complete", false)) else Brand.ROYAL_BLUE, true)) + rank_content.add_child(row) + var view := Button.new() + view.text = "View ladder and records" + view.flat = true + view.alignment = HORIZONTAL_ALIGNMENT_LEFT + view.pressed.connect(func(): main.show_screen("profile")) + rank_content.add_child(view) + + func _render_missions() -> void: if mission_content == null: return diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd index 5a295a3..209436b 100644 --- a/scripts/ui/progress_profile.gd +++ b/scripts/ui/progress_profile.gd @@ -21,6 +21,7 @@ var outcome_delete: Button var connected_accounts_list: VBoxContainer var return_screen := "" var back_to_session_button: Button +var progression_content: VBoxContainer func _ready() -> void: @@ -95,6 +96,7 @@ func _build() -> void: right.add_theme_constant_override("separation", 14) columns.add_child(right) right.add_child(_outcome_card()) + right.add_child(_progression_card()) right.add_child(_connected_accounts_card()) right.add_child(_account_actions_card()) _refresh_back_to_session_visibility() @@ -227,6 +229,13 @@ func _outcome_card() -> PanelContainer: return _card(body) +func _progression_card() -> PanelContainer: + progression_content = VBoxContainer.new() + progression_content.add_theme_constant_override("separation", 10) + progression_content.add_child(_section_header("Clinical Progression", "Breadth, challenge, and retention")) + return _card(progression_content) + + func _account_actions_card() -> PanelContainer: var body := VBoxContainer.new() body.add_theme_constant_override("separation", 9) @@ -279,9 +288,73 @@ func _render() -> void: _set_metric("cases", str(int(data.get("ccsCasesCompleted", 0)))) _set_metric("streak", "%d days" % int(data.get("currentStreak", 0))) _render_systems(data.get("systems", [])) + _render_progression(data.get("progression", {})) _render_outcome() +func _render_progression(value: Variant) -> void: + if progression_content == null: + return + _clear(progression_content) + progression_content.add_child(_section_header("Clinical Progression", "Breadth, challenge, and retention")) + var progression: Dictionary = value if typeof(value) == TYPE_DICTIONARY else {} + if progression.is_empty(): + progression_content.add_child(_label("Loading progression...", 13, Brand.muted_color(_dark()))) + return + progression_content.add_child(_label(str(progression.get("displayName", "Case Scout III")), 24, Brand.PURPLE, true)) + var badges: Array = progression.get("earnedBadges", []) + progression_content.add_child(_label("Earned: %s" % ", ".join(PackedStringArray(badges)), 12, Brand.muted_color(_dark()))) + var ladder := GridContainer.new() + ladder.columns = 3 + ladder.add_theme_constant_override("h_separation", 7) + ladder.add_theme_constant_override("v_separation", 7) + var rank_names := ["Case Scout", "Pattern Hunter", "Diagnostic Tactician", "Management Strategist", "Clinical Vanguard"] + var divisions := ["III", "II", "I"] + var earned_ordinal := int(progression.get("rankOrdinal", 0)) + var earned_dates: Dictionary = progression.get("earnedRanksUtc", {}) + var ladder_data: Array = progression.get("ladder", []) + for ordinal in 15: + var earned := ordinal <= earned_ordinal + var caption := "%s %s" % [rank_names[floori(float(ordinal) / 3.0)], divisions[ordinal % 3]] + var date := str(earned_dates.get(str(ordinal), "")).left(10) + if earned and not date.is_empty(): + caption += "\n%s" % date + var cell := _label(caption, 10, Brand.SUCCESS if earned else Brand.muted_color(_dark()), earned) + cell.custom_minimum_size = Vector2(105, 38) + cell.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + cell.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + if ordinal < ladder_data.size() and typeof(ladder_data[ordinal]) == TYPE_DICTIONARY: + cell.tooltip_text = str((ladder_data[ordinal] as Dictionary).get("requirementSummary", "")) + ladder.add_child(cell) + progression_content.add_child(ladder) + var next_name = progression.get("nextRankName") + if next_name != null: + progression_content.add_child(_label("Next: %s %s" % [str(next_name), str(progression.get("nextDivision", ""))], 14, Brand.text_color(_dark()), true)) + for requirement_value in progression.get("nextRequirements", []): + var requirement: Dictionary = requirement_value + var row := HBoxContainer.new() + var text := _label(str(requirement.get("label", "Requirement")), 11, Brand.text_color(_dark())) + text.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(text) + row.add_child(_label("%d / %d" % [int(requirement.get("current", 0)), int(requirement.get("required", 0))], 11, + Brand.SUCCESS if bool(requirement.get("complete", false)) else Brand.ROYAL_BLUE, true)) + progression_content.add_child(row) + progression_content.add_child(HSeparator.new()) + progression_content.add_child(_label("Personal records", 15, Brand.text_color(_dark()), true)) + for record_value in progression.get("records", []): + var record: Dictionary = record_value + var suffix := "%d%%" % int(record.get("value", 0)) if str(record.get("key", "")) == "timed_block" else str(int(record.get("value", 0))) + var title := _label("%s %s" % [str(record.get("label", "Record")), suffix], 12, Brand.text_color(_dark()), true) + title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + progression_content.add_child(title) + var definition := str(record.get("definition", "")) + if bool(record.get("trackingFromLaunch", false)): + definition += " Tracking since %s." % str(progression.get("trackingStartedUtc", "")).left(10) + var detail := _label(definition, 10, Brand.muted_color(_dark())) + detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + progression_content.add_child(detail) + + func _render_connected_accounts(provider_values: Variant, available_values: Variant) -> void: if connected_accounts_list == null: return