Add full-length Step 3 Day 1 simulation
This commit is contained in:
@@ -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<ExamSimulationItem> 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<string, Question> BuildQuestions(int count) =>
|
||||||
|
Enumerable.Range(0, count).ToDictionary(
|
||||||
|
index => $"question-{index}",
|
||||||
|
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A" });
|
||||||
|
}
|
||||||
@@ -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<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> Active()
|
||||||
|
{
|
||||||
|
var simulation = await simulations.FindActiveAsync(UserId());
|
||||||
|
return simulation is null ? NoContent() : Ok(await simulations.ToResponseAsync(simulation));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Get()
|
||||||
|
{
|
||||||
|
var outcome = await simulations.GetOutcomeAsync(UserId());
|
||||||
|
return outcome is null ? NoContent() : Ok(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("me")]
|
||||||
|
public async Task<IActionResult> Put(UpsertStep3OutcomeRequest request)
|
||||||
|
{
|
||||||
|
try { return Ok(await simulations.UpsertOutcomeAsync(UserId(), request)); }
|
||||||
|
catch (ArgumentException exception) { return BadRequest(exception.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("me")]
|
||||||
|
public async Task<IActionResult> Delete()
|
||||||
|
{
|
||||||
|
await simulations.DeleteOutcomeAsync(UserId());
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? User.FindFirstValue(ClaimTypes.Name)
|
||||||
|
?? User.Identity?.Name
|
||||||
|
?? throw new UnauthorizedAccessException();
|
||||||
|
}
|
||||||
@@ -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<ExamSimulationItem> Items { get; set; } = [];
|
||||||
|
public List<ExamSimulationResponse> 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<string, int> BlueprintTargets { get; set; } = [];
|
||||||
|
public Dictionary<string, int> BlueprintActual { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExamSimulationItem
|
||||||
|
{
|
||||||
|
public string QuestionExternalId { get; set; } = "";
|
||||||
|
public int BlockIndex { get; set; }
|
||||||
|
public int Position { get; set; }
|
||||||
|
public List<string> 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<string> 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<string, ExamSimulationCategoryScore> 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<string> 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<QuestionChoice> Choices { get; set; } = [];
|
||||||
|
public string Discipline { get; set; } = "";
|
||||||
|
public string System { get; set; } = "";
|
||||||
|
public string PerformanceCategory { get; set; } = "";
|
||||||
|
public List<QuestionLab> Labs { get; set; } = [];
|
||||||
|
public List<string> 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<ExamSimulationQuestionResponse> Questions { get; set; } = [];
|
||||||
|
public List<ExamSimulationResponse> Responses { get; set; } = [];
|
||||||
|
public ExamSimulationScore? Score { get; set; }
|
||||||
|
public Dictionary<string, int> BlueprintTargets { get; set; } = [];
|
||||||
|
public Dictionary<string, int> BlueprintActual { get; set; } = [];
|
||||||
|
public List<QuestionResponse> 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; }
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ builder.Services.AddSingleton<AnswerStatisticsStore>();
|
|||||||
builder.Services.AddSingleton<RatingStore>();
|
builder.Services.AddSingleton<RatingStore>();
|
||||||
builder.Services.AddSingleton<QuestionMediaStore>();
|
builder.Services.AddSingleton<QuestionMediaStore>();
|
||||||
builder.Services.AddSingleton<QuestionReportStore>();
|
builder.Services.AddSingleton<QuestionReportStore>();
|
||||||
|
builder.Services.AddSingleton<ExamSimulationStore>();
|
||||||
builder.Services.AddSingleton<ServiceMetrics>();
|
builder.Services.AddSingleton<ServiceMetrics>();
|
||||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||||
|
|
||||||
|
|||||||
@@ -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<ExamSimulationItem> items,
|
||||||
|
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
|
||||||
|
IReadOnlyDictionary<string, Question> 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<string, ExamSimulationCategoryScore>();
|
||||||
|
|
||||||
|
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<ExamSimulationItem> items,
|
||||||
|
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
|
||||||
|
IReadOnlyDictionary<string, Question> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ExamSimulation> _simulations;
|
||||||
|
private readonly IMongoCollection<Step3Outcome> _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<ExamSimulation>("ExamSimulations");
|
||||||
|
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
|
||||||
|
_questions = questions;
|
||||||
|
_attempts = attempts;
|
||||||
|
_statistics = statistics;
|
||||||
|
_ratings = ratings;
|
||||||
|
_metrics = metrics;
|
||||||
|
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||||
|
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State)));
|
||||||
|
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||||
|
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UpdatedUtc)));
|
||||||
|
_outcomes.Indexes.CreateOne(new CreateIndexModel<Step3Outcome>(
|
||||||
|
Builders<Step3Outcome>.IndexKeys.Ascending(item => item.UserId),
|
||||||
|
new CreateIndexOptions { Unique = true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExamSimulationReadinessResponse> 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<ExamSimulation> 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<ExamSimulationItem>();
|
||||||
|
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<ExamSimulation?> 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<ExamSimulation?> 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<ExamSimulation> 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<ExamSimulation> 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<ExamSimulation> 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<ExamSimulationStateResponse> 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<Step3Outcome?> GetOutcomeAsync(string userId) =>
|
||||||
|
await _outcomes.Find(item => item.UserId == userId).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<Step3Outcome> 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<Step3Outcome>.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<Step3Outcome> { 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<string> 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<string> 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<Question> SelectForm(List<Question> bank, HashSet<string> seen, Dictionary<string, int> targets)
|
||||||
|
{
|
||||||
|
var remaining = bank.OrderBy(question => seen.Contains(question.ExternalId) ? 1 : 0).ThenBy(_ => Random.Shared.Next()).ToList();
|
||||||
|
var selected = new List<Question>();
|
||||||
|
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<string, int> BuildTargets()
|
||||||
|
{
|
||||||
|
var weights = new Dictionary<string, double>
|
||||||
|
{
|
||||||
|
["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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,12 @@ public class QuestionAttemptStore
|
|||||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
|
||||||
|
_col.Distinct(
|
||||||
|
attempt => attempt.QuestionExternalId,
|
||||||
|
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||||
|
|
||||||
public async Task<bool> TryAcquireRatingLeaseAsync(string attemptId, string owner, TimeSpan duration)
|
public async Task<bool> TryAcquireRatingLeaseAsync(string attemptId, string owner, TimeSpan duration)
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ public class QuestionStore
|
|||||||
public async Task<List<Question>> GetAllAsync() =>
|
public async Task<List<Question>> GetAllAsync() =>
|
||||||
await _col.Find(Builders<Question>.Filter.Empty).ToListAsync();
|
await _col.Find(Builders<Question>.Filter.Empty).ToListAsync();
|
||||||
|
|
||||||
|
public async Task<List<Question>> GetPublishedAsync() =>
|
||||||
|
await _col.Find(question => question.IsPublished).ToListAsync();
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, Question>> GetByExternalIdsAsync(IEnumerable<string> 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<Question> Items, string? NextCursor, bool HasMore)> ExportPageAsync(
|
public async Task<(List<Question> Items, string? NextCursor, bool HasMore)> ExportPageAsync(
|
||||||
string? after,
|
string? after,
|
||||||
int requestedLimit)
|
int requestedLimit)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ QuestionBankService="*res://scripts/autoload/question_bank_service.gd"
|
|||||||
ProgressionService="*res://scripts/autoload/progression_service.gd"
|
ProgressionService="*res://scripts/autoload/progression_service.gd"
|
||||||
ProfileService="*res://scripts/autoload/profile_service.gd"
|
ProfileService="*res://scripts/autoload/profile_service.gd"
|
||||||
BlockSessionService="*res://scripts/autoload/block_session_service.gd"
|
BlockSessionService="*res://scripts/autoload/block_session_service.gd"
|
||||||
|
ExamSimulationService="*res://scripts/autoload/exam_simulation_service.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ var core_current_had_wrong_attempt := false
|
|||||||
var current_question_started_at := 0.0
|
var current_question_started_at := 0.0
|
||||||
var current_question_pause_started_at := 0.0
|
var current_question_pause_started_at := 0.0
|
||||||
var core_status_message := ""
|
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_MIN_TARGET_SECONDS := 45.0
|
||||||
const CORE_MAX_TARGET_SECONDS := 150.0
|
const CORE_MAX_TARGET_SECONDS := 150.0
|
||||||
@@ -65,6 +68,45 @@ func start_core_run() -> bool:
|
|||||||
return true
|
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:
|
func get_current_question() -> Dictionary:
|
||||||
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
|
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
|
||||||
return {}
|
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
|
"target_seconds": core_target_seconds if session_kind == "core" else 0.0
|
||||||
}
|
}
|
||||||
answers[current_index] = answer
|
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":
|
if session_kind == "core":
|
||||||
_apply_core_answer_rewards(answer)
|
_apply_core_answer_rewards(answer)
|
||||||
session_changed.emit()
|
session_changed.emit()
|
||||||
@@ -184,6 +231,8 @@ func move_to(index: int) -> bool:
|
|||||||
func get_progress_text() -> String:
|
func get_progress_text() -> String:
|
||||||
if session_kind == "core":
|
if session_kind == "core":
|
||||||
return str(core_answer_count + 1)
|
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()]
|
return "%d / %d" % [min(current_index + 1, active_questions.size()), active_questions.size()]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://hyeimg6wp3p7
|
||||||
+22
-3
@@ -155,6 +155,18 @@ func _build_ui() -> void:
|
|||||||
screens_container.add_child(review)
|
screens_container.add_child(review)
|
||||||
screens["review"] = 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()
|
var profile := preload("res://scripts/ui/progress_profile.gd").new()
|
||||||
profile.name = "ProgressProfile"
|
profile.name = "ProgressProfile"
|
||||||
profile.main = self
|
profile.main = self
|
||||||
@@ -206,8 +218,10 @@ func _refresh_theme_button() -> void:
|
|||||||
|
|
||||||
func _logout() -> void:
|
func _logout() -> void:
|
||||||
_close_app_menu()
|
_close_app_menu()
|
||||||
if BlockSessionService.block_started:
|
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
|
||||||
BlockSessionService.finish_block()
|
BlockSessionService.finish_block()
|
||||||
|
elif BlockSessionService.session_kind == "simulation":
|
||||||
|
BlockSessionService.block_started = false
|
||||||
QuestionBankService.clear_questions()
|
QuestionBankService.clear_questions()
|
||||||
AuthState.clear_session()
|
AuthState.clear_session()
|
||||||
show_screen("login")
|
show_screen("login")
|
||||||
@@ -284,6 +298,7 @@ func _toggle_app_menu() -> void:
|
|||||||
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5
|
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5
|
||||||
menu_exit_button.visible = current_screen_name == "player" and BlockSessionService.block_started
|
menu_exit_button.visible = current_screen_name == "player" and BlockSessionService.block_started
|
||||||
if menu_exit_button.visible:
|
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)
|
Style.style_chip(menu_exit_button, Style.AMBER)
|
||||||
BlockSessionService.pause_current_question_timer()
|
BlockSessionService.pause_current_question_timer()
|
||||||
|
|
||||||
@@ -296,15 +311,19 @@ func _close_app_menu() -> void:
|
|||||||
|
|
||||||
func _exit_run() -> void:
|
func _exit_run() -> void:
|
||||||
_close_app_menu()
|
_close_app_menu()
|
||||||
if BlockSessionService.block_started:
|
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
|
||||||
BlockSessionService.finish_block()
|
BlockSessionService.finish_block()
|
||||||
|
elif BlockSessionService.session_kind == "simulation":
|
||||||
|
BlockSessionService.block_started = false
|
||||||
show_screen("selection", "step3")
|
show_screen("selection", "step3")
|
||||||
|
|
||||||
|
|
||||||
func _open_profile_from_menu() -> void:
|
func _open_profile_from_menu() -> void:
|
||||||
_close_app_menu()
|
_close_app_menu()
|
||||||
if BlockSessionService.block_started:
|
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
|
||||||
BlockSessionService.finish_block()
|
BlockSessionService.finish_block()
|
||||||
|
elif BlockSessionService.session_kind == "simulation":
|
||||||
|
BlockSessionService.block_started = false
|
||||||
show_screen("profile")
|
show_screen("profile")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ var pending_correct_advance := false
|
|||||||
var question_text_scale := 1.0
|
var question_text_scale := 1.0
|
||||||
var presented_choices: Array = []
|
var presented_choices: Array = []
|
||||||
var awaiting_next_question := false
|
var awaiting_next_question := false
|
||||||
|
var simulation_submit_pending := false
|
||||||
|
|
||||||
var progress_label: Label
|
var progress_label: Label
|
||||||
var timer_label: Label
|
var timer_label: Label
|
||||||
@@ -131,6 +132,10 @@ func _ready() -> void:
|
|||||||
QuestionBankService.questions_changed.connect(_on_random_question_changed)
|
QuestionBankService.questions_changed.connect(_on_random_question_changed)
|
||||||
if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed):
|
if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed):
|
||||||
QuestionBankService.questions_load_failed.connect(_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()
|
var top := HBoxContainer.new()
|
||||||
top.add_theme_constant_override("separation", 12)
|
top.add_theme_constant_override("separation", 12)
|
||||||
@@ -212,6 +217,8 @@ func _ready() -> void:
|
|||||||
flag_button.toggled.connect(func(value: bool):
|
flag_button.toggled.connect(func(value: bool):
|
||||||
flagged = value
|
flagged = value
|
||||||
_refresh_toolbar_state()
|
_refresh_toolbar_state()
|
||||||
|
if BlockSessionService.session_kind == "simulation":
|
||||||
|
_save_simulation_current_response()
|
||||||
)
|
)
|
||||||
question_toolbar.add_child(flag_button)
|
question_toolbar.add_child(flag_button)
|
||||||
|
|
||||||
@@ -369,7 +376,7 @@ func _ready() -> void:
|
|||||||
actions.add_child(skip_button)
|
actions.add_child(skip_button)
|
||||||
|
|
||||||
next_button = Button.new()
|
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.tooltip_text = ""
|
||||||
next_button.custom_minimum_size = Vector2(140, 48)
|
next_button.custom_minimum_size = Vector2(140, 48)
|
||||||
next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
@@ -420,14 +427,62 @@ func on_show(_payload: Variant = null) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func on_hide() -> void:
|
func on_hide() -> void:
|
||||||
|
if BlockSessionService.session_kind == "simulation" and BlockSessionService.block_started:
|
||||||
|
_save_simulation_current_response()
|
||||||
awaiting_next_question = false
|
awaiting_next_question = false
|
||||||
_hide_media_viewer()
|
_hide_media_viewer()
|
||||||
_hide_report_modal(true)
|
_hide_report_modal(true)
|
||||||
_hide_labs_modal()
|
_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:
|
func _process(delta: float) -> void:
|
||||||
_update_answer_feedback(delta)
|
_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":
|
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
|
||||||
_update_timer_bar()
|
_update_timer_bar()
|
||||||
if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible):
|
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
|
next_button.disabled = true
|
||||||
skip_button.disabled = false
|
skip_button.disabled = false
|
||||||
skip_button.visible = BlockSessionService.session_kind == "core"
|
skip_button.visible = BlockSessionService.session_kind == "core"
|
||||||
|
report_button.visible = BlockSessionService.session_kind != "simulation"
|
||||||
|
|
||||||
var question := BlockSessionService.get_current_question()
|
var question := BlockSessionService.get_current_question()
|
||||||
if question.is_empty():
|
if question.is_empty():
|
||||||
main.show_screen("selection", "step3")
|
main.show_screen("selection", "step3")
|
||||||
return
|
return
|
||||||
progress_label.visible = BlockSessionService.session_kind != "core"
|
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.visible = BlockSessionService.session_kind != "core"
|
||||||
timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed"
|
timer_label.text = "" if BlockSessionService.session_kind == "core" else "Tutor Mode" if BlockSessionService.mode == "Tutor" else "Timed"
|
||||||
timer_bar.visible = BlockSessionService.session_kind == "core"
|
timer_bar.visible = BlockSessionService.session_kind == "core"
|
||||||
@@ -538,8 +594,11 @@ func _load_question() -> void:
|
|||||||
flagged = bool(saved_answer.get("flagged", false))
|
flagged = bool(saved_answer.get("flagged", false))
|
||||||
flag_button.button_pressed = flagged
|
flag_button.button_pressed = flagged
|
||||||
_refresh_toolbar_state()
|
_refresh_toolbar_state()
|
||||||
_show_answer_review()
|
if BlockSessionService.session_kind == "simulation":
|
||||||
_update_question_elo_label(true)
|
_apply_saved_choice_state()
|
||||||
|
else:
|
||||||
|
_show_answer_review()
|
||||||
|
_update_question_elo_label(true)
|
||||||
|
|
||||||
_refresh_nav_palette()
|
_refresh_nav_palette()
|
||||||
_refresh_toolbar_state()
|
_refresh_toolbar_state()
|
||||||
@@ -556,9 +615,12 @@ func _select_choice(index: int) -> void:
|
|||||||
return
|
return
|
||||||
var choice_id := str(choices[index].get("id", ""))
|
var choice_id := str(choices[index].get("id", ""))
|
||||||
selected_choice = "" if selected_choice == choice_id else choice_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 = {}
|
current_answer = {}
|
||||||
next_button.text = "Submit"
|
if BlockSessionService.mode == "Simulation":
|
||||||
|
_save_simulation_current_response()
|
||||||
|
else:
|
||||||
|
next_button.text = "Submit"
|
||||||
_apply_saved_choice_state()
|
_apply_saved_choice_state()
|
||||||
|
|
||||||
|
|
||||||
@@ -566,9 +628,12 @@ func _clear_selected_choice() -> void:
|
|||||||
if selected_choice == "" or _is_correct_answer_locked():
|
if selected_choice == "" or _is_correct_answer_locked():
|
||||||
return
|
return
|
||||||
selected_choice = ""
|
selected_choice = ""
|
||||||
if BlockSessionService.mode == "Timed":
|
if BlockSessionService.mode == "Timed" or BlockSessionService.mode == "Simulation":
|
||||||
current_answer = {}
|
current_answer = {}
|
||||||
next_button.text = "Submit"
|
if BlockSessionService.mode == "Simulation":
|
||||||
|
_save_simulation_current_response()
|
||||||
|
else:
|
||||||
|
next_button.text = "Submit"
|
||||||
_apply_saved_choice_state()
|
_apply_saved_choice_state()
|
||||||
|
|
||||||
|
|
||||||
@@ -635,7 +700,7 @@ func _choice_gui_input(event: InputEvent, index: int) -> void:
|
|||||||
var mouse_event := event as InputEventMouseButton
|
var mouse_event := event as InputEventMouseButton
|
||||||
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed:
|
if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_RIGHT or not mouse_event.pressed:
|
||||||
return
|
return
|
||||||
if not current_answer.is_empty():
|
if not current_answer.is_empty() and BlockSessionService.session_kind != "simulation":
|
||||||
return
|
return
|
||||||
|
|
||||||
var choices := _get_presented_choices()
|
var choices := _get_presented_choices()
|
||||||
@@ -653,10 +718,12 @@ func _choice_gui_input(event: InputEvent, index: int) -> void:
|
|||||||
UISoundService.play_sound("eliminate")
|
UISoundService.play_sound("eliminate")
|
||||||
_set_choice_button_text(index, choice)
|
_set_choice_button_text(index, choice)
|
||||||
_apply_saved_choice_state()
|
_apply_saved_choice_state()
|
||||||
|
if BlockSessionService.session_kind == "simulation":
|
||||||
|
_save_simulation_current_response()
|
||||||
|
|
||||||
|
|
||||||
func _toggle_eliminate_selected() -> void:
|
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
|
return
|
||||||
var choices := _get_presented_choices()
|
var choices := _get_presented_choices()
|
||||||
for index in range(choices.size()):
|
for index in range(choices.size()):
|
||||||
@@ -668,9 +735,23 @@ func _toggle_eliminate_selected() -> void:
|
|||||||
UISoundService.play_sound("eliminate")
|
UISoundService.play_sound("eliminate")
|
||||||
_set_choice_button_text(index, choices[index])
|
_set_choice_button_text(index, choices[index])
|
||||||
_apply_saved_choice_state()
|
_apply_saved_choice_state()
|
||||||
|
if BlockSessionService.session_kind == "simulation":
|
||||||
|
_save_simulation_current_response()
|
||||||
|
|
||||||
|
|
||||||
func _submit_or_next() -> void:
|
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:
|
if awaiting_next_question:
|
||||||
return
|
return
|
||||||
if pending_correct_advance:
|
if pending_correct_advance:
|
||||||
@@ -1917,6 +1998,9 @@ func _is_correct_answer_locked() -> bool:
|
|||||||
func _refresh_submit_state() -> void:
|
func _refresh_submit_state() -> void:
|
||||||
if next_button == null:
|
if next_button == null:
|
||||||
return
|
return
|
||||||
|
if BlockSessionService.session_kind == "simulation":
|
||||||
|
next_button.disabled = false
|
||||||
|
return
|
||||||
if pending_correct_advance or followup_required:
|
if pending_correct_advance or followup_required:
|
||||||
next_button.disabled = true
|
next_button.disabled = true
|
||||||
return
|
return
|
||||||
@@ -2489,12 +2573,14 @@ func _refresh_nav_palette() -> void:
|
|||||||
Style.style_chip(button, Style.CYAN)
|
Style.style_chip(button, Style.CYAN)
|
||||||
elif answer != null and bool(answer.get("flagged", false)):
|
elif answer != null and bool(answer.get("flagged", false)):
|
||||||
Style.style_chip(button, Style.AMBER)
|
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)
|
Style.style_chip(button, Style.LIME)
|
||||||
else:
|
else:
|
||||||
Style.style_chip(button, Color("596982"))
|
Style.style_chip(button, Color("596982"))
|
||||||
button.pressed.connect(func(target_index: int = index):
|
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)
|
BlockSessionService.move_to(target_index)
|
||||||
_load_question()
|
_load_question()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dnyyo3s2jkmkr
|
||||||
@@ -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."
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://h7bg77wtfl3t
|
||||||
@@ -11,7 +11,9 @@ var question_bank_button: Button
|
|||||||
var bank_status_label: Label
|
var bank_status_label: Label
|
||||||
var bank_error_label: Label
|
var bank_error_label: Label
|
||||||
var retry_button: Button
|
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 current_stage := "exam"
|
||||||
var pending_question_bank_launch := false
|
var pending_question_bank_launch := false
|
||||||
|
|
||||||
@@ -24,6 +26,12 @@ func _ready() -> void:
|
|||||||
QuestionBankService.questions_changed.connect(_on_questions_changed)
|
QuestionBankService.questions_changed.connect(_on_questions_changed)
|
||||||
if not QuestionBankService.questions_load_failed.is_connected(_on_questions_load_failed):
|
if not QuestionBankService.questions_load_failed.is_connected(_on_questions_load_failed):
|
||||||
QuestionBankService.questions_load_failed.connect(_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_exam_view()
|
||||||
_build_mode_view()
|
_build_mode_view()
|
||||||
@@ -36,6 +44,7 @@ func on_show(payload: Variant = null) -> void:
|
|||||||
elif str(payload) == "exam":
|
elif str(payload) == "exam":
|
||||||
_show_stage("exam")
|
_show_stage("exam")
|
||||||
_refresh_bank_state()
|
_refresh_bank_state()
|
||||||
|
ExamSimulationService.load_readiness()
|
||||||
|
|
||||||
|
|
||||||
func on_hide() -> void:
|
func on_hide() -> void:
|
||||||
@@ -135,6 +144,22 @@ func _build_mode_view() -> void:
|
|||||||
dynamic_button.disabled = true
|
dynamic_button.disabled = true
|
||||||
mode_grid.add_child(dynamic_button)
|
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 = Label.new()
|
||||||
bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
bank_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
Style.set_label(bank_status_label, 16, Style.CYAN)
|
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)
|
retry_button.pressed.connect(_retry_question_bank)
|
||||||
mode_view.add_child(retry_button)
|
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()
|
var spacer := Control.new()
|
||||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
mode_view.add_child(spacer)
|
mode_view.add_child(spacer)
|
||||||
@@ -206,12 +222,6 @@ func _retry_question_bank() -> void:
|
|||||||
_refresh_bank_state()
|
_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:
|
func _on_questions_changed() -> void:
|
||||||
_refresh_bank_state()
|
_refresh_bank_state()
|
||||||
if pending_question_bank_launch and visible and current_stage == "step3" and QuestionBankService.load_state == "ready":
|
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()
|
_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:
|
func _refresh_bank_state() -> void:
|
||||||
if question_bank_button == null:
|
if question_bank_button == null:
|
||||||
return
|
return
|
||||||
var state := QuestionBankService.load_state
|
var state := QuestionBankService.load_state
|
||||||
question_bank_button.disabled = pending_question_bank_launch
|
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.visible = state == "failed" or state == "retrying"
|
||||||
retry_button.disabled = state == "retrying"
|
retry_button.disabled = state == "retrying"
|
||||||
bank_error_label.text = QuestionBankService.last_error if state == "failed" or state == "retrying" else ""
|
bank_error_label.text = QuestionBankService.last_error if state == "failed" or state == "retrying" else ""
|
||||||
|
|||||||
Reference in New Issue
Block a user