From 206a5c0124ad4122b710650112a7fb321b4b1abf Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 14 Jul 2026 16:54:11 -0500 Subject: [PATCH] Add full-length Step 3 Day 1 simulation --- api/QuestionsApi.Tests/ExamSimulationTests.cs | 87 +++++ .../Controllers/ExamSimulationsController.cs | 115 ++++++ api/QuestionsApi/Models/ExamSimulation.cs | 139 ++++++++ api/QuestionsApi/Program.cs | 1 + .../Services/ExamSimulationScoring.cs | 100 ++++++ .../Services/ExamSimulationStore.cs | 333 ++++++++++++++++++ .../Services/QuestionAttemptStore.cs | 6 + api/QuestionsApi/Services/QuestionStore.cs | 10 + project.godot | 1 + scripts/autoload/block_session_service.gd | 49 +++ scripts/autoload/exam_simulation_service.gd | 184 ++++++++++ .../autoload/exam_simulation_service.gd.uid | 1 + scripts/ui/main.gd | 25 +- scripts/ui/question_player.gd | 110 +++++- scripts/ui/simulation_break.gd | 125 +++++++ scripts/ui/simulation_break.gd.uid | 1 + scripts/ui/simulation_results.gd | 169 +++++++++ scripts/ui/simulation_results.gd.uid | 1 + scripts/ui/study_selector.gd | 95 ++++- 19 files changed, 1520 insertions(+), 32 deletions(-) create mode 100644 api/QuestionsApi.Tests/ExamSimulationTests.cs create mode 100644 api/QuestionsApi/Controllers/ExamSimulationsController.cs create mode 100644 api/QuestionsApi/Models/ExamSimulation.cs create mode 100644 api/QuestionsApi/Services/ExamSimulationScoring.cs create mode 100644 api/QuestionsApi/Services/ExamSimulationStore.cs create mode 100644 scripts/autoload/exam_simulation_service.gd create mode 100644 scripts/autoload/exam_simulation_service.gd.uid create mode 100644 scripts/ui/simulation_break.gd create mode 100644 scripts/ui/simulation_break.gd.uid create mode 100644 scripts/ui/simulation_results.gd create mode 100644 scripts/ui/simulation_results.gd.uid diff --git a/api/QuestionsApi.Tests/ExamSimulationTests.cs b/api/QuestionsApi.Tests/ExamSimulationTests.cs new file mode 100644 index 0000000..0385b60 --- /dev/null +++ b/api/QuestionsApi.Tests/ExamSimulationTests.cs @@ -0,0 +1,87 @@ +using QuestionsApi.Models; +using QuestionsApi.Services; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class ExamSimulationTests +{ + [Fact] + public void DayOneFormHasOfficialItemAndBlockCounts() + { + Assert.Equal(232, ExamSimulationStore.BlockSizes.Sum()); + Assert.Equal(12, ExamSimulationStore.BlockSizes.Length); + Assert.All(ExamSimulationStore.BlockSizes, size => Assert.InRange(size, 18, 20)); + } + + [Fact] + public void BlueprintTargetsFillTheCompleteForm() + { + var targets = ExamSimulationStore.BuildTargets(); + Assert.Equal(ExamSimulationStore.RequiredQuestions, targets.Values.Sum()); + Assert.Equal(14, targets.Count); + Assert.All(targets.Values, count => Assert.True(count > 0)); + } + + [Fact] + public void MoreCorrectAnswersProduceAHigherPrediction() + { + var items = BuildItems(100); + var questions = BuildQuestions(100); + var allCorrect = items.ToDictionary( + item => item.QuestionExternalId, + item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" }); + var allWrong = items.ToDictionary( + item => item.QuestionExternalId, + item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "B" }); + + var passing = ExamSimulationScoring.Calculate(items, allCorrect, questions); + var failing = ExamSimulationScoring.Calculate(items, allWrong, questions); + + Assert.True(passing.PredictedScore > failing.PredictedScore); + Assert.True(passing.PassProbability > failing.PassProbability); + Assert.Equal(100, passing.Correct); + Assert.Equal(0, failing.Correct); + } + + [Fact] + public void SeenItemsAreReportedAndCarryLessScoringWeight() + { + var questions = BuildQuestions(20); + var unseen = BuildItems(20); + var seen = BuildItems(20, wasSeen: true); + var responses = unseen.ToDictionary( + item => item.QuestionExternalId, + item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" }); + + var unseenScore = ExamSimulationScoring.Calculate(unseen, responses, questions); + var seenScore = ExamSimulationScoring.Calculate(seen, responses, questions); + + Assert.Equal(0, unseenScore.SeenCount); + Assert.Equal(20, seenScore.SeenCount); + Assert.True(unseenScore.AbilityElo > seenScore.AbilityElo); + } + + [Theory] + [InlineData("Cardiovascular System", "Cardiovascular")] + [InlineData("Professionalism and Ethics", "Social Sciences")] + [InlineData("Endocrine, Diabetes & Metabolism", "Endocrine")] + public void QuestionsMapToExpectedBlueprintCategory(string system, string expected) + { + Assert.Equal(expected, ExamSimulationStore.BlueprintCategory(new Question { System = system })); + } + + private static List BuildItems(int count, bool wasSeen = false) => + Enumerable.Range(0, count).Select(index => new ExamSimulationItem + { + QuestionExternalId = $"question-{index}", + QuestionElo = 1000, + WasSeen = wasSeen, + BlueprintCategory = "Cardiovascular" + }).ToList(); + + private static Dictionary BuildQuestions(int count) => + Enumerable.Range(0, count).ToDictionary( + index => $"question-{index}", + index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A" }); +} diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs new file mode 100644 index 0000000..3f8ecd2 --- /dev/null +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/exam-simulations")] +[Authorize(Roles = "USER,SUPER")] +public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase +{ + [HttpGet("readiness")] + public async Task Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); + + [HttpPost] + public async Task Create() + { + try + { + var simulation = await simulations.CreateAsync(UserId()); + return Ok(await simulations.ToResponseAsync(simulation)); + } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + + [HttpGet("active")] + public async Task Active() + { + var simulation = await simulations.FindActiveAsync(UserId()); + return simulation is null ? NoContent() : Ok(await simulations.ToResponseAsync(simulation)); + } + + [HttpGet("{id}")] + public async Task Get(string id) + { + var simulation = await simulations.GetOwnedAsync(id, UserId()); + return simulation is null ? NotFound() : Ok(await simulations.ToResponseAsync(simulation)); + } + + [HttpPost("{id}/blocks/start")] + public async Task StartBlock(string id) + { + var simulation = await simulations.GetOwnedAsync(id, UserId()); + if (simulation is null) return NotFound(); + try { return Ok(await simulations.ToResponseAsync(await simulations.StartNextBlockAsync(simulation))); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + + [HttpPut("{id}/responses")] + public async Task SaveResponse(string id, SaveSimulationResponseRequest request) + { + var simulation = await simulations.GetOwnedAsync(id, UserId()); + if (simulation is null) return NotFound(); + try { return Ok(await simulations.ToResponseAsync(await simulations.SaveResponseAsync(simulation, request))); } + catch (ArgumentException exception) { return BadRequest(exception.Message); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + + [HttpPost("{id}/blocks/submit")] + public async Task SubmitBlock(string id) + { + var simulation = await simulations.GetOwnedAsync(id, UserId()); + if (simulation is null) return NotFound(); + try { return Ok(await simulations.ToResponseAsync(await simulations.SubmitBlockAsync(simulation))); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + + [HttpPost("{id}/abandon")] + public async Task Abandon(string id) + { + var simulation = await simulations.GetOwnedAsync(id, UserId()); + if (simulation is null) return NotFound(); + await simulations.AbandonAsync(simulation); + return NoContent(); + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? throw new UnauthorizedAccessException(); +} + +[ApiController] +[Route("api/step3-outcomes")] +[Authorize(Roles = "USER,SUPER")] +public class Step3OutcomesController(ExamSimulationStore simulations) : ControllerBase +{ + [HttpGet("me")] + public async Task Get() + { + var outcome = await simulations.GetOutcomeAsync(UserId()); + return outcome is null ? NoContent() : Ok(outcome); + } + + [HttpPut("me")] + public async Task Put(UpsertStep3OutcomeRequest request) + { + try { return Ok(await simulations.UpsertOutcomeAsync(UserId(), request)); } + catch (ArgumentException exception) { return BadRequest(exception.Message); } + } + + [HttpDelete("me")] + public async Task Delete() + { + await simulations.DeleteOutcomeAsync(UserId()); + return NoContent(); + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? throw new UnauthorizedAccessException(); +} diff --git a/api/QuestionsApi/Models/ExamSimulation.cs b/api/QuestionsApi/Models/ExamSimulation.cs new file mode 100644 index 0000000..51b4c2d --- /dev/null +++ b/api/QuestionsApi/Models/ExamSimulation.cs @@ -0,0 +1,139 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using QuestionsApi.Services; + +namespace QuestionsApi.Models; + +public static class ExamSimulationStates +{ + public const string BetweenBlocks = "between_blocks"; + public const string BlockActive = "block_active"; + public const string Completed = "completed"; + public const string Abandoned = "abandoned"; +} + +public class ExamSimulation +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string State { get; set; } = ExamSimulationStates.BetweenBlocks; + public string ModelVersion { get; set; } = ExamSimulationScoring.ModelVersion; + public List Items { get; set; } = []; + public List Responses { get; set; } = []; + public int CurrentBlock { get; set; } = -1; + public int BreakSecondsRemaining { get; set; } = 3300; + public DateTime? BlockDeadlineUtc { get; set; } + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; + public DateTime? CompletedUtc { get; set; } + public ExamSimulationScore? Score { get; set; } + public Dictionary BlueprintTargets { get; set; } = []; + public Dictionary BlueprintActual { get; set; } = []; +} + +public class ExamSimulationItem +{ + public string QuestionExternalId { get; set; } = ""; + public int BlockIndex { get; set; } + public int Position { get; set; } + public List PresentedChoiceIds { get; set; } = []; + public int QuestionElo { get; set; } = 1000; + public bool WasSeen { get; set; } + public string BlueprintCategory { get; set; } = "Other"; +} + +public class ExamSimulationResponse +{ + public string QuestionExternalId { get; set; } = ""; + public string ChoiceId { get; set; } = ""; + public List EliminatedChoiceIds { get; set; } = []; + public bool Flagged { get; set; } + public int ElapsedSeconds { get; set; } + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public class ExamSimulationScore +{ + public int PredictedScore { get; set; } + public int RangeLow { get; set; } + public int RangeHigh { get; set; } + public double PassProbability { get; set; } + public int Correct { get; set; } + public int Total { get; set; } + public int SeenCount { get; set; } + public double AbilityElo { get; set; } + public Dictionary Categories { get; set; } = []; +} + +public class ExamSimulationCategoryScore +{ + public int Correct { get; set; } + public int Total { get; set; } +} + +public class SaveSimulationResponseRequest +{ + public string QuestionExternalId { get; set; } = ""; + public string ChoiceId { get; set; } = ""; + public List EliminatedChoiceIds { get; set; } = []; + public bool Flagged { get; set; } + public int ElapsedSeconds { get; set; } +} + +public class ExamSimulationQuestionResponse +{ + public string ExternalId { get; set; } = ""; + public string Stem { get; set; } = ""; + public string LeadIn { get; set; } = ""; + public List Choices { get; set; } = []; + public string Discipline { get; set; } = ""; + public string System { get; set; } = ""; + public string PerformanceCategory { get; set; } = ""; + public List Labs { get; set; } = []; + public List Media { get; set; } = []; +} + +public class ExamSimulationStateResponse +{ + public string Id { get; set; } = ""; + public string State { get; set; } = ""; + public int CurrentBlock { get; set; } + public int TotalBlocks { get; set; } = 12; + public int BreakSecondsRemaining { get; set; } + public DateTime? BlockDeadlineUtc { get; set; } + public List Questions { get; set; } = []; + public List Responses { get; set; } = []; + public ExamSimulationScore? Score { get; set; } + public Dictionary BlueprintTargets { get; set; } = []; + public Dictionary BlueprintActual { get; set; } = []; + public List ReviewQuestions { get; set; } = []; +} + +public class ExamSimulationReadinessResponse +{ + public bool Ready { get; set; } + public int AvailableQuestions { get; set; } + public int RequiredQuestions { get; set; } = 232; + public bool HasActiveSimulation { get; set; } +} + +public class Step3Outcome +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string UserId { get; set; } = ""; + public int OfficialScore { get; set; } + public DateTime ExamDate { get; set; } + public DateTime ConsentUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} + +public class UpsertStep3OutcomeRequest +{ + public int OfficialScore { get; set; } + public DateTime ExamDate { get; set; } + public bool Consent { get; set; } +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index fa94c4b..bb4bc80 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -16,6 +16,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/api/QuestionsApi/Services/ExamSimulationScoring.cs b/api/QuestionsApi/Services/ExamSimulationScoring.cs new file mode 100644 index 0000000..fae77a4 --- /dev/null +++ b/api/QuestionsApi/Services/ExamSimulationScoring.cs @@ -0,0 +1,100 @@ +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public static class ExamSimulationScoring +{ + public const string ModelVersion = "provisional-1"; + private const double PriorMean = 1000.0; + private const double PriorSd = 400.0; + + public static ExamSimulationScore Calculate( + IReadOnlyList items, + IReadOnlyDictionary responses, + IReadOnlyDictionary questions) + { + var ability = EstimateAbility(items, responses, questions); + var predicted = Math.Clamp((int)Math.Round(227.0 + (ability - 1000.0) * 15.0 / 200.0), 1, 300); + var information = 1.0 / (PriorSd * PriorSd); + var correct = 0; + var seen = 0; + var categories = new Dictionary(); + + foreach (var item in items) + { + if (!questions.TryGetValue(item.QuestionExternalId, out var question)) + continue; + responses.TryGetValue(item.QuestionExternalId, out var response); + var isCorrect = response?.ChoiceId == question.CorrectChoiceId; + if (isCorrect) correct++; + if (item.WasSeen) seen++; + var weight = item.WasSeen ? 0.5 : 1.0; + var probability = Expected(ability, item.QuestionElo); + information += weight * probability * (1.0 - probability) * Math.Pow(Math.Log(10.0) / 400.0, 2); + if (!categories.TryGetValue(item.BlueprintCategory, out var category)) + { + category = new ExamSimulationCategoryScore(); + categories[item.BlueprintCategory] = category; + } + category.Total++; + if (isCorrect) category.Correct++; + } + + var abilitySe = Math.Sqrt(1.0 / information); + var scoreSe = abilitySe * 15.0 / 200.0; + var combinedSe = Math.Sqrt(7.0 * 7.0 + 15.0 * 15.0 + scoreSe * scoreSe); + var range = (int)Math.Ceiling(combinedSe); + var passProbability = NormalCdf((predicted - 200.0) / combinedSe); + return new ExamSimulationScore + { + PredictedScore = predicted, + RangeLow = Math.Clamp(predicted - range, 1, 300), + RangeHigh = Math.Clamp(predicted + range, 1, 300), + PassProbability = Math.Round(passProbability, 4), + Correct = correct, + Total = items.Count, + SeenCount = seen, + AbilityElo = Math.Round(ability, 1), + Categories = categories + }; + } + + private static double EstimateAbility( + IReadOnlyList items, + IReadOnlyDictionary responses, + IReadOnlyDictionary questions) + { + var low = 100.0; + var high = 1900.0; + for (var iteration = 0; iteration < 80; iteration++) + { + var ability = (low + high) / 2.0; + var derivative = -(ability - PriorMean) / (PriorSd * PriorSd); + foreach (var item in items) + { + if (!questions.TryGetValue(item.QuestionExternalId, out var question)) continue; + responses.TryGetValue(item.QuestionExternalId, out var response); + var actual = response?.ChoiceId == question.CorrectChoiceId ? 1.0 : 0.0; + var weight = item.WasSeen ? 0.5 : 1.0; + derivative += weight * (actual - Expected(ability, item.QuestionElo)) * Math.Log(10.0) / 400.0; + } + if (derivative > 0) low = ability; else high = ability; + } + return (low + high) / 2.0; + } + + private static double Expected(double ability, int difficulty) => + 1.0 / (1.0 + Math.Pow(10.0, (difficulty - ability) / 400.0)); + + private static double NormalCdf(double value) => 0.5 * (1.0 + Erf(value / Math.Sqrt(2.0))); + + private static double Erf(double value) + { + var sign = value < 0 ? -1.0 : 1.0; + value = Math.Abs(value); + const double p = 0.3275911; + var t = 1.0 / (1.0 + p * value); + var y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.Exp(-value * value); + return sign * y; + } +} diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs new file mode 100644 index 0000000..663c313 --- /dev/null +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -0,0 +1,333 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public class ExamSimulationStore +{ + public const int RequiredQuestions = 232; + public static readonly int[] BlockSizes = [20, 19, 19, 20, 19, 19, 20, 19, 19, 20, 19, 19]; + private readonly IMongoCollection _simulations; + private readonly IMongoCollection _outcomes; + private readonly QuestionStore _questions; + private readonly QuestionAttemptStore _attempts; + private readonly AnswerStatisticsStore _statistics; + private readonly RatingStore _ratings; + private readonly ServiceMetrics _metrics; + + public ExamSimulationStore( + MongoDatabaseProvider mongo, + QuestionStore questions, + QuestionAttemptStore attempts, + AnswerStatisticsStore statistics, + RatingStore ratings, + ServiceMetrics metrics) + { + _simulations = mongo.Database.GetCollection("ExamSimulations"); + _outcomes = mongo.Database.GetCollection("Step3Outcomes"); + _questions = questions; + _attempts = attempts; + _statistics = statistics; + _ratings = ratings; + _metrics = metrics; + _simulations.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State))); + _simulations.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(item => item.UpdatedUtc))); + _outcomes.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(item => item.UserId), + new CreateIndexOptions { Unique = true })); + } + + public async Task GetReadinessAsync(string userId) + { + var count = (await _questions.GetPublishedAsync()).Count(question => question.Choices.Count >= 5); + return new ExamSimulationReadinessResponse + { + Ready = count >= RequiredQuestions, + AvailableQuestions = count, + HasActiveSimulation = await FindActiveAsync(userId) is not null + }; + } + + public async Task CreateAsync(string userId) + { + if (await FindActiveAsync(userId) is not null) + throw new InvalidOperationException("An active Step 3 simulation already exists."); + var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList(); + if (bank.Count < RequiredQuestions) + throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required."); + var seen = (await _attempts.GetUserQuestionIdsAsync(userId)).ToHashSet(StringComparer.Ordinal); + var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId)); + var targets = BuildTargets(); + var selected = SelectForm(bank, seen, targets); + var items = new List(); + var position = 0; + for (var block = 0; block < BlockSizes.Length; block++) + { + for (var offset = 0; offset < BlockSizes[block]; offset++) + { + var question = selected[position]; + items.Add(new ExamSimulationItem + { + QuestionExternalId = question.ExternalId, + BlockIndex = block, + Position = offset, + PresentedChoiceIds = SelectChoices(question), + QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000), + WasSeen = seen.Contains(question.ExternalId), + BlueprintCategory = BlueprintCategory(question) + }); + position++; + } + } + var simulation = new ExamSimulation + { + UserId = userId, + Items = items, + BlueprintTargets = targets, + BlueprintActual = items.GroupBy(item => item.BlueprintCategory).ToDictionary(group => group.Key, group => group.Count()) + }; + await _simulations.InsertOneAsync(simulation); + return simulation; + } + + public async Task FindActiveAsync(string userId) => + await _simulations.Find(item => item.UserId == userId && + (item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive)) + .SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync(); + + public async Task GetOwnedAsync(string id, string userId) + { + var simulation = await _simulations.Find(item => item.Id == id && item.UserId == userId).FirstOrDefaultAsync(); + if (simulation is not null && simulation.State == ExamSimulationStates.BlockActive && simulation.BlockDeadlineUtc <= DateTime.UtcNow) + simulation = await SubmitBlockAsync(simulation, true); + return simulation; + } + + public async Task StartNextBlockAsync(ExamSimulation simulation) + { + if (simulation.State != ExamSimulationStates.BetweenBlocks) + throw new InvalidOperationException("The simulation is not between blocks."); + if (simulation.CurrentBlock + 1 >= BlockSizes.Length) + throw new InvalidOperationException("All blocks are complete."); + simulation.CurrentBlock++; + simulation.State = ExamSimulationStates.BlockActive; + simulation.BlockDeadlineUtc = DateTime.UtcNow.AddMinutes(30); + simulation.UpdatedUtc = DateTime.UtcNow; + await ReplaceAsync(simulation); + return simulation; + } + + public async Task SaveResponseAsync(ExamSimulation simulation, SaveSimulationResponseRequest request) + { + if (simulation.State != ExamSimulationStates.BlockActive || simulation.BlockDeadlineUtc <= DateTime.UtcNow) + throw new InvalidOperationException("The active block has ended."); + var item = simulation.Items.FirstOrDefault(candidate => candidate.BlockIndex == simulation.CurrentBlock && candidate.QuestionExternalId == request.QuestionExternalId) + ?? throw new ArgumentException("Question is not in the active block."); + if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "") + throw new ArgumentException("Choice is not presented for this question."); + var response = simulation.Responses.FirstOrDefault(candidate => candidate.QuestionExternalId == request.QuestionExternalId); + if (response is null) + { + response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId }; + simulation.Responses.Add(response); + } + response.ChoiceId = request.ChoiceId; + response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList(); + response.Flagged = request.Flagged; + response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800); + response.UpdatedUtc = DateTime.UtcNow; + simulation.UpdatedUtc = DateTime.UtcNow; + await ReplaceAsync(simulation); + return simulation; + } + + public async Task SubmitBlockAsync(ExamSimulation simulation, bool expired = false) + { + if (simulation.State != ExamSimulationStates.BlockActive) + throw new InvalidOperationException("There is no active block to submit."); + if (!expired && simulation.BlockDeadlineUtc is not null) + simulation.BreakSecondsRemaining += Math.Max(0, (int)(simulation.BlockDeadlineUtc.Value - DateTime.UtcNow).TotalSeconds); + simulation.BlockDeadlineUtc = null; + simulation.UpdatedUtc = DateTime.UtcNow; + if (simulation.CurrentBlock + 1 < BlockSizes.Length) + { + simulation.State = ExamSimulationStates.BetweenBlocks; + await ReplaceAsync(simulation); + return simulation; + } + await CompleteAsync(simulation); + return simulation; + } + + public async Task AbandonAsync(ExamSimulation simulation) + { + simulation.State = ExamSimulationStates.Abandoned; + simulation.BlockDeadlineUtc = null; + simulation.UpdatedUtc = DateTime.UtcNow; + await ReplaceAsync(simulation); + } + + public async Task ToResponseAsync(ExamSimulation simulation) + { + var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId)); + var response = new ExamSimulationStateResponse + { + Id = simulation.Id ?? "", + State = simulation.State, + CurrentBlock = simulation.CurrentBlock, + BreakSecondsRemaining = simulation.BreakSecondsRemaining, + BlockDeadlineUtc = simulation.BlockDeadlineUtc, + Responses = simulation.Responses, + Score = simulation.Score, + BlueprintTargets = simulation.BlueprintTargets, + BlueprintActual = simulation.BlueprintActual + }; + if (simulation.State == ExamSimulationStates.BlockActive) + { + response.Questions = simulation.Items.Where(item => item.BlockIndex == simulation.CurrentBlock) + .OrderBy(item => item.Position) + .Where(item => questionMap.ContainsKey(item.QuestionExternalId)) + .Select(item => SafeQuestion(questionMap[item.QuestionExternalId], item.PresentedChoiceIds)).ToList(); + } + if (simulation.State == ExamSimulationStates.Completed) + { + response.ReviewQuestions = simulation.Items.OrderBy(item => item.BlockIndex).ThenBy(item => item.Position) + .Where(item => questionMap.ContainsKey(item.QuestionExternalId)) + .Select(item => QuestionResponse.FromModel(questionMap[item.QuestionExternalId], questionElo: item.QuestionElo, includeChoicePool: true)).ToList(); + } + return response; + } + + public async Task GetOutcomeAsync(string userId) => + await _outcomes.Find(item => item.UserId == userId).FirstOrDefaultAsync(); + + public async Task UpsertOutcomeAsync(string userId, UpsertStep3OutcomeRequest request) + { + if (!request.Consent) throw new ArgumentException("Consent is required."); + if (request.OfficialScore is < 1 or > 300) throw new ArgumentException("Official score must be between 1 and 300."); + if (request.ExamDate.Date > DateTime.UtcNow.Date) throw new ArgumentException("Exam date cannot be in the future."); + var now = DateTime.UtcNow; + return await _outcomes.FindOneAndUpdateAsync( + item => item.UserId == userId, + Builders.Update + .SetOnInsert(item => item.UserId, userId) + .SetOnInsert(item => item.ConsentUtc, now) + .Set(item => item.OfficialScore, request.OfficialScore) + .Set(item => item.ExamDate, request.ExamDate) + .Set(item => item.UpdatedUtc, now), + new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); + } + + public Task DeleteOutcomeAsync(string userId) => _outcomes.DeleteOneAsync(item => item.UserId == userId); + + private async Task CompleteAsync(ExamSimulation simulation) + { + var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId)); + var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal); + simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap); + simulation.State = ExamSimulationStates.Completed; + simulation.CompletedUtc = DateTime.UtcNow; + simulation.UpdatedUtc = DateTime.UtcNow; + await ReplaceAsync(simulation); + foreach (var item in simulation.Items.Where(item => !item.WasSeen)) + { + if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue; + responses.TryGetValue(item.QuestionExternalId, out var response); + var choiceId = response?.ChoiceId ?? ""; + var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds); + if (!record.Inserted) continue; + _metrics.AttemptCreated(); + try { await _statistics.IncrementAsync(record.Attempt); } catch { _metrics.CounterFailure(); } + var owner = Guid.NewGuid().ToString("N"); + var attemptId = record.Attempt.Id ?? ""; + var shouldRate = false; + try + { + shouldRate = await _attempts.TryAcquireRatingLeaseAsync(attemptId, owner, TimeSpan.FromSeconds(30)); + await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate); + if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner); + } + catch + { + _metrics.CounterFailure(); + if (shouldRate) await _attempts.ReleaseRatingLeaseAsync(attemptId, owner); + } + } + } + + private Task ReplaceAsync(ExamSimulation simulation) => + _simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, simulation); + + private static ExamSimulationQuestionResponse SafeQuestion(Question question, List presentedIds) => new() + { + ExternalId = question.ExternalId, + Stem = question.Stem, + LeadIn = question.LeadIn, + Choices = presentedIds.Select(id => question.Choices.First(choice => choice.Id == id)).ToList(), + Discipline = question.Discipline, + System = question.System, + PerformanceCategory = question.PerformanceCategory, + Labs = question.Labs, + Media = question.Media + }; + + private static List SelectChoices(Question question) + { + var distractors = question.Choices.Where(choice => choice.Id != question.CorrectChoiceId).OrderBy(_ => Random.Shared.Next()).Take(4).Select(choice => choice.Id).ToList(); + distractors.Add(question.CorrectChoiceId); + return distractors.OrderBy(_ => Random.Shared.Next()).ToList(); + } + + private static List SelectForm(List bank, HashSet seen, Dictionary targets) + { + var remaining = bank.OrderBy(question => seen.Contains(question.ExternalId) ? 1 : 0).ThenBy(_ => Random.Shared.Next()).ToList(); + var selected = new List(); + foreach (var target in targets) + { + var matches = remaining.Where(question => BlueprintCategory(question) == target.Key).Take(target.Value).ToList(); + selected.AddRange(matches); + foreach (var match in matches) remaining.Remove(match); + } + selected.AddRange(remaining.Take(RequiredQuestions - selected.Count)); + return selected.OrderBy(_ => Random.Shared.Next()).ToList(); + } + + public static Dictionary BuildTargets() + { + var weights = new Dictionary + { + ["Human Development"] = 2, ["Immune/Blood/Multisystem"] = 7, ["Behavioral Health"] = 5, + ["Nervous System"] = 9, ["Skin"] = 5, ["Musculoskeletal"] = 6, ["Cardiovascular"] = 10, + ["Respiratory"] = 9, ["Gastrointestinal"] = 7, ["Renal/Urinary/Male"] = 5, + ["Pregnancy/Female/Breast"] = 8, ["Endocrine"] = 6, ["Biostatistics/Population Health"] = 12, + ["Social Sciences"] = 8 + }; + var total = weights.Values.Sum(); + var exact = weights.ToDictionary(pair => pair.Key, pair => pair.Value / total * RequiredQuestions); + var result = exact.ToDictionary(pair => pair.Key, pair => (int)Math.Floor(pair.Value)); + foreach (var key in exact.OrderByDescending(pair => pair.Value - Math.Floor(pair.Value)).Take(RequiredQuestions - result.Values.Sum()).Select(pair => pair.Key)) result[key]++; + return result; + } + + public static string BlueprintCategory(Question question) + { + var value = $"{question.System} {question.PerformanceCategory} {question.Discipline}".ToLowerInvariant(); + if (value.Contains("biostat") || value.Contains("epidemi") || value.Contains("population")) return "Biostatistics/Population Health"; + if (value.Contains("ethic") || value.Contains("communication") || value.Contains("professional") || value.Contains("patient safety")) return "Social Sciences"; + if (value.Contains("pregnan") || value.Contains("obstetric") || value.Contains("female") || value.Contains("breast") || value.Contains("gyne")) return "Pregnancy/Female/Breast"; + if (value.Contains("renal") || value.Contains("urinary") || value.Contains("male") || value.Contains("genitourinary")) return "Renal/Urinary/Male"; + if (value.Contains("cardio")) return "Cardiovascular"; + if (value.Contains("resp") || value.Contains("pulmonary")) return "Respiratory"; + if (value.Contains("gastro") || value.Contains("hepato")) return "Gastrointestinal"; + if (value.Contains("neuro") || value.Contains("special sense")) return "Nervous System"; + if (value.Contains("musculo") || value.Contains("orthop")) return "Musculoskeletal"; + if (value.Contains("skin") || value.Contains("derm")) return "Skin"; + if (value.Contains("behavior") || value.Contains("psychiatr")) return "Behavioral Health"; + if (value.Contains("endocrine") || value.Contains("diabetes") || value.Contains("metabolism")) return "Endocrine"; + if (value.Contains("immune") || value.Contains("hemat") || value.Contains("blood") || value.Contains("multisystem")) return "Immune/Blood/Multisystem"; + return "Human Development"; + } +} diff --git a/api/QuestionsApi/Services/QuestionAttemptStore.cs b/api/QuestionsApi/Services/QuestionAttemptStore.cs index 4cd99bd..b6e3d74 100644 --- a/api/QuestionsApi/Services/QuestionAttemptStore.cs +++ b/api/QuestionsApi/Services/QuestionAttemptStore.cs @@ -71,6 +71,12 @@ public class QuestionAttemptStore Builders.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc)) .ToListAsync(); + public Task> GetUserQuestionIdsAsync(string userId) => + _col.Distinct( + attempt => attempt.QuestionExternalId, + Builders.Filter.Eq(attempt => attempt.UserId, userId)) + .ToListAsync(); + public IMongoCollection Collection => _col; public async Task TryAcquireRatingLeaseAsync(string attemptId, string owner, TimeSpan duration) diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index b6a4432..d8bbb41 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -64,6 +64,16 @@ public class QuestionStore public async Task> GetAllAsync() => await _col.Find(Builders.Filter.Empty).ToListAsync(); + public async Task> GetPublishedAsync() => + await _col.Find(question => question.IsPublished).ToListAsync(); + + public async Task> GetByExternalIdsAsync(IEnumerable externalIds) + { + var ids = externalIds.Distinct(StringComparer.Ordinal).ToList(); + var questions = await _col.Find(question => ids.Contains(question.ExternalId)).ToListAsync(); + return questions.ToDictionary(question => question.ExternalId, StringComparer.Ordinal); + } + public async Task<(List Items, string? NextCursor, bool HasMore)> ExportPageAsync( string? after, int requestedLimit) diff --git a/project.godot b/project.godot index bab0238..bcb1d4d 100644 --- a/project.godot +++ b/project.godot @@ -24,6 +24,7 @@ QuestionBankService="*res://scripts/autoload/question_bank_service.gd" ProgressionService="*res://scripts/autoload/progression_service.gd" ProfileService="*res://scripts/autoload/profile_service.gd" BlockSessionService="*res://scripts/autoload/block_session_service.gd" +ExamSimulationService="*res://scripts/autoload/exam_simulation_service.gd" [display] diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index f980815..8ccc4bc 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -20,6 +20,9 @@ var core_current_had_wrong_attempt := false var current_question_started_at := 0.0 var current_question_pause_started_at := 0.0 var core_status_message := "" +var simulation_block_index := 0 +var simulation_total_blocks := 12 +var simulation_deadline_unix := 0.0 const CORE_MIN_TARGET_SECONDS := 45.0 const CORE_MAX_TARGET_SECONDS := 150.0 @@ -65,6 +68,45 @@ func start_core_run() -> bool: return true +func start_simulation_block(questions: Array, block_index: int, total_blocks: int, deadline_unix: float) -> void: + system_name = "All Systems" + mode = "Simulation" + session_kind = "simulation" + active_questions = questions.duplicate(true) + for index in range(active_questions.size()): + active_questions[index] = _prepare_question_choices(active_questions[index]) + answers.clear() + answers.resize(active_questions.size()) + current_index = 0 + simulation_block_index = block_index + simulation_total_blocks = total_blocks + simulation_deadline_unix = deadline_unix + seconds_remaining = maxf(0.0, deadline_unix - Time.get_unix_time_from_system()) + block_started = true + current_question_started_at = Time.get_ticks_msec() / 1000.0 + session_changed.emit() + + +func restore_simulation_responses(saved_responses: Array) -> void: + if session_kind != "simulation": + return + for response in saved_responses: + if typeof(response) != TYPE_DICTIONARY: + continue + var question_id := str(response.get("questionExternalId", "")) + for index in range(active_questions.size()): + if str(active_questions[index].get("id", "")) == question_id: + answers[index] = { + "question": active_questions[index].duplicate(true), + "selected_choice_id": str(response.get("choiceId", "")), + "is_correct": false, + "eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(), + "flagged": bool(response.get("flagged", false)), + "elapsed_seconds": int(response.get("elapsedSeconds", 0)) + } + break + + func get_current_question() -> Dictionary: if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size(): return {} @@ -86,6 +128,11 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dict "target_seconds": core_target_seconds if session_kind == "core" else 0.0 } answers[current_index] = answer + if session_kind == "simulation": + answer["is_correct"] = false + answers[current_index] = answer + session_changed.emit() + return answer if session_kind == "core": _apply_core_answer_rewards(answer) session_changed.emit() @@ -184,6 +231,8 @@ func move_to(index: int) -> bool: func get_progress_text() -> String: if session_kind == "core": return str(core_answer_count + 1) + if session_kind == "simulation": + return "Block %d/%d | Question %d/%d" % [simulation_block_index + 1, simulation_total_blocks, current_index + 1, active_questions.size()] return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()] diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd new file mode 100644 index 0000000..9e3866d --- /dev/null +++ b/scripts/autoload/exam_simulation_service.gd @@ -0,0 +1,184 @@ +extends Node + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_API_URL := "https://s3q.ranaze.com/api/exam-simulations" + +signal readiness_changed +signal state_changed +signal request_failed(message: String) +signal outcome_changed + +var api_url := DEFAULT_API_URL +var outcome_api_url := "https://s3q.ranaze.com/api/step3-outcomes" +var request: HTTPRequest +var readiness := {} +var simulation := {} +var outcome := {} +var is_loading := false +var pending_action := "" +var request_queue: Array = [] + + +func _ready() -> void: + api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/exam_simulation_api_url", DEFAULT_API_URL)), + "/api/exam-simulations") + request = HTTPRequest.new() + request.request_completed.connect(_on_request_completed) + add_child(request) + outcome_api_url = EndpointConfig.resolve("https://s3q.ranaze.com/api/step3-outcomes", "/api/step3-outcomes") + + +func load_readiness() -> void: + _send("readiness", HTTPClient.METHOD_GET, "/readiness") + + +func load_active() -> void: + _send("active", HTTPClient.METHOD_GET, "/active") + + +func create_simulation() -> void: + _send("create", HTTPClient.METHOD_POST, "") + + +func start_next_block() -> void: + if simulation.is_empty(): + return + _send("start", HTTPClient.METHOD_POST, "/%s/blocks/start" % str(simulation.get("id", ""))) + + +func save_response(question_id: String, choice_id: String, eliminated: Array, flagged: bool, elapsed_seconds: int) -> void: + if simulation.is_empty(): + return + _send("save", HTTPClient.METHOD_PUT, "/%s/responses" % str(simulation.get("id", "")), { + "questionExternalId": question_id, + "choiceId": choice_id, + "eliminatedChoiceIds": eliminated, + "flagged": flagged, + "elapsedSeconds": elapsed_seconds + }) + + +func submit_block() -> void: + if simulation.is_empty(): + return + _send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", ""))) + + +func abandon() -> void: + if simulation.is_empty(): + return + _send("abandon", HTTPClient.METHOD_POST, "/%s/abandon" % str(simulation.get("id", ""))) + + +func load_outcome() -> void: + _send_absolute("outcome_get", HTTPClient.METHOD_GET, "%s/me" % outcome_api_url.rstrip("/")) + + +func save_outcome(official_score: int, exam_date: String) -> void: + _send_absolute("outcome_save", HTTPClient.METHOD_PUT, "%s/me" % outcome_api_url.rstrip("/"), { + "officialScore": official_score, + "examDate": "%sT00:00:00Z" % exam_date, + "consent": true + }) + + +func delete_outcome() -> void: + _send_absolute("outcome_delete", HTTPClient.METHOD_DELETE, "%s/me" % outcome_api_url.rstrip("/")) + + +func get_block_deadline_unix() -> float: + var value := str(simulation.get("blockDeadlineUtc", "")) + if value == "": + return 0.0 + return Time.get_unix_time_from_datetime_string(value) + + +func get_current_questions() -> Array: + return simulation.get("questions", []) + + +func _send(action: String, method: int, suffix: String, payload: Dictionary = {}) -> void: + _send_absolute(action, method, "%s%s" % [api_url.rstrip("/"), suffix], payload) + + +func _send_absolute(action: String, method: int, url: String, payload: Dictionary = {}) -> void: + if AuthState.access_token == "": + return + if is_loading: + request_queue.append({"action": action, "method": method, "url": url, "payload": payload.duplicate(true)}) + return + is_loading = true + pending_action = action + var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]) + var body := "" + if not payload.is_empty(): + headers.append("Content-Type: application/json") + body = JSON.stringify(payload) + var err := request.request(url, headers, method, body) + if err != OK: + is_loading = false + pending_action = "" + request_failed.emit("The simulation request could not be started.") + + +func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var action := pending_action + pending_action = "" + is_loading = false + call_deferred("_process_request_queue") + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit("Simulation request failed with status %d." % response_code) + return + if response_code == 204: + if action == "active" or action == "abandon": + simulation = {} + state_changed.emit() + elif action == "outcome_delete" or action == "outcome_get": + outcome = {} + outcome_changed.emit() + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + request_failed.emit("The simulation API returned an invalid response.") + return + if action == "readiness": + readiness = parsed + readiness_changed.emit() + return + if action.begins_with("outcome_"): + outcome = parsed + outcome_changed.emit() + return + simulation = _normalize_simulation(parsed) + state_changed.emit() + + +func _process_request_queue() -> void: + if is_loading or request_queue.is_empty(): + return + var queued: Dictionary = request_queue.pop_front() + _send_absolute(str(queued.get("action", "")), int(queued.get("method", HTTPClient.METHOD_GET)), str(queued.get("url", "")), queued.get("payload", {})) + + +func _normalize_simulation(raw: Dictionary) -> Dictionary: + var normalized := raw.duplicate(true) + var normalized_questions: Array = [] + for question in raw.get("questions", []): + if typeof(question) != TYPE_DICTIONARY: + continue + normalized_questions.append({ + "id": str(question.get("externalId", "")), + "stem": str(question.get("stem", "")), + "lead_in": str(question.get("leadIn", "")), + "choices": question.get("choices", []), + "discipline": str(question.get("discipline", "")), + "system": str(question.get("system", "")), + "performance_category": str(question.get("performanceCategory", "")), + "labs": question.get("labs", []), + "media": question.get("media", []), + "correct_choice_id": "", + "explanation": "" + }) + normalized["questions"] = normalized_questions + return normalized diff --git a/scripts/autoload/exam_simulation_service.gd.uid b/scripts/autoload/exam_simulation_service.gd.uid new file mode 100644 index 0000000..8186689 --- /dev/null +++ b/scripts/autoload/exam_simulation_service.gd.uid @@ -0,0 +1 @@ +uid://hyeimg6wp3p7 diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 633b7dc..64bb21f 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -155,6 +155,18 @@ func _build_ui() -> void: screens_container.add_child(review) screens["review"] = review + var simulation_break := preload("res://scripts/ui/simulation_break.gd").new() + simulation_break.name = "SimulationBreak" + simulation_break.main = self + screens_container.add_child(simulation_break) + screens["simulation_break"] = simulation_break + + var simulation_results := preload("res://scripts/ui/simulation_results.gd").new() + simulation_results.name = "SimulationResults" + simulation_results.main = self + screens_container.add_child(simulation_results) + screens["simulation_results"] = simulation_results + var profile := preload("res://scripts/ui/progress_profile.gd").new() profile.name = "ProgressProfile" profile.main = self @@ -206,8 +218,10 @@ func _refresh_theme_button() -> void: func _logout() -> void: _close_app_menu() - if BlockSessionService.block_started: + if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation": BlockSessionService.finish_block() + elif BlockSessionService.session_kind == "simulation": + BlockSessionService.block_started = false QuestionBankService.clear_questions() AuthState.clear_session() show_screen("login") @@ -284,6 +298,7 @@ func _toggle_app_menu() -> void: app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5 menu_exit_button.visible = current_screen_name == "player" and BlockSessionService.block_started if menu_exit_button.visible: + menu_exit_button.text = "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" else "Exit Run" Style.style_chip(menu_exit_button, Style.AMBER) BlockSessionService.pause_current_question_timer() @@ -296,15 +311,19 @@ func _close_app_menu() -> void: func _exit_run() -> void: _close_app_menu() - if BlockSessionService.block_started: + if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation": BlockSessionService.finish_block() + elif BlockSessionService.session_kind == "simulation": + BlockSessionService.block_started = false show_screen("selection", "step3") func _open_profile_from_menu() -> void: _close_app_menu() - if BlockSessionService.block_started: + if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation": BlockSessionService.finish_block() + elif BlockSessionService.session_kind == "simulation": + BlockSessionService.block_started = false show_screen("profile") diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 8b97199..ded61b3 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -45,6 +45,7 @@ var pending_correct_advance := false var question_text_scale := 1.0 var presented_choices: Array = [] var awaiting_next_question := false +var simulation_submit_pending := false var progress_label: Label var timer_label: Label @@ -131,6 +132,10 @@ func _ready() -> void: QuestionBankService.questions_changed.connect(_on_random_question_changed) if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed): QuestionBankService.questions_load_failed.connect(_on_random_question_failed) + if not ExamSimulationService.state_changed.is_connected(_on_simulation_state_changed): + ExamSimulationService.state_changed.connect(_on_simulation_state_changed) + if not ExamSimulationService.request_failed.is_connected(_on_simulation_request_failed): + ExamSimulationService.request_failed.connect(_on_simulation_request_failed) var top := HBoxContainer.new() top.add_theme_constant_override("separation", 12) @@ -212,6 +217,8 @@ func _ready() -> void: flag_button.toggled.connect(func(value: bool): flagged = value _refresh_toolbar_state() + if BlockSessionService.session_kind == "simulation": + _save_simulation_current_response() ) question_toolbar.add_child(flag_button) @@ -369,7 +376,7 @@ func _ready() -> void: actions.add_child(skip_button) next_button = Button.new() - next_button.text = "Submit" + next_button.text = "Submit Block" if BlockSessionService.session_kind == "simulation" and BlockSessionService.current_index + 1 == BlockSessionService.active_questions.size() else "Next" if BlockSessionService.session_kind == "simulation" else "Submit" next_button.tooltip_text = "" next_button.custom_minimum_size = Vector2(140, 48) next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -420,14 +427,62 @@ func on_show(_payload: Variant = null) -> void: func on_hide() -> void: + if BlockSessionService.session_kind == "simulation" and BlockSessionService.block_started: + _save_simulation_current_response() awaiting_next_question = false _hide_media_viewer() _hide_report_modal(true) _hide_labs_modal() +func _save_simulation_current_response() -> void: + if BlockSessionService.session_kind != "simulation": + return + var question := BlockSessionService.get_current_question() + if question.is_empty(): + return + current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) + ExamSimulationService.save_response( + str(question.get("id", "")), + selected_choice, + eliminated, + flagged, + int(BlockSessionService.get_current_elapsed_seconds())) + + +func _on_simulation_state_changed() -> void: + if BlockSessionService.session_kind != "simulation": + return + var state := str(ExamSimulationService.simulation.get("state", "")) + if state == "between_blocks": + simulation_submit_pending = false + BlockSessionService.block_started = false + main.show_screen("simulation_break") + elif state == "completed": + simulation_submit_pending = false + BlockSessionService.block_started = false + main.show_screen("simulation_results", ExamSimulationService.simulation) + + +func _on_simulation_request_failed(message: String) -> void: + if BlockSessionService.session_kind != "simulation": + return + simulation_submit_pending = false + next_button.disabled = false + next_button.text = "Retry" + next_button.tooltip_text = message + + func _process(delta: float) -> void: _update_answer_feedback(delta) + if visible and BlockSessionService.session_kind == "simulation": + BlockSessionService.seconds_remaining = maxf(0.0, ExamSimulationService.get_block_deadline_unix() - Time.get_unix_time_from_system()) + var simulation_seconds := int(ceil(BlockSessionService.seconds_remaining)) + timer_label.text = "Block time: %02d:%02d" % [int(simulation_seconds / 60), simulation_seconds % 60] + if simulation_seconds <= 0 and not simulation_submit_pending: + simulation_submit_pending = true + ExamSimulationService.submit_block() + return if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core": _update_timer_bar() if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible): @@ -499,13 +554,14 @@ func _load_question() -> void: next_button.disabled = true skip_button.disabled = false skip_button.visible = BlockSessionService.session_kind == "core" + report_button.visible = BlockSessionService.session_kind != "simulation" var question := BlockSessionService.get_current_question() if question.is_empty(): main.show_screen("selection", "step3") return progress_label.visible = BlockSessionService.session_kind != "core" - progress_label.text = "" if BlockSessionService.session_kind == "core" else "Question %s" % BlockSessionService.get_progress_text() + progress_label.text = "" if BlockSessionService.session_kind == "core" else BlockSessionService.get_progress_text() timer_label.visible = BlockSessionService.session_kind != "core" timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed" timer_bar.visible = BlockSessionService.session_kind == "core" @@ -538,8 +594,11 @@ func _load_question() -> void: flagged = bool(saved_answer.get("flagged", false)) flag_button.button_pressed = flagged _refresh_toolbar_state() - _show_answer_review() - _update_question_elo_label(true) + if BlockSessionService.session_kind == "simulation": + _apply_saved_choice_state() + else: + _show_answer_review() + _update_question_elo_label(true) _refresh_nav_palette() _refresh_toolbar_state() @@ -556,9 +615,12 @@ func _select_choice(index: int) -> void: return var choice_id := str(choices[index].get("id", "")) selected_choice = "" if selected_choice == choice_id else choice_id - if BlockSessionService.mode == "Timed": + if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation": current_answer = {} - next_button.text = "Submit" + if BlockSessionService.mode == "Simulation": + _save_simulation_current_response() + else: + next_button.text = "Submit" _apply_saved_choice_state() @@ -566,9 +628,12 @@ func _clear_selected_choice() -> void: if selected_choice == "" or _is_correct_answer_locked(): return selected_choice = "" - if BlockSessionService.mode == "Timed": + if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation": current_answer = {} - next_button.text = "Submit" + if BlockSessionService.mode == "Simulation": + _save_simulation_current_response() + else: + next_button.text = "Submit" _apply_saved_choice_state() @@ -635,7 +700,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void: var mouse_event := event as InputEventMouseButton if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed: return - if not current_answer.is_empty(): + if not current_answer.is_empty() and BlockSessionService.session_kind != "simulation": return var choices := _get_presented_choices() @@ -653,10 +718,12 @@ func _choice_gui_input(event: InputEvent, index: int) -> void: UISoundService.play_sound("eliminate") _set_choice_button_text(index, choice) _apply_saved_choice_state() + if BlockSessionService.session_kind == "simulation": + _save_simulation_current_response() func _toggle_eliminate_selected() -> void: - if pending_correct_advance or followup_required or selected_choice == "" or not current_answer.is_empty() or _is_correct_answer_locked(): + if pending_correct_advance or followup_required or selected_choice == "" or (not current_answer.is_empty() and BlockSessionService.session_kind != "simulation") or _is_correct_answer_locked(): return var choices := _get_presented_choices() for index in range(choices.size()): @@ -668,9 +735,23 @@ func _toggle_eliminate_selected() -> void: UISoundService.play_sound("eliminate") _set_choice_button_text(index, choices[index]) _apply_saved_choice_state() + if BlockSessionService.session_kind == "simulation": + _save_simulation_current_response() func _submit_or_next() -> void: + if BlockSessionService.session_kind == "simulation": + _save_simulation_current_response() + if BlockSessionService.current_index + 1 < BlockSessionService.active_questions.size(): + BlockSessionService.move_next() + _load_question() + return + elif not simulation_submit_pending: + simulation_submit_pending = true + next_button.disabled = true + next_button.text = "Submitting..." + ExamSimulationService.submit_block() + return if awaiting_next_question: return if pending_correct_advance: @@ -1917,6 +1998,9 @@ func _is_correct_answer_locked() -> bool: func _refresh_submit_state() -> void: if next_button == null: return + if BlockSessionService.session_kind == "simulation": + next_button.disabled = false + return if pending_correct_advance or followup_required: next_button.disabled = true return @@ -2489,12 +2573,14 @@ func _refresh_nav_palette() -> void: Style.style_chip(button, Style.CYAN) elif answer != null and bool(answer.get("flagged", false)): Style.style_chip(button, Style.AMBER) - elif answer != null: + elif answer != null and str(answer.get("selected_choice_id", "")) != "": Style.style_chip(button, Style.LIME) else: Style.style_chip(button, Color("596982")) button.pressed.connect(func(target_index: int = index): - if BlockSessionService.mode == "Timed": + if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation": + if BlockSessionService.mode == "Simulation": + _save_simulation_current_response() BlockSessionService.move_to(target_index) _load_question() ) diff --git a/scripts/ui/simulation_break.gd b/scripts/ui/simulation_break.gd new file mode 100644 index 0000000..fc3a43f --- /dev/null +++ b/scripts/ui/simulation_break.gd @@ -0,0 +1,125 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var title: Label +var detail: Label +var start_button: Button +var abandon_button: Button +var error_label: Label +var abandon_armed := false + + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 18) + if not ExamSimulationService.state_changed.is_connected(_on_state_changed): + ExamSimulationService.state_changed.connect(_on_state_changed) + if not ExamSimulationService.request_failed.is_connected(_on_failed): + ExamSimulationService.request_failed.connect(_on_failed) + + var eyebrow := Label.new() + eyebrow.text = "STEP 3 DAY 1 SIMULATION" + Style.set_label(eyebrow, 14, Style.CYAN) + add_child(eyebrow) + title = Label.new() + Style.set_label(title, 34, Style.TEXT) + add_child(title) + detail = Label.new() + detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(detail, 18, Style.MUTED) + add_child(detail) + + start_button = Button.new() + start_button.custom_minimum_size = Vector2(240, 54) + start_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_primary_button(start_button) + start_button.pressed.connect(_start_block) + add_child(start_button) + + abandon_button = Button.new() + abandon_button.text = "Abandon simulation" + abandon_button.custom_minimum_size = Vector2(240, 44) + abandon_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_chip(abandon_button, Style.RED) + abandon_button.pressed.connect(_abandon) + add_child(abandon_button) + + error_label = Label.new() + error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(error_label, 16, Style.RED) + add_child(error_label) + + var note := Label.new() + note.text = "Each block runs for 30 minutes. You may review and change answers within the active block, but closed blocks cannot be reopened. Leaving during a block does not stop its server timer." + note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(note, 16, Style.MUTED) + add_child(note) + + var spacer := Control.new() + spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(spacer) + + +func on_show(_payload: Variant = null) -> void: + abandon_armed = false + _refresh() + + +func _refresh() -> void: + var block := int(ExamSimulationService.simulation.get("currentBlock", -1)) + var break_seconds := int(ExamSimulationService.simulation.get("breakSecondsRemaining", 3300)) + if block < 0: + title.text = "Foundations of Independent Practice" + detail.text = "232 questions across 12 timed blocks. Results and explanations remain hidden until the simulation is complete. Estimated break pool: %d minutes." % int(break_seconds / 60) + else: + title.text = "Block %d complete" % (block + 1) + detail.text = "%d of 12 blocks complete. Your progress is saved. Break pool: approximately %d minutes." % [block + 1, int(break_seconds / 60)] + start_button.text = "Start Block %d" % (block + 2) + start_button.disabled = ExamSimulationService.is_loading + abandon_button.text = "Confirm abandon" if abandon_armed else "Abandon simulation" + abandon_button.disabled = ExamSimulationService.is_loading + error_label.text = "" + + +func _start_block() -> void: + start_button.disabled = true + start_button.text = "Preparing..." + ExamSimulationService.start_next_block() + + +func _abandon() -> void: + if not abandon_armed: + abandon_armed = true + abandon_button.text = "Confirm abandon" + error_label.text = "This permanently closes the current simulation. Select Confirm abandon to continue." + return + abandon_button.disabled = true + ExamSimulationService.abandon() + + +func _on_state_changed() -> void: + if not visible: + return + if ExamSimulationService.simulation.is_empty(): + main.show_screen("selection", "step3") + return + if str(ExamSimulationService.simulation.get("state", "")) != "block_active": + _refresh() + return + BlockSessionService.start_simulation_block( + ExamSimulationService.get_current_questions(), + int(ExamSimulationService.simulation.get("currentBlock", 0)), + int(ExamSimulationService.simulation.get("totalBlocks", 12)), + ExamSimulationService.get_block_deadline_unix()) + BlockSessionService.restore_simulation_responses(ExamSimulationService.simulation.get("responses", [])) + main.show_screen("player") + + +func _on_failed(message: String) -> void: + if not visible: + return + error_label.text = message + start_button.disabled = false + _refresh() diff --git a/scripts/ui/simulation_break.gd.uid b/scripts/ui/simulation_break.gd.uid new file mode 100644 index 0000000..a453c8f --- /dev/null +++ b/scripts/ui/simulation_break.gd.uid @@ -0,0 +1 @@ +uid://dnyyo3s2jkmkr diff --git a/scripts/ui/simulation_results.gd b/scripts/ui/simulation_results.gd new file mode 100644 index 0000000..401bdeb --- /dev/null +++ b/scripts/ui/simulation_results.gd @@ -0,0 +1,169 @@ +extends VBoxContainer + +const Style := preload("res://scripts/ui/ui_style.gd") + +var main: Control +var score_label: Label +var summary_label: Label +var category_list: VBoxContainer +var review_list: VBoxContainer +var outcome_score: SpinBox +var outcome_date: LineEdit +var outcome_consent: CheckBox +var outcome_status: Label +var outcome_delete_button: Button + + +func _ready() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 12) + var eyebrow := Label.new() + eyebrow.text = "STEP 3 DAY 1 SIMULATION COMPLETE" + Style.set_label(eyebrow, 14, Style.CYAN) + add_child(eyebrow) + score_label = Label.new() + Style.set_label(score_label, 36, Style.LIME) + add_child(score_label) + summary_label = Label.new() + summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(summary_label, 17, Style.TEXT) + add_child(summary_label) + + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + add_child(scroll) + var content := VBoxContainer.new() + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content.add_theme_constant_override("separation", 10) + scroll.add_child(content) + var category_heading := Label.new() + category_heading.text = "Performance by content area" + Style.set_label(category_heading, 22, Style.TEXT) + content.add_child(category_heading) + category_list = VBoxContainer.new() + content.add_child(category_list) + var review_heading := Label.new() + review_heading.text = "Answer review" + Style.set_label(review_heading, 22, Style.TEXT) + content.add_child(review_heading) + review_list = VBoxContainer.new() + review_list.add_theme_constant_override("separation", 8) + content.add_child(review_list) + + var outcome_heading := Label.new() + outcome_heading.text = "Help calibrate future predictions" + Style.set_label(outcome_heading, 22, Style.TEXT) + content.add_child(outcome_heading) + var outcome_note := Label.new() + outcome_note.text = "After receiving your official result, you may voluntarily submit it. You can edit or delete it at any time; calibration uses de-identified aggregates." + outcome_note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(outcome_note, 15, Style.MUTED) + content.add_child(outcome_note) + var outcome_row := HBoxContainer.new() + outcome_row.add_theme_constant_override("separation", 10) + content.add_child(outcome_row) + outcome_score = SpinBox.new() + outcome_score.min_value = 1 + outcome_score.max_value = 300 + outcome_score.value = 200 + outcome_score.custom_minimum_size = Vector2(120, 44) + outcome_row.add_child(outcome_score) + outcome_date = LineEdit.new() + outcome_date.placeholder_text = "Exam date: YYYY-MM-DD" + outcome_date.custom_minimum_size = Vector2(220, 44) + outcome_row.add_child(outcome_date) + outcome_consent = CheckBox.new() + outcome_consent.text = "I consent to calibration use" + outcome_row.add_child(outcome_consent) + var save_outcome := Button.new() + save_outcome.text = "Save outcome" + Style.style_chip(save_outcome, Style.CYAN) + save_outcome.pressed.connect(_save_outcome) + outcome_row.add_child(save_outcome) + outcome_delete_button = Button.new() + outcome_delete_button.text = "Delete outcome" + Style.style_chip(outcome_delete_button, Style.RED) + outcome_delete_button.pressed.connect(ExamSimulationService.delete_outcome) + outcome_row.add_child(outcome_delete_button) + outcome_status = Label.new() + Style.set_label(outcome_status, 15, Style.MUTED) + content.add_child(outcome_status) + if not ExamSimulationService.outcome_changed.is_connected(_refresh_outcome): + ExamSimulationService.outcome_changed.connect(_refresh_outcome) + + var done := Button.new() + done.text = "Return to study modes" + done.custom_minimum_size = Vector2(240, 50) + done.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + Style.style_primary_button(done) + done.pressed.connect(func(): main.show_screen("selection", "step3")) + add_child(done) + + +func on_show(payload: Variant = null) -> void: + var simulation: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else ExamSimulationService.simulation + var score: Dictionary = simulation.get("score", {}) + var predicted := int(score.get("predictedScore", 0)) + var low := int(score.get("rangeLow", 0)) + var high := int(score.get("rangeHigh", 0)) + var probability := int(round(float(score.get("passProbability", 0.0)) * 100.0)) + score_label.text = "Predicted Step 3 score: %d (%d-%d)" % [predicted, low, high] + summary_label.text = "%d/%d correct | Estimated probability of reaching 200: %d%% | Previously seen questions: %d\nThis provisional educational estimate is not an official USMLE or NBME score and excludes CCS performance." % [ + int(score.get("correct", 0)), int(score.get("total", 0)), probability, int(score.get("seenCount", 0))] + _clear(category_list) + for category in score.get("categories", {}): + var value: Dictionary = score["categories"][category] + var label := Label.new() + label.text = "%s: %d/%d" % [category, int(value.get("correct", 0)), int(value.get("total", 0))] + Style.set_label(label, 16, Style.TEXT) + category_list.add_child(label) + _build_review(simulation) + ExamSimulationService.load_outcome() + + +func _build_review(simulation: Dictionary) -> void: + _clear(review_list) + var responses := {} + for response in simulation.get("responses", []): + responses[str(response.get("questionExternalId", ""))] = response + for raw_question in simulation.get("reviewQuestions", []): + if typeof(raw_question) != TYPE_DICTIONARY: + continue + var question: Dictionary = raw_question + var id := str(question.get("externalId", "")) + var selected := str(responses.get(id, {}).get("choiceId", "")) + var correct := str(question.get("correctChoiceId", "")) + var item := RichTextLabel.new() + item.bbcode_enabled = true + item.fit_content = true + item.custom_minimum_size = Vector2(0, 84) + item.text = "[b]%s[/b] %s\nSelected: %s | Correct: %s\n%s" % ["CORRECT" if selected == correct else "MISS", str(question.get("leadIn", "")), selected if selected != "" else "Unanswered", correct, str(question.get("explanation", ""))] + review_list.add_child(item) + + +func _clear(container: Control) -> void: + for child in container.get_children(): + child.queue_free() + + +func _save_outcome() -> void: + if not outcome_consent.button_pressed: + outcome_status.text = "Consent is required before saving an outcome." + return + if outcome_date.text.length() != 10: + outcome_status.text = "Enter the exam date as YYYY-MM-DD." + return + ExamSimulationService.save_outcome(int(outcome_score.value), outcome_date.text) + outcome_status.text = "Saving..." + + +func _refresh_outcome() -> void: + if ExamSimulationService.outcome.is_empty(): + outcome_status.text = "No official outcome is stored." + outcome_delete_button.visible = false + return + outcome_score.value = int(ExamSimulationService.outcome.get("officialScore", 200)) + outcome_date.text = str(ExamSimulationService.outcome.get("examDate", "")).left(10) + outcome_consent.button_pressed = true + outcome_delete_button.visible = true + outcome_status.text = "Official outcome saved." diff --git a/scripts/ui/simulation_results.gd.uid b/scripts/ui/simulation_results.gd.uid new file mode 100644 index 0000000..8246fd1 --- /dev/null +++ b/scripts/ui/simulation_results.gd.uid @@ -0,0 +1 @@ +uid://h7bg77wtfl3t diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd index bd6c4b4..7a0c611 100644 --- a/scripts/ui/study_selector.gd +++ b/scripts/ui/study_selector.gd @@ -11,7 +11,9 @@ var question_bank_button: Button var bank_status_label: Label var bank_error_label: Label var retry_button: Button -var image_question_test_link: LinkButton +var simulation_button: Button +var simulation_status_label: Label +var pending_simulation_launch := false var current_stage := "exam" var pending_question_bank_launch := false @@ -24,6 +26,12 @@ func _ready() -> void: QuestionBankService.questions_changed.connect(_on_questions_changed) if not QuestionBankService.questions_load_failed.is_connected(_on_questions_load_failed): QuestionBankService.questions_load_failed.connect(_on_questions_load_failed) + if not ExamSimulationService.readiness_changed.is_connected(_refresh_simulation_state): + ExamSimulationService.readiness_changed.connect(_refresh_simulation_state) + if not ExamSimulationService.state_changed.is_connected(_on_simulation_state_changed): + ExamSimulationService.state_changed.connect(_on_simulation_state_changed) + if not ExamSimulationService.request_failed.is_connected(_on_simulation_failed): + ExamSimulationService.request_failed.connect(_on_simulation_failed) _build_exam_view() _build_mode_view() @@ -36,6 +44,7 @@ func on_show(payload: Variant = null) -> void: elif str(payload) == "exam": _show_stage("exam") _refresh_bank_state() + ExamSimulationService.load_readiness() func on_hide() -> void: @@ -135,6 +144,22 @@ func _build_mode_view() -> void: dynamic_button.disabled = true mode_grid.add_child(dynamic_button) + simulation_button = Button.new() + simulation_button.text = "Step 3 Day 1 Simulation\n232 questions | Checking readiness" + simulation_button.alignment = HORIZONTAL_ALIGNMENT_LEFT + simulation_button.custom_minimum_size = Vector2(360, 150) + simulation_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + simulation_button.add_theme_font_size_override("font_size", 20) + Style.style_primary_button(simulation_button) + simulation_button.disabled = true + simulation_button.pressed.connect(_select_simulation) + mode_grid.add_child(simulation_button) + + simulation_status_label = Label.new() + simulation_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(simulation_status_label, 15, Style.MUTED) + mode_view.add_child(simulation_status_label) + bank_status_label = Label.new() bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART Style.set_label(bank_status_label, 16, Style.CYAN) @@ -153,15 +178,6 @@ func _build_mode_view() -> void: retry_button.pressed.connect(_retry_question_bank) mode_view.add_child(retry_button) - image_question_test_link = LinkButton.new() - image_question_test_link.text = "Temporary: test a question with an image" - image_question_test_link.tooltip_text = "Load a random published question that includes media" - image_question_test_link.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - image_question_test_link.add_theme_color_override("font_color", Style.CYAN) - image_question_test_link.add_theme_color_override("font_hover_color", Style.MAGENTA) - image_question_test_link.pressed.connect(_select_image_question_for_testing) - mode_view.add_child(image_question_test_link) - var spacer := Control.new() spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL mode_view.add_child(spacer) @@ -206,12 +222,6 @@ func _retry_question_bank() -> void: _refresh_bank_state() -func _select_image_question_for_testing() -> void: - pending_question_bank_launch = true - QuestionBankService.load_image_question_for_testing() - _refresh_bank_state() - - func _on_questions_changed() -> void: _refresh_bank_state() if pending_question_bank_launch and visible and current_stage == "step3" and QuestionBankService.load_state == "ready": @@ -224,12 +234,63 @@ func _on_questions_load_failed(_message: String) -> void: _refresh_bank_state() +func _select_simulation() -> void: + pending_simulation_launch = true + simulation_button.disabled = true + if bool(ExamSimulationService.readiness.get("hasActiveSimulation", false)): + ExamSimulationService.load_active() + else: + ExamSimulationService.create_simulation() + + +func _on_simulation_state_changed() -> void: + _refresh_simulation_state() + if not pending_simulation_launch or ExamSimulationService.simulation.is_empty(): + return + pending_simulation_launch = false + var state := str(ExamSimulationService.simulation.get("state", "")) + if state == "block_active": + _launch_simulation_block() + elif state == "completed": + main.show_screen("simulation_results", ExamSimulationService.simulation) + else: + main.show_screen("simulation_break") + + +func _launch_simulation_block() -> void: + BlockSessionService.start_simulation_block( + ExamSimulationService.get_current_questions(), + int(ExamSimulationService.simulation.get("currentBlock", 0)), + int(ExamSimulationService.simulation.get("totalBlocks", 12)), + ExamSimulationService.get_block_deadline_unix()) + BlockSessionService.restore_simulation_responses(ExamSimulationService.simulation.get("responses", [])) + main.show_screen("player") + + +func _on_simulation_failed(message: String) -> void: + pending_simulation_launch = false + if simulation_status_label != null: + simulation_status_label.text = message + _refresh_simulation_state() + + +func _refresh_simulation_state() -> void: + if simulation_button == null: + return + var ready := bool(ExamSimulationService.readiness.get("ready", false)) + var active := bool(ExamSimulationService.readiness.get("hasActiveSimulation", false)) or not ExamSimulationService.simulation.is_empty() and str(ExamSimulationService.simulation.get("state", "")) in ["between_blocks", "block_active"] + var available := int(ExamSimulationService.readiness.get("availableQuestions", 0)) + simulation_button.disabled = pending_simulation_launch or (not ready and not active) + simulation_button.text = "Resume Step 3 Day 1 Simulation\nProgress is saved between blocks" if active else "Step 3 Day 1 Simulation\n232 questions | %s" % ("Available" if ready else "%d/232 ready" % available) + if simulation_status_label != null and not pending_simulation_launch: + simulation_status_label.text = "Full-length, 12-block simulation. Predicted scores are provisional and exclude CCS." if ready or active else "The simulation unlocks when at least 232 published questions are available." + + func _refresh_bank_state() -> void: if question_bank_button == null: return var state := QuestionBankService.load_state question_bank_button.disabled = pending_question_bank_launch - image_question_test_link.disabled = pending_question_bank_launch retry_button.visible = state == "failed" or state == "retrying" retry_button.disabled = state == "retrying" bank_error_label.text = QuestionBankService.last_error if state == "failed" or state == "retrying" else ""