From c716b16c53af5344a9513aed1d4ee0da8ce8ad10 Mon Sep 17 00:00:00 2001 From: hz Date: Sat, 11 Jul 2026 02:05:43 -0500 Subject: [PATCH] Add server-side ELO ratings --- .../Controllers/QuestionsController.cs | 62 ++++++- .../Controllers/RatingsController.cs | 32 ++++ api/QuestionsApi/Models/Question.cs | 1 + api/QuestionsApi/Models/QuestionAttempt.cs | 1 + api/QuestionsApi/Models/QuestionRequests.cs | 1 + api/QuestionsApi/Models/QuestionResponse.cs | 64 ++++++- api/QuestionsApi/Models/RatingModels.cs | 48 ++++++ api/QuestionsApi/Program.cs | 1 + .../Services/QuestionAttemptStore.cs | 7 +- api/QuestionsApi/Services/QuestionStore.cs | 1 + api/QuestionsApi/Services/RatingStore.cs | 157 ++++++++++++++++++ scripts/autoload/block_session_service.gd | 32 ++++ scripts/autoload/profile_service.gd | 8 +- scripts/autoload/question_bank_service.gd | 127 ++++++++++++++ scripts/ui/dashboard.gd | 11 +- scripts/ui/main.gd | 6 +- scripts/ui/main_menu.gd | 9 +- scripts/ui/progress_profile.gd | 27 ++- scripts/ui/question_player.gd | 135 ++++++++++++++- scripts/ui/review_results.gd | 3 +- scripts/ui/study_map.gd | 2 +- 21 files changed, 688 insertions(+), 47 deletions(-) create mode 100644 api/QuestionsApi/Controllers/RatingsController.cs create mode 100644 api/QuestionsApi/Models/RatingModels.cs create mode 100644 api/QuestionsApi/Services/RatingStore.cs diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 8517ec1..2ab3d6b 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -13,11 +13,13 @@ public class QuestionsController : ControllerBase { private readonly QuestionStore _questions; private readonly QuestionAttemptStore _attempts; + private readonly RatingStore _ratings; - public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts) + public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts, RatingStore ratings) { _questions = questions; _attempts = attempts; + _ratings = ratings; } [HttpGet("systems")] @@ -34,10 +36,11 @@ public class QuestionsController : ControllerBase { var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); var percentages = await _attempts.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId)); + var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId)); return Ok(questions.Select(q => { percentages.TryGetValue(q.ExternalId, out var questionPercentages); - return QuestionResponse.FromModel(q, questionPercentages); + return QuestionResponse.FromModel(q, questionPercentages, questionElos.GetValueOrDefault(q.ExternalId, 1000)); }).ToList()); } @@ -50,7 +53,8 @@ public class QuestionsController : ControllerBase return NotFound(); var percentages = await _attempts.GetPercentagesForQuestionAsync(question.ExternalId); - return Ok(QuestionResponse.FromModel(question, percentages)); + var questionElo = await _ratings.GetQuestionEloAsync(question.ExternalId); + return Ok(QuestionResponse.FromModel(question, percentages, questionElo)); } [HttpPost("attempts/first")] @@ -75,18 +79,60 @@ public class QuestionsController : ControllerBase if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); - await _attempts.RecordFirstAttemptAsync( + var wasFirstAttempt = await _attempts.RecordFirstAttemptAsync( question.ExternalId, userId, req.ChoiceId.Trim(), req.ChoiceId.Trim() == question.CorrectChoiceId); + var rating = await _ratings.ApplyFirstResultAsync( + userId, + question, + req.ChoiceId.Trim() == question.CorrectChoiceId, + wasFirstAttempt); return Ok(new FirstQuestionAttemptResponse { QuestionExternalId = question.ExternalId, ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages( question, - await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)) + await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)), + QuestionElo = rating.QuestionElo, + Rating = rating + }); + } + + [HttpPost("{externalId}/skip")] + [Authorize(Roles = "USER,SUPER")] + public async Task Skip(string externalId) + { + if (string.IsNullOrWhiteSpace(externalId)) + return BadRequest("externalId required"); + + var question = await _questions.GetByExternalIdAsync(externalId.Trim()); + if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) + return NotFound(); + + var userId = CurrentUserId(); + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + var wasFirstAttempt = await _attempts.RecordFirstAttemptAsync( + question.ExternalId, + userId, + "", + false, + true); + var rating = await _ratings.ApplyFirstResultAsync(userId, question, false, wasFirstAttempt); + + return Ok(new SkipQuestionResponse + { + QuestionExternalId = question.ExternalId, + CorrectChoiceId = question.CorrectChoiceId, + ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages( + question, + await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)), + QuestionElo = rating.QuestionElo, + Rating = rating }); } @@ -183,4 +229,10 @@ public class QuestionsController : ControllerBase return null; } + + private string CurrentUserId() => + User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? ""; } diff --git a/api/QuestionsApi/Controllers/RatingsController.cs b/api/QuestionsApi/Controllers/RatingsController.cs new file mode 100644 index 0000000..886fd69 --- /dev/null +++ b/api/QuestionsApi/Controllers/RatingsController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class RatingsController : ControllerBase +{ + private readonly RatingStore _ratings; + + public RatingsController(RatingStore ratings) + { + _ratings = ratings; + } + + [HttpGet("me")] + [Authorize(Roles = "USER,SUPER")] + public async Task Me() + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? ""; + if (string.IsNullOrWhiteSpace(userId)) + return Unauthorized(); + + return Ok(await _ratings.GetProfileAsync(userId)); + } +} diff --git a/api/QuestionsApi/Models/Question.cs b/api/QuestionsApi/Models/Question.cs index df21fc9..d1f2b99 100644 --- a/api/QuestionsApi/Models/Question.cs +++ b/api/QuestionsApi/Models/Question.cs @@ -19,6 +19,7 @@ public class Question public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; + public string PerformanceCategory { get; set; } = ""; public int Difficulty { get; set; } = 1; public List Labs { get; set; } = []; public List Media { get; set; } = []; diff --git a/api/QuestionsApi/Models/QuestionAttempt.cs b/api/QuestionsApi/Models/QuestionAttempt.cs index 65d6832..36dda80 100644 --- a/api/QuestionsApi/Models/QuestionAttempt.cs +++ b/api/QuestionsApi/Models/QuestionAttempt.cs @@ -13,5 +13,6 @@ public class QuestionAttempt public string UserId { get; set; } = ""; public string ChoiceId { get; set; } = ""; public bool IsCorrect { get; set; } + public bool IsSkip { get; set; } public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; } diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 816d2ee..6aa279f 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -22,6 +22,7 @@ public class UpsertQuestionRequest public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; + public string PerformanceCategory { get; set; } = ""; public int Difficulty { get; set; } = 1; public List Labs { get; set; } = []; public List Media { get; set; } = []; diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 0773deb..3348807 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -13,12 +13,17 @@ public class QuestionResponse public Dictionary ChoiceSelectionPercentages { get; set; } = []; public string Discipline { get; set; } = ""; public string System { get; set; } = ""; + public string PerformanceCategory { get; set; } = ""; public int Difficulty { get; set; } + public int QuestionElo { get; set; } = 1000; public List Labs { get; set; } = []; public List Media { get; set; } = []; public List Tags { get; set; } = []; - public static QuestionResponse FromModel(Question question, Dictionary? liveChoiceSelectionPercentages = null) => new() + public static QuestionResponse FromModel( + Question question, + Dictionary? liveChoiceSelectionPercentages = null, + int questionElo = 1000) => new() { Id = question.Id ?? "", ExternalId = question.ExternalId, @@ -31,7 +36,9 @@ public class QuestionResponse ChoiceSelectionPercentages = BuildChoiceSelectionPercentages(question, liveChoiceSelectionPercentages), Discipline = question.Discipline, System = question.System, + PerformanceCategory = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline), Difficulty = question.Difficulty, + QuestionElo = questionElo, Labs = question.Labs, Media = question.Media, Tags = question.Tags @@ -52,4 +59,59 @@ public class FirstQuestionAttemptResponse { public string QuestionExternalId { get; set; } = ""; public Dictionary ChoiceSelectionPercentages { get; set; } = []; + public int QuestionElo { get; set; } = 1000; + public RatingChangeResponse? Rating { get; set; } +} + +public class SkipQuestionResponse +{ + public string QuestionExternalId { get; set; } = ""; + public string CorrectChoiceId { get; set; } = ""; + public Dictionary ChoiceSelectionPercentages { get; set; } = []; + public int QuestionElo { get; set; } = 1000; + public RatingChangeResponse? Rating { get; set; } +} + +public static class RatingCategory +{ + public const string Uncategorized = "Uncategorized"; + + public static readonly string[] Categories = + [ + "Renal/Urinary & Male/Female Systems & Pregnancy", + "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety", + "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", + "Biostatistics & Epidemiology/Population Health", + "Musculoskeletal/Skin & Subcutaneous Tissue", + "Cardiovascular System", + "Nervous System & Special Senses", + "Respiratory System", + "Gastrointestinal System", + Uncategorized + ]; + + public static string Normalize(string? explicitCategory, string? system, string? discipline) + { + foreach (var value in new[] { explicitCategory, system, discipline }) + { + if (string.IsNullOrWhiteSpace(value)) + continue; + var normalized = Categories.FirstOrDefault(c => string.Equals(c, value.Trim(), StringComparison.OrdinalIgnoreCase)); + if (normalized is not null) + return normalized; + + var lower = value.ToLowerInvariant(); + if (lower.Contains("cardio")) return "Cardiovascular System"; + if (lower.Contains("resp")) return "Respiratory System"; + if (lower.Contains("gastro") || lower.Contains("hepato")) return "Gastrointestinal System"; + if (lower.Contains("neuro") || lower.Contains("special sense")) return "Nervous System & Special Senses"; + if (lower.Contains("renal") || lower.Contains("urinary") || lower.Contains("pregnan") || lower.Contains("reproductive")) return "Renal/Urinary & Male/Female Systems & Pregnancy"; + if (lower.Contains("behavior") || lower.Contains("ethic") || lower.Contains("communication") || lower.Contains("safety")) return "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety"; + if (lower.Contains("immune") || lower.Contains("blood") || lower.Contains("endocrine") || lower.Contains("multisystem")) return "Immune/Blood & Lymphoreticular/Endocrine/Multisystem"; + if (lower.Contains("biostat") || lower.Contains("epidemi") || lower.Contains("population")) return "Biostatistics & Epidemiology/Population Health"; + if (lower.Contains("musculo") || lower.Contains("skin") || lower.Contains("derm")) return "Musculoskeletal/Skin & Subcutaneous Tissue"; + } + + return Uncategorized; + } } diff --git a/api/QuestionsApi/Models/RatingModels.cs b/api/QuestionsApi/Models/RatingModels.cs new file mode 100644 index 0000000..9b1feb7 --- /dev/null +++ b/api/QuestionsApi/Models/RatingModels.cs @@ -0,0 +1,48 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public class UserRating +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public string UserId { get; set; } = ""; + public int OverallElo { get; set; } = 1000; + public Dictionary CategoryElos { get; set; } = []; + public int RatedQuestionCount { get; set; } + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public class QuestionRating +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + public string QuestionExternalId { get; set; } = ""; + public int Elo { get; set; } = 1000; + public int AttemptCount { get; set; } + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public class RatingChangeResponse +{ + public bool Rated { get; set; } + public string Category { get; set; } = ""; + public int UserOverallElo { get; set; } + public int UserOverallDelta { get; set; } + public int UserCategoryElo { get; set; } + public int UserCategoryDelta { get; set; } + public int QuestionElo { get; set; } + public int QuestionEloDelta { get; set; } +} + +public class RatingProfileResponse +{ + public int OverallElo { get; set; } + public int RatedQuestionCount { get; set; } + public Dictionary CategoryElos { get; set; } = []; +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 368b9c7..fd2a56d 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -10,6 +10,7 @@ builder.Services.AddControllers(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => diff --git a/api/QuestionsApi/Services/QuestionAttemptStore.cs b/api/QuestionsApi/Services/QuestionAttemptStore.cs index fc9613a..7592c37 100644 --- a/api/QuestionsApi/Services/QuestionAttemptStore.cs +++ b/api/QuestionsApi/Services/QuestionAttemptStore.cs @@ -26,7 +26,7 @@ public class QuestionAttemptStore _col.Indexes.CreateOne(new CreateIndexModel(questionIndex)); } - public async Task RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect) + public async Task RecordFirstAttemptAsync(string questionExternalId, string userId, string choiceId, bool isCorrect, bool isSkip = false) { var attempt = new QuestionAttempt { @@ -34,15 +34,18 @@ public class QuestionAttemptStore UserId = userId, ChoiceId = choiceId, IsCorrect = isCorrect, + IsSkip = isSkip, CreatedUtc = DateTime.UtcNow }; try { await _col.InsertOneAsync(attempt); + return true; } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { + return false; } } @@ -58,7 +61,7 @@ public class QuestionAttemptStore if (ids.Count == 0) return []; - var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId)).ToListAsync(); + var attempts = await _col.Find(a => ids.Contains(a.QuestionExternalId) && !a.IsSkip).ToListAsync(); var output = new Dictionary>(); foreach (var group in attempts.GroupBy(a => a.QuestionExternalId)) diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index fd835c2..912d144 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -139,6 +139,7 @@ public class QuestionStore ChoiceSelectionPercentages = req.ChoiceSelectionPercentages, Discipline = req.Discipline.Trim(), System = req.System.Trim(), + PerformanceCategory = RatingCategory.Normalize(req.PerformanceCategory, req.System, req.Discipline), Difficulty = req.Difficulty, Labs = req.Labs, Media = req.Media, diff --git a/api/QuestionsApi/Services/RatingStore.cs b/api/QuestionsApi/Services/RatingStore.cs new file mode 100644 index 0000000..9616ba0 --- /dev/null +++ b/api/QuestionsApi/Services/RatingStore.cs @@ -0,0 +1,157 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public class RatingStore +{ + private const int BaseElo = 1000; + private const int MinElo = 100; + + private readonly IMongoCollection _users; + private readonly IMongoCollection _questions; + + public RatingStore(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _users = db.GetCollection("UserRatings"); + _questions = db.GetCollection("QuestionRatings"); + + _users.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(r => r.UserId), + new CreateIndexOptions { Unique = true })); + _questions.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(r => r.QuestionExternalId), + new CreateIndexOptions { Unique = true })); + } + + public async Task GetProfileAsync(string userId) + { + var rating = await GetOrCreateUserRatingAsync(userId); + return new RatingProfileResponse + { + OverallElo = rating.OverallElo, + RatedQuestionCount = rating.RatedQuestionCount, + CategoryElos = rating.CategoryElos + }; + } + + public async Task> GetQuestionElosAsync(IEnumerable externalIds) + { + var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList(); + if (ids.Count == 0) + return []; + + var ratings = await _questions.Find(r => ids.Contains(r.QuestionExternalId)).ToListAsync(); + return ids.ToDictionary( + id => id, + id => ratings.FirstOrDefault(r => r.QuestionExternalId == id)?.Elo ?? BaseElo); + } + + public async Task GetQuestionEloAsync(string questionExternalId) + { + var rating = await GetOrCreateQuestionRatingAsync(questionExternalId); + return rating.Elo; + } + + public async Task ApplyFirstResultAsync(string userId, Question question, bool isCorrect, bool shouldRate) + { + var userRating = await GetOrCreateUserRatingAsync(userId); + var questionRating = await GetOrCreateQuestionRatingAsync(question.ExternalId); + var category = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline); + + if (!shouldRate) + { + return new RatingChangeResponse + { + Rated = false, + Category = category, + UserOverallElo = userRating.OverallElo, + UserCategoryElo = userRating.CategoryElos.GetValueOrDefault(category, BaseElo), + QuestionElo = questionRating.Elo + }; + } + + var actual = isCorrect ? 1.0 : 0.0; + var userK = userRating.RatedQuestionCount < 20 ? 40 : 24; + var questionK = questionRating.AttemptCount < 20 ? 32 : 24; + + var oldOverall = userRating.OverallElo; + var oldCategory = userRating.CategoryElos.GetValueOrDefault(category, BaseElo); + var oldQuestion = questionRating.Elo; + + var overallExpected = ExpectedScore(oldOverall, oldQuestion); + var categoryExpected = ExpectedScore(oldCategory, oldQuestion); + var overallDelta = (int)Math.Round(userK * (actual - overallExpected)); + var categoryDelta = (int)Math.Round(userK * (actual - categoryExpected)); + var questionDelta = (int)Math.Round(questionK * (overallExpected - actual)); + + userRating.OverallElo = Math.Max(MinElo, oldOverall + overallDelta); + userRating.CategoryElos[category] = Math.Max(MinElo, oldCategory + categoryDelta); + userRating.RatedQuestionCount++; + userRating.UpdatedUtc = DateTime.UtcNow; + + questionRating.Elo = Math.Max(MinElo, oldQuestion + questionDelta); + questionRating.AttemptCount++; + questionRating.UpdatedUtc = DateTime.UtcNow; + + await _users.ReplaceOneAsync(r => r.UserId == userRating.UserId, userRating, new ReplaceOptions { IsUpsert = true }); + await _questions.ReplaceOneAsync(r => r.QuestionExternalId == questionRating.QuestionExternalId, questionRating, new ReplaceOptions { IsUpsert = true }); + + return new RatingChangeResponse + { + Rated = true, + Category = category, + UserOverallElo = userRating.OverallElo, + UserOverallDelta = userRating.OverallElo - oldOverall, + UserCategoryElo = userRating.CategoryElos[category], + UserCategoryDelta = userRating.CategoryElos[category] - oldCategory, + QuestionElo = questionRating.Elo, + QuestionEloDelta = questionRating.Elo - oldQuestion + }; + } + + private async Task GetOrCreateUserRatingAsync(string userId) + { + var rating = await _users.Find(r => r.UserId == userId).FirstOrDefaultAsync(); + if (rating is null) + { + rating = new UserRating { UserId = userId, CategoryElos = DefaultCategoryElos() }; + await _users.InsertOneAsync(rating); + return rating; + } + + var changed = false; + foreach (var category in RatingCategory.Categories) + { + if (!rating.CategoryElos.ContainsKey(category)) + { + rating.CategoryElos[category] = BaseElo; + changed = true; + } + } + if (changed) + await _users.ReplaceOneAsync(r => r.UserId == userId, rating); + return rating; + } + + private async Task GetOrCreateQuestionRatingAsync(string questionExternalId) + { + var rating = await _questions.Find(r => r.QuestionExternalId == questionExternalId).FirstOrDefaultAsync(); + if (rating is not null) + return rating; + + rating = new QuestionRating { QuestionExternalId = questionExternalId }; + await _questions.InsertOneAsync(rating); + return rating; + } + + private static Dictionary DefaultCategoryElos() => + RatingCategory.Categories.ToDictionary(category => category, _ => BaseElo); + + private static double ExpectedScore(int playerElo, int questionElo) => + 1.0 / (1.0 + Math.Pow(10.0, (questionElo - playerElo) / 400.0)); +} diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 909b375..65c7dfb 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -130,6 +130,29 @@ func submit_core_attempt(choice_id: String, eliminated: Array, flagged: bool) -> } +func skip_core_question(eliminated: Array, flagged: bool) -> Dictionary: + var question := get_current_question() + if question.is_empty(): + return {} + + var answer := { + "question": question.duplicate(true), + "selected_choice_id": "", + "is_correct": false, + "is_skip": true, + "eliminated_choice_ids": eliminated.duplicate(), + "flagged": flagged, + "elapsed_seconds": get_current_elapsed_seconds(), + "target_seconds": core_current_target_seconds + } + answers[current_index] = answer + ProfileService.record_wrong_attempt(question) + _apply_core_wrong_penalty(answer) + core_target_seconds = min(CORE_MAX_TARGET_SECONDS, core_target_seconds * 1.05) + session_changed.emit() + return answer + + func move_previous() -> bool: if current_index > 0: current_index -= 1 @@ -207,6 +230,15 @@ func update_question_choice_percentages(question_id: String, percentages: Dictio break +func update_question_elo(question_id: String, question_elo: int) -> void: + for index in range(active_questions.size()): + var question: Dictionary = active_questions[index] + if str(question.get("id", "")) == question_id: + question["question_elo"] = question_elo + active_questions[index] = question + break + + func advance_core_question() -> bool: var question := _draw_core_question() if question.is_empty(): diff --git a/scripts/autoload/profile_service.gd b/scripts/autoload/profile_service.gd index 4fe9f2a..ca344f0 100644 --- a/scripts/autoload/profile_service.gd +++ b/scripts/autoload/profile_service.gd @@ -62,12 +62,9 @@ func get_profile() -> Dictionary: func apply_block_results(results: Dictionary) -> void: _ensure_daily_current() - profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0)) - profile["level"] = ProgressionService.level_for_xp(int(profile["xp"])) profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"])) profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"])) profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0)) - profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0)) var unlocked_badges: Array = results.get("badges_unlocked", []) for badge in unlocked_badges: @@ -91,7 +88,7 @@ func apply_block_results(results: Dictionary) -> void: "system": results.get("system", "All Systems"), "correct": results.get("correct", 0), "total": results.get("total", 0), - "xp_earned": results.get("xp_earned", 0) + "xp_earned": 0 } profile["block_history"].push_front(summary) if profile["block_history"].size() > 12: @@ -135,12 +132,9 @@ func reset_qbank_memory() -> void: func apply_question_results(results: Dictionary) -> void: _ensure_daily_current() - profile["xp"] = int(profile["xp"]) + int(results.get("xp_earned", 0)) - profile["level"] = ProgressionService.level_for_xp(int(profile["xp"])) profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"])) profile["best_streak"] = max(int(profile["best_streak"]), int(profile["current_streak"])) profile["daily_questions_answered"] = int(profile["daily_questions_answered"]) + int(results.get("total", 0)) - profile["daily_xp_earned"] = int(profile["daily_xp_earned"]) + int(results.get("xp_earned", 0)) var unlocked_badges: Array = results.get("badges_unlocked", []) for badge in unlocked_badges: diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 8504732..fa9a416 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -2,27 +2,42 @@ extends Node const DEFAULT_QUESTIONS_API_URL := "https://s3q.ranaze.com/api/questions/search" const DEFAULT_QUESTION_ATTEMPT_API_URL := "https://s3q.ranaze.com/api/questions/attempts/first" +const DEFAULT_QUESTION_API_BASE_URL := "https://s3q.ranaze.com/api/questions" +const DEFAULT_RATINGS_API_URL := "https://s3q.ranaze.com/api/ratings/me" const RETRY_DELAYS := [2, 5, 10, 20, 30] signal questions_changed signal questions_load_failed(message: String) signal choice_percentages_updated(question_id: String, percentages: Dictionary) +signal ratings_changed +signal question_rating_updated(question_id: String, question_elo: int, rating: Dictionary) +signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary) var questions: Array = [] var request: HTTPRequest var attempt_request: HTTPRequest +var rating_request: HTTPRequest +var skip_request: HTTPRequest var retry_timer: Timer var questions_api_url := DEFAULT_QUESTIONS_API_URL var question_attempt_api_url := DEFAULT_QUESTION_ATTEMPT_API_URL +var question_api_base_url := DEFAULT_QUESTION_API_BASE_URL +var ratings_api_url := DEFAULT_RATINGS_API_URL var is_loading := false var last_error := "" var load_state := "idle" var retry_attempt := 0 var next_retry_seconds := 0 +var overall_elo := 1000 +var rated_question_count := 0 +var category_elos := {} +var last_rating_change := {} func _ready() -> void: questions_api_url = str(ProjectSettings.get_setting("step3/questions_api_url", DEFAULT_QUESTIONS_API_URL)) question_attempt_api_url = str(ProjectSettings.get_setting("step3/question_attempt_api_url", DEFAULT_QUESTION_ATTEMPT_API_URL)) + question_api_base_url = str(ProjectSettings.get_setting("step3/question_api_base_url", DEFAULT_QUESTION_API_BASE_URL)) + ratings_api_url = str(ProjectSettings.get_setting("step3/ratings_api_url", DEFAULT_RATINGS_API_URL)) request = HTTPRequest.new() request.request_completed.connect(_on_api_questions_loaded) add_child(request) @@ -31,6 +46,14 @@ func _ready() -> void: attempt_request.request_completed.connect(_on_first_attempt_recorded) add_child(attempt_request) + rating_request = HTTPRequest.new() + rating_request.request_completed.connect(_on_ratings_loaded) + add_child(rating_request) + + skip_request = HTTPRequest.new() + skip_request.request_completed.connect(_on_question_skipped) + add_child(skip_request) + retry_timer = Timer.new() retry_timer.one_shot = true retry_timer.timeout.connect(_on_retry_timer_timeout) @@ -52,6 +75,7 @@ func load_questions_from_api(reset_retry: bool = true) -> void: last_error = "" questions.clear() questions_changed.emit() + load_ratings_from_api() var headers := PackedStringArray([ "Content-Type: application/json", @@ -107,6 +131,10 @@ func clear_questions() -> void: request.cancel_request() if attempt_request != null: attempt_request.cancel_request() + if rating_request != null: + rating_request.cancel_request() + if skip_request != null: + skip_request.cancel_request() if retry_timer != null: retry_timer.stop() questions.clear() @@ -115,7 +143,12 @@ func clear_questions() -> void: load_state = "idle" retry_attempt = 0 next_retry_seconds = 0 + overall_elo = 1000 + rated_question_count = 0 + category_elos = {} + last_rating_change = {} questions_changed.emit() + ratings_changed.emit() func record_first_attempt(question_id: String, choice_id: String) -> void: @@ -135,6 +168,31 @@ func record_first_attempt(question_id: String, choice_id: String) -> void: push_warning("Failed to record first question attempt.") +func skip_question(question_id: String) -> void: + if AuthState.access_token.is_empty() or question_id == "": + return + + var headers := PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % AuthState.access_token + ]) + var url := "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()] + var err := skip_request.request(url, headers, HTTPClient.METHOD_POST, "") + if err != OK: + push_warning("Failed to record skipped question.") + + +func load_ratings_from_api() -> void: + if AuthState.access_token.is_empty(): + return + var headers := PackedStringArray([ + "Authorization: Bearer %s" % AuthState.access_token + ]) + var err := rating_request.request(ratings_api_url, headers, HTTPClient.METHOD_GET) + if err != OK: + push_warning("Failed to request rating profile.") + + func get_all_questions() -> Array: return questions.duplicate(true) @@ -214,7 +272,53 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe return _update_question_percentages(question_id, percentages) + var question_elo := int(parsed.get("questionElo", parsed.get("question_elo", 1000))) + var rating: Dictionary = parsed.get("rating", {}) + _update_question_elo(question_id, question_elo) + _apply_rating_change(rating) choice_percentages_updated.emit(question_id, percentages) + question_rating_updated.emit(question_id, question_elo, rating) + + +func _on_question_skipped(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + push_warning("Question skip API request failed with status %d." % response_code) + return + + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("Question skip API response was not an object.") + return + + 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", {})) + var question_elo := int(parsed.get("questionElo", parsed.get("question_elo", 1000))) + var rating: Dictionary = parsed.get("rating", {}) + if question_id == "": + return + + _update_question_percentages(question_id, percentages) + _update_question_elo(question_id, question_elo) + _apply_rating_change(rating) + choice_percentages_updated.emit(question_id, percentages) + question_skipped.emit(question_id, correct_choice_id, question_elo, rating) + + +func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + push_warning("Ratings API request failed with status %d." % response_code) + return + + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("Ratings API response was not an object.") + return + + overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000))) + rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0))) + category_elos = parsed.get("categoryElos", parsed.get("category_elos", {})) + ratings_changed.emit() func _update_question_percentages(question_id: String, percentages: Dictionary) -> void: @@ -226,6 +330,27 @@ func _update_question_percentages(question_id: String, percentages: Dictionary) return +func _update_question_elo(question_id: String, question_elo: int) -> void: + for index in range(questions.size()): + var question: Dictionary = questions[index] + if str(question.get("id", "")) == question_id: + question["question_elo"] = question_elo + questions[index] = question + return + + +func _apply_rating_change(rating: Dictionary) -> void: + if rating.is_empty(): + return + last_rating_change = rating + overall_elo = int(rating.get("userOverallElo", rating.get("user_overall_elo", overall_elo))) + var category := str(rating.get("category", "")) + if category != "": + category_elos[category] = int(rating.get("userCategoryElo", rating.get("user_category_elo", category_elos.get(category, 1000)))) + rated_question_count += 1 if bool(rating.get("rated", false)) else 0 + ratings_changed.emit() + + func _is_valid_question(item: Variant) -> bool: if typeof(item) != TYPE_DICTIONARY: return false @@ -252,7 +377,9 @@ func _normalize_api_question(item: Variant) -> Dictionary: "choice_selection_percentages": item.get("choiceSelectionPercentages", item.get("choice_selection_percentages", {})), "discipline": str(item.get("discipline", "")), "system": str(item.get("system", "")), + "performance_category": str(item.get("performanceCategory", item.get("performance_category", ""))), "difficulty": int(item.get("difficulty", 1)), + "question_elo": int(item.get("questionElo", item.get("question_elo", 1000))), "labs": item.get("labs", []), "media": item.get("media", []), "tags": item.get("tags", []) diff --git a/scripts/ui/dashboard.gd b/scripts/ui/dashboard.gd index 373a77f..d996cd9 100644 --- a/scripts/ui/dashboard.gd +++ b/scripts/ui/dashboard.gd @@ -5,7 +5,7 @@ const Style := preload("res://scripts/ui/ui_style.gd") var main: Control var status_label: Label var daily_label: Label -var xp_label: Label +var elo_label: Label var streak_label: Label var bank_label: Label var error_label: Label @@ -17,6 +17,8 @@ func _ready() -> void: add_theme_constant_override("separation", 18) if not QuestionBankService.questions_changed.is_connected(_refresh): QuestionBankService.questions_changed.connect(_refresh) + if not QuestionBankService.ratings_changed.is_connected(_refresh): + QuestionBankService.ratings_changed.connect(_refresh) if not ProfileService.profile_changed.is_connected(_refresh): ProfileService.profile_changed.connect(_refresh) @@ -36,7 +38,7 @@ func _ready() -> void: add_child(summary) daily_label = _add_metric(summary, "Today") - xp_label = _add_metric(summary, "Daily XP") + elo_label = _add_metric(summary, "ELO") streak_label = _add_metric(summary, "Streak") bank_label = _add_metric(summary, "Question Bank") @@ -115,7 +117,10 @@ func _refresh() -> void: if progress > 100: progress = 100 daily_label.text = "Today\n%d/%d questions\n%d%% complete" % [answered, goal, progress] - xp_label.text = "Daily XP\n%d XP earned" % int(profile.get("daily_xp_earned", 0)) + elo_label.text = "ELO\n%d overall\n%d rated" % [ + QuestionBankService.overall_elo, + QuestionBankService.rated_question_count + ] streak_label.text = "Streak\n%d current\n%d best" % [ int(profile.get("current_streak", 0)), int(profile.get("best_streak", 0)) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index eff5322..7bd1ba3 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -131,6 +131,7 @@ func _build_ui() -> void: screens["profile"] = profile ProfileService.profile_changed.connect(_refresh_header_stats) + QuestionBankService.ratings_changed.connect(_refresh_header_stats) AuthState.auth_changed.connect(_refresh_header_stats) _refresh_header_stats() _build_app_menu() @@ -148,9 +149,8 @@ func _refresh_header_stats() -> void: if header_stats == null: return var profile := ProfileService.get_profile() - header_stats.text = "LV %d | %d XP | Streak %d" % [ - profile.get("level", 1), - profile.get("xp", 0), + header_stats.text = "ELO %d | Streak %d" % [ + QuestionBankService.overall_elo, profile.get("current_streak", 0) ] if user_label != null: diff --git a/scripts/ui/main_menu.gd b/scripts/ui/main_menu.gd index 4616be6..3cfd9d0 100644 --- a/scripts/ui/main_menu.gd +++ b/scripts/ui/main_menu.gd @@ -10,7 +10,7 @@ func _ready() -> void: add_theme_constant_override("separation", 18) var headline := Label.new() - headline.text = "Answer board-style questions. Earn XP. Unlock the map." + headline.text = "Answer board-style questions. Raise your ELO. Unlock the map." headline.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(headline, 38, Style.TEXT) add_child(headline) @@ -31,7 +31,7 @@ func _ready() -> void: hero.add_child(hero_content) var loop := Label.new() - loop.text = "XP Rush | Streak Heat | System Zones | Timed Blocks" + loop.text = "ELO Climb | Streak Heat | System Zones | Timed Blocks" Style.set_label(loop, 24, Style.CYAN) hero_content.add_child(loop) @@ -72,9 +72,8 @@ func _ready() -> void: func on_show(_payload: Variant = null) -> void: var profile := ProfileService.get_profile() - stats_label.text = "Level %d | %d XP | Current streak: %d | Badges: %d" % [ - profile["level"], - profile["xp"], + stats_label.text = "ELO %d | Current streak: %d | Badges: %d" % [ + QuestionBankService.overall_elo, profile["current_streak"], profile["badges"].size() ] diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd index e688311..3b73a4f 100644 --- a/scripts/ui/progress_profile.gd +++ b/scripts/ui/progress_profile.gd @@ -52,23 +52,21 @@ func _ready() -> void: func on_show(_payload: Variant = null) -> void: _reset_qbank_confirmation() var profile := ProfileService.get_profile() - var xp_into := ProgressionService.xp_into_level(int(profile["xp"])) - var text := "[b]Level %d[/b]\n%d total XP (%d/%d to next level)\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions, +%d XP\n\n[b]Badges[/b]\n" % [ - profile["level"], - profile["xp"], - xp_into, - ProgressionService.XP_PER_LEVEL, + var text := "[b]Overall ELO %d[/b]\n%d rated questions\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions\n\n[b]Category ELO[/b]\n" % [ + QuestionBankService.overall_elo, + QuestionBankService.rated_question_count, profile["current_streak"], profile["best_streak"], profile.get("daily_questions_answered", 0), - profile.get("daily_goal_questions", 10), - profile.get("daily_xp_earned", 0) + profile.get("daily_goal_questions", 10) ] - if profile["badges"].is_empty(): - text += "No badges yet.\n" + if QuestionBankService.category_elos.is_empty(): + text += "No rated categories yet.\n" else: - for badge in profile["badges"]: - text += "- %s\n" % badge + var categories := QuestionBankService.category_elos.keys() + categories.sort() + for category in categories: + text += "- %s: %d\n" % [category, int(QuestionBankService.category_elos[category])] text += "\n[b]System Accuracy[/b]\n" if profile["system_stats"].is_empty(): @@ -83,12 +81,11 @@ func on_show(_payload: Variant = null) -> void: text += "\n[b]Recent Blocks[/b]\n" for block in profile["block_history"]: - text += "- %s: %s %d/%d, +%d XP\n" % [ + text += "- %s: %s %d/%d\n" % [ block.get("timestamp", ""), block.get("system", ""), block.get("correct", 0), - block.get("total", 0), - block.get("xp_earned", 0) + block.get("total", 0) ] profile_text.text = text diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index a6f0a90..edf828f 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -6,6 +6,7 @@ const CORRECT_ADVANCE_DELAY_SECONDS := 0.6 var main: Control var choice_buttons: Array[Button] = [] +var choice_percent_labels: Array[Label] = [] var eliminated: Array = [] var locked_wrong_choice_ids: Array = [] var flagged := false @@ -23,7 +24,9 @@ var timer_label: Label var stem_label: RichTextLabel var lead_label: Label var explanation_label: RichTextLabel +var question_elo_label: Label var next_button: Button +var skip_button: Button var flag_button: Button var report_button: Button var nav_palette: HBoxContainer @@ -41,6 +44,10 @@ func _ready() -> void: add_theme_constant_override("separation", 10) if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated): QuestionBankService.choice_percentages_updated.connect(_on_choice_percentages_updated) + if not QuestionBankService.question_rating_updated.is_connected(_on_question_rating_updated): + QuestionBankService.question_rating_updated.connect(_on_question_rating_updated) + if not QuestionBankService.question_skipped.is_connected(_on_question_skipped): + QuestionBankService.question_skipped.connect(_on_question_skipped) var top := HBoxContainer.new() top.add_theme_constant_override("separation", 12) @@ -125,6 +132,12 @@ func _ready() -> void: Style.set_label(lead_label, 21, Style.TEXT) question_content.add_child(lead_label) + question_elo_label = Label.new() + question_elo_label.visible = false + question_elo_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + Style.set_label(question_elo_label, 16, Style.AMBER) + question_content.add_child(question_elo_label) + for index in range(5): var button := Button.new() button.custom_minimum_size = Vector2(0, 64) @@ -133,6 +146,19 @@ func _ready() -> void: button.pressed.connect(func(i := index): _select_choice(i)) button.gui_input.connect(func(event: InputEvent, i := index): _choice_gui_input(event, i)) choice_buttons.append(button) + + var percent_label := Label.new() + percent_label.visible = false + percent_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + percent_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + percent_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + percent_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + percent_label.offset_left = 0 + percent_label.offset_right = -18 + Style.set_label(percent_label, 18, Style.TEXT) + button.add_child(percent_label) + choice_percent_labels.append(percent_label) + question_content.add_child(button) explanation_label = RichTextLabel.new() @@ -145,6 +171,13 @@ func _ready() -> void: var actions := HBoxContainer.new() actions.add_theme_constant_override("separation", 10) + skip_button = Button.new() + skip_button.text = "Skip" + skip_button.custom_minimum_size = Vector2(120, 48) + Style.style_chip(skip_button, Style.AMBER) + skip_button.pressed.connect(_skip_current_question) + actions.add_child(skip_button) + next_button = Button.new() next_button.text = "Submit" next_button.custom_minimum_size = Vector2(140, 48) @@ -215,7 +248,12 @@ func _load_question() -> void: flag_button.button_pressed = false _refresh_toolbar_state() explanation_label.visible = false + question_elo_label.visible = false + question_elo_label.text = "" next_button.text = "Submit" + next_button.disabled = false + skip_button.disabled = false + skip_button.visible = BlockSessionService.session_kind == "core" var question := BlockSessionService.get_current_question() if question.is_empty(): @@ -255,6 +293,7 @@ func _load_question() -> void: else: _apply_saved_choice_state() next_button.text = "Next" + _update_question_elo_label(true) _refresh_nav_palette() _refresh_toolbar_state() @@ -276,6 +315,25 @@ func _select_choice(index: int) -> void: _apply_saved_choice_state() +func _skip_current_question() -> void: + if pending_correct_advance or not current_answer.is_empty() or BlockSessionService.session_kind != "core": + return + var question_id := str(BlockSessionService.get_current_question().get("id", "")) + if question_id == "": + return + current_answer = BlockSessionService.skip_core_question(eliminated, flagged) + if current_answer.is_empty(): + return + QuestionBankService.skip_question(question_id) + selected_choice = "" + next_button.disabled = true + skip_button.disabled = true + pending_correct_advance = true + correct_advance_delay = CORRECT_ADVANCE_DELAY_SECONDS + _show_explanation() + _update_question_elo_label(true) + + func _choice_gui_input(event: InputEvent, index: int) -> void: if pending_correct_advance: return @@ -333,6 +391,7 @@ func _submit_or_next() -> void: var answer: Dictionary = result.get("answer", {}) if bool(result.get("is_correct", false)): current_answer = answer + _update_question_elo_label(true) _trigger_correct_feedback() return _lock_wrong_choice(attempted_choice) @@ -361,6 +420,7 @@ func _show_explanation() -> void: for index in range(question.get("choices", []).size()): var choice: Dictionary = question["choices"][index] var id := str(choice.get("id", "")) + _set_choice_button_text(index, choice) if id == correct_id: choice_buttons[index].modulate = Color(0.54, 1.0, 0.58) elif id == selected_choice: @@ -369,6 +429,7 @@ func _show_explanation() -> void: var result_text := "Correct" if current_answer.get("is_correct", false) else "Incorrect" explanation_label.text = "[b]%s[/b]\n%s" % [result_text, question.get("explanation", "")] explanation_label.visible = true + _update_question_elo_label(true) _refresh_nav_palette() @@ -434,6 +495,8 @@ func _trigger_correct_feedback() -> void: red_fog_time = 0.0 green_fog_time = FEEDBACK_FADE_SECONDS next_button.disabled = true + if skip_button != null: + skip_button.disabled = true for button in choice_buttons: button.disabled = true if feedback_fog != null: @@ -462,6 +525,8 @@ func _update_answer_feedback(delta: float) -> void: if correct_advance_delay <= 0.0: pending_correct_advance = false next_button.disabled = false + if skip_button != null: + skip_button.disabled = false _advance_or_finish() @@ -474,6 +539,8 @@ func _reset_wrong_feedback() -> void: correct_advance_delay = 0.0 pending_correct_advance = false next_button.disabled = false + if skip_button != null: + skip_button.disabled = false if feedback_fog != null: feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0) @@ -596,22 +663,38 @@ func _apply_saved_choice_state() -> void: else: choice_buttons[index].modulate = Color.WHITE + if pending_correct_advance: + _apply_correct_answer_preview() + func _set_choice_button_text(index: int, choice: Dictionary) -> void: var choice_id := str(choice.get("id", "")) var choice_text := str(choice.get("text", "")) + _set_choice_percent_label(index, "") if locked_wrong_choice_ids.has(choice_id): - var text := "%s. %s" % [choice_id, _strike_text(choice_text)] var percentage := _get_choice_selection_percentage(choice_id) if percentage >= 0: - text += "\n %d%%" % percentage - choice_buttons[index].text = text + _set_choice_percent_label(index, "%d%%" % percentage) + choice_buttons[index].text = "%s. %s" % [choice_id, _strike_text(choice_text)] + elif _should_show_correct_choice_percentage(choice_id): + var correct_percentage := _get_choice_selection_percentage(choice_id) + if correct_percentage >= 0: + _set_choice_percent_label(index, "%d%%" % correct_percentage) + choice_buttons[index].text = "%s. %s" % [choice_id, choice_text] elif eliminated.has(choice_id): choice_buttons[index].text = "%s. [eliminated] %s" % [choice_id, _strike_text(choice_text)] else: choice_buttons[index].text = "%s. %s" % [choice_id, choice_text] +func _set_choice_percent_label(index: int, text: String) -> void: + if index < 0 or index >= choice_percent_labels.size(): + return + var label := choice_percent_labels[index] + label.text = text + label.visible = text != "" + + func _get_choice_selection_percentage(choice_id: String) -> int: var question := BlockSessionService.get_current_question() var percentages: Dictionary = question.get("choice_selection_percentages", {}) @@ -622,6 +705,21 @@ func _get_choice_selection_percentage(choice_id: String) -> int: return clamp(int(percentages.get(choice_id, -1)), 0, 100) +func _should_show_correct_choice_percentage(choice_id: String) -> bool: + var question := BlockSessionService.get_current_question() + return choice_id == str(question.get("correct_choice_id", "")) and (pending_correct_advance or not current_answer.is_empty()) + + +func _apply_correct_answer_preview() -> void: + var question := BlockSessionService.get_current_question() + var correct_id := str(question.get("correct_choice_id", "")) + var choices: Array = question.get("choices", []) + for index in range(choices.size()): + if str(choices[index].get("id", "")) == correct_id: + choice_buttons[index].modulate = Color(0.54, 1.0, 0.58) + return + + func _on_choice_percentages_updated(question_id: String, percentages: Dictionary) -> void: if not visible: return @@ -631,6 +729,37 @@ func _on_choice_percentages_updated(question_id: String, percentages: Dictionary _apply_saved_choice_state() +func _on_question_rating_updated(question_id: String, question_elo: int, _rating: Dictionary) -> void: + if str(BlockSessionService.get_current_question().get("id", "")) != question_id: + return + BlockSessionService.update_question_elo(question_id, question_elo) + _update_question_elo_label(not current_answer.is_empty() or pending_correct_advance) + _apply_saved_choice_state() + + +func _on_question_skipped(question_id: String, correct_choice_id: String, question_elo: int, _rating: Dictionary) -> void: + if str(BlockSessionService.get_current_question().get("id", "")) != question_id: + return + var question := BlockSessionService.get_current_question() + if correct_choice_id != "": + question["correct_choice_id"] = correct_choice_id + BlockSessionService.update_question_elo(question_id, question_elo) + _update_question_elo_label(true) + _apply_saved_choice_state() + + +func _update_question_elo_label(show_label: bool) -> void: + if question_elo_label == null: + return + if not show_label: + question_elo_label.visible = false + return + var question := BlockSessionService.get_current_question() + var question_elo := int(question.get("question_elo", 1000)) + question_elo_label.text = "Question ELO %d" % question_elo + question_elo_label.visible = true + + func _strike_text(value: String) -> String: var result := "" for character in value: diff --git a/scripts/ui/review_results.gd b/scripts/ui/review_results.gd index e59d381..6935289 100644 --- a/scripts/ui/review_results.gd +++ b/scripts/ui/review_results.gd @@ -48,11 +48,10 @@ func _ready() -> void: func on_show(payload: Variant = null) -> void: var results: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {} var accuracy := int(round(float(results.get("accuracy", 0.0)) * 100.0)) - summary_label.text = "%d/%d correct (%d%%) | +%d XP | Streak: %d | New badges: %s" % [ + summary_label.text = "%d/%d correct (%d%%) | Streak: %d | New badges: %s" % [ results.get("correct", 0), results.get("total", 0), accuracy, - results.get("xp_earned", 0), results.get("ending_streak", 0), ", ".join(results.get("badges_unlocked", [])) if results.get("badges_unlocked", []).size() > 0 else "None" ] diff --git a/scripts/ui/study_map.gd b/scripts/ui/study_map.gd index cf04b06..94bfb1b 100644 --- a/scripts/ui/study_map.gd +++ b/scripts/ui/study_map.gd @@ -17,7 +17,7 @@ func _ready() -> void: add_child(title) var help := Label.new() - help.text = "Choose a system zone. Strong scores unlock momentum badges and level progress." + help.text = "Choose a system zone. Strong scores raise your rating and build momentum." help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(help, 16, Style.MUTED) add_child(help)