Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67afd6bd59 | ||
|
|
012d0cf196 | ||
|
|
fe54f7b417 | ||
|
|
be2d542cb6 | ||
|
|
53b85942d3 | ||
|
|
5488dec2c7 | ||
|
|
9709bab9a9 | ||
|
|
a08d0d9bf9 | ||
|
|
be144b272a | ||
|
|
778da32e73 | ||
|
|
ae6b37eb7d | ||
|
|
d5048c155a | ||
|
|
e5f29f0f38 | ||
|
|
c63227fcf5 | ||
|
|
9f150baecc | ||
|
|
94de696af1 | ||
|
|
98518e8d39 | ||
|
|
fa5df8795f | ||
|
|
a58aa401c8 | ||
|
|
09502944ec | ||
|
|
3fd670d111 | ||
|
|
0bd74fcabf | ||
|
|
1bc7fbfb3c | ||
|
|
bcb411bed8 | ||
|
|
1860e6ede6 | ||
|
|
4909cf0b71 | ||
|
|
a162ce31c6 | ||
|
|
19371b7a93 | ||
|
|
da641a8d1d |
@@ -77,6 +77,23 @@ Promotion is idempotent and does not delete or implicitly unpublish PRD content.
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
## Rz Coin Store attempt-cycle migration
|
||||
|
||||
The QBank reset preserves every attempt and increments a user's eligibility cycle. After deploying
|
||||
the store-capable API to an environment, inspect and then apply the one-time index migration before
|
||||
using QBank reset tokens:
|
||||
|
||||
```bash
|
||||
kubectl exec deployment/step3-api -n step3-uat -- \
|
||||
dotnet QuestionsApi.dll migrate-question-attempt-cycles
|
||||
kubectl exec deployment/step3-api -n step3-uat -- \
|
||||
dotnet QuestionsApi.dll migrate-question-attempt-cycles --apply --confirm MIGRATE_ATTEMPT_CYCLES
|
||||
```
|
||||
|
||||
Repeat with namespace `step3-prd` after the tested revision is promoted to `production`. The dry run
|
||||
reports missing cycle fields and both index states. Apply backfills existing attempts to cycle zero,
|
||||
creates the three-field unique index, and only then removes the legacy two-field unique index.
|
||||
|
||||
Check both internal and public readiness after every rollout:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,5 +17,28 @@ public sealed class CreditEconomyTests
|
||||
{
|
||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
|
||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
|
||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.Purchases)));
|
||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.AppliedOperations)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogHasStableCoinOnlyUtilityPrices()
|
||||
{
|
||||
var exam = CreditEconomy.FindProduct(CreditEconomy.ExamTokenSku);
|
||||
var reset = CreditEconomy.FindProduct(CreditEconomy.QBankResetTokenSku);
|
||||
|
||||
Assert.NotNull(exam);
|
||||
Assert.Equal(100, exam.CoinPrice);
|
||||
Assert.Equal(RzStoreGrant.ExamToken, exam.Grant);
|
||||
Assert.NotNull(reset);
|
||||
Assert.Equal(300, reset.CoinPrice);
|
||||
Assert.Equal(RzStoreGrant.QBankResetToken, reset.Grant);
|
||||
Assert.Equal(2, CreditEconomy.Products.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownSkuIsRejectedByCatalog()
|
||||
{
|
||||
Assert.Null(CreditEconomy.FindProduct("cash_purchase"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,104 @@ public sealed class ExamSimulationTests
|
||||
Assert.Equal(0, failing.Correct);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 50)]
|
||||
[InlineData(20, 100)]
|
||||
[InlineData(60, 200)]
|
||||
[InlineData(100, 300)]
|
||||
public void NeutralDifficultyUsesTransparentScoreAnchors(int correctCount, int expectedScore)
|
||||
{
|
||||
var items = BuildItems(100);
|
||||
var questions = BuildQuestions(100);
|
||||
var responses = items.Take(correctCount).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var score = ExamSimulationScoring.Calculate(items, responses, questions);
|
||||
|
||||
Assert.Equal(expectedScore, score.PredictedScore);
|
||||
Assert.Equal(correctCount / 100.0, score.RawAccuracy, 4);
|
||||
Assert.Equal(correctCount / 100.0, score.DifficultyAdjustedAccuracy, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeenItemsAreReportedAndCarryLessScoringWeight()
|
||||
public void CorrectHarderQuestionsProduceAHigherAdjustedPrediction()
|
||||
{
|
||||
var questions = BuildQuestions(10);
|
||||
var hardCorrect = BuildItems(10);
|
||||
var easyCorrect = BuildItems(10);
|
||||
for (var index = 0; index < 10; index++)
|
||||
{
|
||||
hardCorrect[index].QuestionDifficulty = index < 5 ? 5 : 1;
|
||||
easyCorrect[index].QuestionDifficulty = index < 5 ? 1 : 5;
|
||||
}
|
||||
var responses = hardCorrect.Take(5).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var hardScore = ExamSimulationScoring.Calculate(hardCorrect, responses, questions);
|
||||
var easyScore = ExamSimulationScoring.Calculate(easyCorrect, responses, questions);
|
||||
|
||||
Assert.Equal(hardScore.RawAccuracy, easyScore.RawAccuracy);
|
||||
Assert.True(hardScore.DifficultyAdjustedAccuracy > easyScore.DifficultyAdjustedAccuracy);
|
||||
Assert.True(hardScore.PredictedScore > easyScore.PredictedScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmpiricalQuestionEloDoesNotAffectPrediction()
|
||||
{
|
||||
var questions = BuildQuestions(20);
|
||||
var lowElos = BuildItems(20);
|
||||
var highElos = BuildItems(20);
|
||||
foreach (var item in lowElos) item.QuestionElo = 100;
|
||||
foreach (var item in highElos) item.QuestionElo = 1900;
|
||||
var responses = lowElos.Take(10).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var lowEloScore = ExamSimulationScoring.Calculate(lowElos, responses, questions);
|
||||
var highEloScore = ExamSimulationScoring.Calculate(highElos, responses, questions);
|
||||
|
||||
Assert.Equal(lowEloScore.PredictedScore, highEloScore.PredictedScore);
|
||||
Assert.Equal(lowEloScore.PassProbability, highEloScore.PassProbability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkippedAndUnansweredQuestionsCountAsIncorrect()
|
||||
{
|
||||
var items = BuildItems(10);
|
||||
var questions = BuildQuestions(10);
|
||||
var responses = new Dictionary<string, ExamSimulationResponse>
|
||||
{
|
||||
[items[0].QuestionExternalId] = new() { QuestionExternalId = items[0].QuestionExternalId, ChoiceId = "A" },
|
||||
[items[1].QuestionExternalId] = new() { QuestionExternalId = items[1].QuestionExternalId, ChoiceId = "" }
|
||||
};
|
||||
|
||||
var score = ExamSimulationScoring.Calculate(items, responses, questions);
|
||||
|
||||
Assert.Equal(1, score.Correct);
|
||||
Assert.Equal(10, score.Total);
|
||||
Assert.Equal(0.1, score.RawAccuracy, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LowAccuracyFullLengthSimulationIsNotInflated()
|
||||
{
|
||||
var items = BuildItems(232);
|
||||
var questions = BuildQuestions(232);
|
||||
var responses = items.Take(49).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var score = ExamSimulationScoring.Calculate(items, responses, questions);
|
||||
|
||||
Assert.Equal(103, score.PredictedScore);
|
||||
Assert.True(score.PassProbability < 0.001);
|
||||
Assert.True(score.RangeHigh < 130);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeenItemsAreReportedButDoNotChangeScoring()
|
||||
{
|
||||
var questions = BuildQuestions(20);
|
||||
var unseen = BuildItems(20);
|
||||
@@ -59,7 +155,9 @@ public sealed class ExamSimulationTests
|
||||
|
||||
Assert.Equal(0, unseenScore.SeenCount);
|
||||
Assert.Equal(20, seenScore.SeenCount);
|
||||
Assert.True(unseenScore.AbilityElo > seenScore.AbilityElo);
|
||||
Assert.Equal(unseenScore.PredictedScore, seenScore.PredictedScore);
|
||||
Assert.Null(unseenScore.AbilityElo);
|
||||
Assert.Null(seenScore.AbilityElo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -111,11 +209,64 @@ public sealed class ExamSimulationTests
|
||||
Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExamSimulationStates.BetweenBlocks, true)]
|
||||
[InlineData(ExamSimulationStates.Completed, true)]
|
||||
[InlineData(ExamSimulationStates.BlockActive, false)]
|
||||
[InlineData(ExamSimulationStates.Abandoned, false)]
|
||||
public void RepeatedBlockSubmissionIsIdempotentOnlyAfterSubmission(string state, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ExamSimulationStore.IsAlreadySubmittedState(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmittedSimulationResponsesAreLocked()
|
||||
{
|
||||
Assert.True(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = true }));
|
||||
Assert.False(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = false }));
|
||||
Assert.True(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = null }));
|
||||
Assert.False(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "", IsSubmitted = null }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmittedResponseRetriesMustPreserveAnswerContent()
|
||||
{
|
||||
var response = new ExamSimulationResponse
|
||||
{
|
||||
ChoiceId = "B",
|
||||
EliminatedChoiceIds = ["A"],
|
||||
Flagged = true,
|
||||
IsSubmitted = true
|
||||
};
|
||||
var same = new SaveSimulationResponseRequest
|
||||
{
|
||||
ChoiceId = "B",
|
||||
EliminatedChoiceIds = ["A"],
|
||||
Flagged = true,
|
||||
IsSubmitted = true
|
||||
};
|
||||
var changed = new SaveSimulationResponseRequest
|
||||
{
|
||||
ChoiceId = "C",
|
||||
EliminatedChoiceIds = ["A"],
|
||||
Flagged = true,
|
||||
IsSubmitted = true
|
||||
};
|
||||
|
||||
Assert.True(ExamSimulationStore.ResponseContentMatches(response, same, ["A", "B", "C"]));
|
||||
Assert.False(ExamSimulationStore.ResponseContentMatches(response, changed, ["A", "B", "C"]));
|
||||
}
|
||||
|
||||
private static List<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
|
||||
Enumerable.Range(0, count).Select(index => new ExamSimulationItem
|
||||
{
|
||||
QuestionExternalId = $"question-{index}",
|
||||
QuestionElo = 1000,
|
||||
QuestionDifficulty = 3,
|
||||
WasSeen = wasSeen,
|
||||
BlueprintCategory = "Cardiovascular"
|
||||
}).ToList();
|
||||
@@ -123,5 +274,5 @@ public sealed class ExamSimulationTests
|
||||
private static Dictionary<string, Question> BuildQuestions(int count) =>
|
||||
Enumerable.Range(0, count).ToDictionary(
|
||||
index => $"question-{index}",
|
||||
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A" });
|
||||
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A", Difficulty = 3 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Services;
|
||||
using QuestionsApi.Models;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
@@ -8,13 +9,71 @@ namespace QuestionsApi.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/credits")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class CreditsController(CreditStore credits) : ControllerBase
|
||||
public sealed class CreditsController(CreditStore credits, ExamSimulationStore simulations) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
|
||||
|
||||
[HttpPost("purchases")]
|
||||
public async Task<IActionResult> Purchase([FromBody] CreditPurchaseRequest request)
|
||||
{
|
||||
var product = CreditEconomy.FindProduct(request.Sku);
|
||||
if (product is null) return BadRequest(new { code = "unknown_sku", message = "Unknown store product." });
|
||||
if (!ValidOperationId(request.OperationId))
|
||||
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
|
||||
var result = await credits.PurchaseAsync(UserId(), product, request.OperationId);
|
||||
return result.Result switch
|
||||
{
|
||||
CreditOperationResult.Succeeded => Ok(result.Status),
|
||||
CreditOperationResult.InsufficientCredits => Conflict(new
|
||||
{ code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }),
|
||||
_ => Conflict(new
|
||||
{ code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status })
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("qbank-reset")]
|
||||
public async Task<IActionResult> ResetQBank([FromBody] QBankResetRequest request)
|
||||
{
|
||||
if (request.Confirmation != "RESET_QBANK")
|
||||
return BadRequest(new { code = "confirmation_required", message = "Enter RESET_QBANK to confirm." });
|
||||
if (!ValidOperationId(request.OperationId))
|
||||
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
|
||||
if (await simulations.FindActiveAsync(UserId()) is not null)
|
||||
return Conflict(new { code = "active_exam", message = "Finish or end the active exam before resetting the QBank." });
|
||||
var result = await credits.ApplyQBankResetAsync(UserId(), request.OperationId);
|
||||
return result.Result == CreditOperationResult.Succeeded
|
||||
? Ok(result.Status)
|
||||
: Conflict(new
|
||||
{ code = "reset_token_required", message = "A QBank Reset Token is required.", credits = result.Status });
|
||||
}
|
||||
|
||||
[HttpGet("spin-config")]
|
||||
public IActionResult SpinConfig() => Ok(new SpinConfigResponse());
|
||||
|
||||
[HttpPost("spin")]
|
||||
public async Task<IActionResult> Spin([FromBody] SpinRequest request)
|
||||
{
|
||||
if (!ValidOperationId(request.OperationId))
|
||||
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
|
||||
var result = await credits.SpinAsync(UserId(), request.OperationId);
|
||||
return result.Result switch
|
||||
{
|
||||
CreditOperationResult.Succeeded => Ok(result),
|
||||
CreditOperationResult.InsufficientCredits => Conflict(new
|
||||
{ code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }),
|
||||
CreditOperationResult.RateLimited => Conflict(new
|
||||
{ code = "rate_limited", message = "Too many spins - try again later.", credits = result.Status }),
|
||||
_ => Conflict(new
|
||||
{ code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status })
|
||||
};
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? throw new UnauthorizedAccessException();
|
||||
|
||||
private static bool ValidOperationId(string value) =>
|
||||
!string.IsNullOrWhiteSpace(value) && value.Length <= 100;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/exam-simulations")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase
|
||||
{
|
||||
[HttpGet("readiness")]
|
||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||
@@ -17,12 +17,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var userId = UserId();
|
||||
if (await simulations.FindActiveAsync(userId) is not null)
|
||||
return Conflict(new { code = "active_exam", message = "An active Step 3 simulation already exists." });
|
||||
var operationId = $"exam-create:{Guid.NewGuid():N}";
|
||||
var bypass = User.IsInRole("SUPER");
|
||||
if (!bypass && !await credits.TryConsumeExamTokenAsync(userId, operationId))
|
||||
return Conflict(new
|
||||
{
|
||||
code = "exam_token_required",
|
||||
message = "An Exam Simulation Token is required.",
|
||||
credits = await credits.GetStatusAsync(userId)
|
||||
});
|
||||
try
|
||||
{
|
||||
var simulation = await simulations.CreateAsync(UserId());
|
||||
var simulation = await simulations.CreateAsync(userId);
|
||||
return Ok(await simulations.ToResponseAsync(simulation));
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
|
||||
return Conflict(new { code = "exam_creation_failed", message = exception.Message });
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("preview-results")]
|
||||
|
||||
@@ -157,7 +157,8 @@ public class QuestionsController : ControllerBase
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||
false,
|
||||
presentedChoiceIds,
|
||||
req.DurationSeconds);
|
||||
req.DurationSeconds,
|
||||
await _credits.GetBankCycleAsync(userId));
|
||||
if (record.Inserted)
|
||||
{
|
||||
_metrics.AttemptCreated();
|
||||
@@ -276,7 +277,8 @@ public class QuestionsController : ControllerBase
|
||||
userId,
|
||||
"",
|
||||
false,
|
||||
true);
|
||||
true,
|
||||
bankCycle: await _credits.GetBankCycleAsync(userId));
|
||||
if (record.Inserted)
|
||||
_metrics.AttemptCreated();
|
||||
else
|
||||
|
||||
@@ -7,6 +7,86 @@ public static class CreditEconomy
|
||||
{
|
||||
public const int RollingEarningLimit = 20;
|
||||
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
|
||||
public const string ExamTokenSku = "exam_token_1";
|
||||
public const string QBankResetTokenSku = "qbank_reset_1";
|
||||
public const int ExamTokenPrice = 100;
|
||||
public const int QBankResetTokenPrice = 300;
|
||||
|
||||
public static readonly IReadOnlyList<RzStoreProduct> Products =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Sku = ExamTokenSku,
|
||||
Name = "Exam Simulation Token",
|
||||
Description = "Starts one new full exam simulation.",
|
||||
CoinPrice = ExamTokenPrice,
|
||||
Grant = RzStoreGrant.ExamToken
|
||||
},
|
||||
new()
|
||||
{
|
||||
Sku = QBankResetTokenSku,
|
||||
Name = "QBank Reset Token",
|
||||
Description = "Makes every QBank question eligible again without erasing progress.",
|
||||
CoinPrice = QBankResetTokenPrice,
|
||||
Grant = RzStoreGrant.QBankResetToken
|
||||
}
|
||||
];
|
||||
|
||||
public static RzStoreProduct? FindProduct(string sku) =>
|
||||
Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public static class SlotMachineEconomy
|
||||
{
|
||||
public const int SpinCost = 0; // TODO: restore to 50 once testing is done
|
||||
public const int RollingSpinLimit = 200;
|
||||
public static readonly TimeSpan SpinWindow = TimeSpan.FromHours(24);
|
||||
|
||||
public static readonly IReadOnlyList<SpinReward> Rewards =
|
||||
[
|
||||
new() { Key = "jackpot", Label = "10,000 RZ Coins", Description = "Jackpot!", Grant = RzStoreGrant.Coins, CoinAmount = 10000, Probability = 0.005 },
|
||||
new() { Key = "epic", Label = "2,000 RZ Coins", Description = "Epic Reward", Grant = RzStoreGrant.Coins, CoinAmount = 2000, Probability = 0.02 },
|
||||
new() { Key = "great", Label = "500 RZ Coins", Description = "Great Reward", Grant = RzStoreGrant.Coins, CoinAmount = 500, Probability = 0.10 },
|
||||
new() { Key = "exam_token", Label = "Exam Simulation Token", Description = "Starts one new full exam simulation.", Grant = RzStoreGrant.ExamToken, Probability = 0.15 },
|
||||
new() { Key = "qbank_reset", Label = "QBank Reset Token", Description = "Makes every QBank question eligible again.", Grant = RzStoreGrant.QBankResetToken, Probability = 0.20 },
|
||||
new() { Key = "nice", Label = "100 RZ Coins", Description = "Nice!", Grant = RzStoreGrant.Coins, CoinAmount = 100, Probability = 0.30 },
|
||||
new() { Key = "good_luck", Label = "50 RZ Coins", Description = "Good luck!", Grant = RzStoreGrant.Coins, CoinAmount = 50, Probability = 0.225 },
|
||||
];
|
||||
|
||||
public static SpinReward RollReward(double roll)
|
||||
{
|
||||
var cumulative = 0.0;
|
||||
foreach (var reward in Rewards)
|
||||
{
|
||||
cumulative += reward.Probability;
|
||||
if (roll < cumulative) return reward;
|
||||
}
|
||||
return Rewards[^1];
|
||||
}
|
||||
|
||||
public static SpinReward? FindReward(string key) =>
|
||||
Rewards.FirstOrDefault(value => value.Key.Equals(key, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public enum RzStoreGrant { ExamToken, QBankResetToken, Coins }
|
||||
|
||||
public sealed class SpinReward
|
||||
{
|
||||
public string Key { get; set; } = "";
|
||||
public string Label { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public RzStoreGrant Grant { get; set; }
|
||||
public int CoinAmount { get; set; }
|
||||
public double Probability { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RzStoreProduct
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public int CoinPrice { get; set; }
|
||||
public RzStoreGrant Grant { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreditEvent
|
||||
@@ -15,6 +95,22 @@ public sealed class CreditEvent
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreditPurchaseEvent
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public int CoinCost { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SpinEvent
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
public string RewardKey { get; set; } = "";
|
||||
public int CoinsWon { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserCreditAccount
|
||||
{
|
||||
[BsonId]
|
||||
@@ -22,7 +118,15 @@ public sealed class UserCreditAccount
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public int Credits { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int QBankResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public bool InitialExamGrantApplied { get; set; }
|
||||
public List<CreditEvent> RecentEarnings { get; set; } = [];
|
||||
public List<CreditPurchaseEvent> Purchases { get; set; } = [];
|
||||
public List<SpinEvent> Spins { get; set; } = [];
|
||||
public List<CreditEvent> RecentSpins { get; set; } = [];
|
||||
public List<string> AppliedOperations { get; set; } = [];
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -32,4 +136,48 @@ public sealed class CreditStatusResponse
|
||||
public int EarnedInRollingWindow { get; set; }
|
||||
public int RemainingEarningCapacity { get; set; }
|
||||
public int RollingWindowHours { get; set; } = 24;
|
||||
public int ExamTokens { get; set; }
|
||||
public int QBankResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public IReadOnlyList<RzStoreProduct> Products { get; set; } = CreditEconomy.Products;
|
||||
}
|
||||
|
||||
public sealed class CreditPurchaseRequest
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class QBankResetRequest
|
||||
{
|
||||
public string Confirmation { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class SpinRequest
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum CreditOperationResult { Succeeded, InsufficientCredits, InsufficientTokens, OperationConflict, RateLimited }
|
||||
|
||||
public sealed class CreditOperationResponse
|
||||
{
|
||||
public CreditOperationResult Result { get; set; }
|
||||
public CreditStatusResponse Status { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SpinConfigResponse
|
||||
{
|
||||
public int SpinCost { get; set; } = SlotMachineEconomy.SpinCost;
|
||||
public IReadOnlyList<SpinReward> Rewards { get; set; } = SlotMachineEconomy.Rewards;
|
||||
}
|
||||
|
||||
public sealed class SpinResponse
|
||||
{
|
||||
public CreditOperationResult Result { get; set; }
|
||||
public string RewardKey { get; set; } = "";
|
||||
public string RewardLabel { get; set; } = "";
|
||||
public int CoinsWon { get; set; }
|
||||
public CreditStatusResponse Status { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ public class ExamSimulationItem
|
||||
public int Position { get; set; }
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public int QuestionDifficulty { get; set; }
|
||||
public bool WasSeen { get; set; }
|
||||
public string BlueprintCategory { get; set; } = "Other";
|
||||
}
|
||||
@@ -52,6 +53,7 @@ public class ExamSimulationResponse
|
||||
public List<string> EliminatedChoiceIds { get; set; } = [];
|
||||
public bool Flagged { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public bool? IsSubmitted { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -64,7 +66,9 @@ public class ExamSimulationScore
|
||||
public int Correct { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int SeenCount { get; set; }
|
||||
public double AbilityElo { get; set; }
|
||||
public double RawAccuracy { get; set; }
|
||||
public double DifficultyAdjustedAccuracy { get; set; }
|
||||
public double? AbilityElo { get; set; }
|
||||
public Dictionary<string, ExamSimulationCategoryScore> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -81,6 +85,7 @@ public class SaveSimulationResponseRequest
|
||||
public List<string> EliminatedChoiceIds { get; set; } = [];
|
||||
public bool Flagged { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public bool IsSubmitted { get; set; }
|
||||
}
|
||||
|
||||
public class ExamSimulationQuestionResponse
|
||||
|
||||
@@ -11,6 +11,7 @@ public class QuestionAttempt
|
||||
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public int BankCycle { get; set; }
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public bool TracksPresentation { get; set; }
|
||||
|
||||
@@ -55,6 +55,17 @@ if (args.FirstOrDefault() == "flag-media-questions")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "migrate-question-attempt-cycles")
|
||||
{
|
||||
var migrationConfiguration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
Environment.ExitCode = await QuestionAttemptCycleMigrationCommand.RunAsync(
|
||||
migrationConfiguration, args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
@@ -114,6 +125,7 @@ builder.Services.AddSingleton<DonationStore>();
|
||||
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
builder.Services.AddHostedService<ExamSimulationScoringMigrationService>();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
|
||||
@@ -51,15 +51,167 @@ public sealed class CreditStore
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
return new CreditStatusResponse
|
||||
{
|
||||
Credits = account.Credits,
|
||||
EarnedInRollingWindow = account.RecentEarnings.Count,
|
||||
RemainingEarningCapacity = Math.Max(
|
||||
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
|
||||
};
|
||||
return ToStatus(account);
|
||||
}
|
||||
|
||||
public async Task<int> GetBankCycleAsync(string userId)
|
||||
{
|
||||
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
|
||||
}
|
||||
|
||||
public async Task<CreditOperationResponse> PurchaseAsync(
|
||||
string userId, RzStoreProduct product, string operationId)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (prior is not null)
|
||||
return Response(prior.Sku == product.Sku
|
||||
? CreditOperationResult.Succeeded
|
||||
: CreditOperationResult.OperationConflict, existing);
|
||||
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
|
||||
Builders<UserCreditAccount>.Filter.Not(
|
||||
Builders<UserCreditAccount>.Filter.ElemMatch(
|
||||
value => value.Purchases, purchase => purchase.OperationId == operationId));
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.Credits, -product.CoinPrice)
|
||||
.Push(value => value.Purchases, new CreditPurchaseEvent
|
||||
{
|
||||
OperationId = operationId,
|
||||
Sku = product.Sku,
|
||||
CoinCost = product.CoinPrice,
|
||||
CreatedUtc = now
|
||||
})
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
update = product.Grant == RzStoreGrant.ExamToken
|
||||
? update.Inc(value => value.ExamTokens, 1)
|
||||
: update.Inc(value => value.QBankResetTokens, 1);
|
||||
var account = await _accounts.FindOneAndUpdateAsync(
|
||||
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
|
||||
|
||||
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (prior is not null)
|
||||
return Response(prior.Sku == product.Sku
|
||||
? CreditOperationResult.Succeeded
|
||||
: CreditOperationResult.OperationConflict, account);
|
||||
return Response(CreditOperationResult.InsufficientCredits, account);
|
||||
}
|
||||
|
||||
public async Task<CreditOperationResponse> ApplyQBankResetAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Gt(value => value.QBankResetTokens, 0) &
|
||||
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.QBankResetTokens, -1)
|
||||
.Inc(value => value.BankCycle, 1)
|
||||
.AddToSet(value => value.AppliedOperations, operationId)
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
var account = await _accounts.FindOneAndUpdateAsync(
|
||||
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
|
||||
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
if (account.AppliedOperations.Contains(operationId))
|
||||
return Response(CreditOperationResult.Succeeded, account);
|
||||
return Response(CreditOperationResult.InsufficientTokens, account);
|
||||
}
|
||||
|
||||
public async Task<SpinResponse> SpinAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
var priorSpin = existing.Spins.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (priorSpin is not null)
|
||||
return SpinResult(CreditOperationResult.Succeeded, existing, priorSpin);
|
||||
|
||||
var reward = SlotMachineEconomy.RollReward(Random.Shared.NextDouble());
|
||||
var netCreditDelta = reward.Grant == RzStoreGrant.Coins
|
||||
? reward.CoinAmount - SlotMachineEconomy.SpinCost
|
||||
: -SlotMachineEconomy.SpinCost;
|
||||
var spinEvent = new SpinEvent
|
||||
{
|
||||
OperationId = operationId,
|
||||
RewardKey = reward.Key,
|
||||
CoinsWon = reward.Grant == RzStoreGrant.Coins ? reward.CoinAmount : 0,
|
||||
CreatedUtc = now
|
||||
};
|
||||
|
||||
var belowSpinCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
|
||||
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
|
||||
{
|
||||
new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })),
|
||||
SlotMachineEconomy.RollingSpinLimit
|
||||
})));
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) &
|
||||
Builders<UserCreditAccount>.Filter.Not(
|
||||
Builders<UserCreditAccount>.Filter.ElemMatch(
|
||||
value => value.Spins, spin => spin.OperationId == operationId)) &
|
||||
belowSpinCap;
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.Credits, netCreditDelta)
|
||||
.Push(value => value.Spins, spinEvent)
|
||||
.Push(value => value.RecentSpins, new CreditEvent { OperationId = operationId, CreatedUtc = now })
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
update = reward.Grant switch
|
||||
{
|
||||
RzStoreGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
|
||||
RzStoreGrant.QBankResetToken => update.Inc(value => value.QBankResetTokens, 1),
|
||||
_ => update
|
||||
};
|
||||
var account = await _accounts.FindOneAndUpdateAsync(
|
||||
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
if (account is not null) return SpinResult(CreditOperationResult.Succeeded, account, spinEvent);
|
||||
|
||||
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
priorSpin = account.Spins.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (priorSpin is not null) return SpinResult(CreditOperationResult.Succeeded, account, priorSpin);
|
||||
if (account.Credits < SlotMachineEconomy.SpinCost)
|
||||
return SpinResult(CreditOperationResult.InsufficientCredits, account, null);
|
||||
return SpinResult(CreditOperationResult.RateLimited, account, null);
|
||||
}
|
||||
|
||||
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Gt(value => value.ExamTokens, 0) &
|
||||
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.ExamTokens, -1)
|
||||
.AddToSet(value => value.AppliedOperations, operationId)
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public Task RefundExamTokenAsync(string userId, string operationId) =>
|
||||
_accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.AnyEq(value => value.AppliedOperations, operationId),
|
||||
Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.ExamTokens, 1)
|
||||
.Pull(value => value.AppliedOperations, operationId)
|
||||
.Set(value => value.UpdatedUtc, _clock.GetUtcNow().UtcDateTime));
|
||||
|
||||
private async Task EnsureAndCleanAsync(string userId, DateTime now)
|
||||
{
|
||||
await _accounts.UpdateOneAsync(
|
||||
@@ -67,18 +219,82 @@ public sealed class CreditStore
|
||||
Builders<UserCreditAccount>.Update
|
||||
.SetOnInsert(value => value.UserId, userId)
|
||||
.SetOnInsert(value => value.Credits, 0)
|
||||
.SetOnInsert(value => value.ExamTokens, 1)
|
||||
.SetOnInsert(value => value.QBankResetTokens, 0)
|
||||
.SetOnInsert(value => value.BankCycle, 0)
|
||||
.SetOnInsert(value => value.InitialExamGrantApplied, true)
|
||||
.SetOnInsert(value => value.RecentEarnings, [])
|
||||
.SetOnInsert(value => value.Purchases, [])
|
||||
.SetOnInsert(value => value.Spins, [])
|
||||
.SetOnInsert(value => value.RecentSpins, [])
|
||||
.SetOnInsert(value => value.AppliedOperations, [])
|
||||
.SetOnInsert(value => value.UpdatedUtc, now),
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Ne(value => value.InitialExamGrantApplied, true),
|
||||
Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.ExamTokens, 1)
|
||||
.Set(value => value.InitialExamGrantApplied, true));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Spins), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.Spins, []));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.RecentSpins, []));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.AppliedOperations, []));
|
||||
var cutoff = now - CreditEconomy.EarningWindow;
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserCreditAccount>.Update.PullFilter(
|
||||
value => value.RecentEarnings,
|
||||
earning => earning.CreatedUtc <= cutoff));
|
||||
var spinCutoff = now - SlotMachineEconomy.SpinWindow;
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserCreditAccount>.Update.PullFilter(
|
||||
value => value.RecentSpins,
|
||||
spin => spin.CreatedUtc <= spinCutoff));
|
||||
}
|
||||
|
||||
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
|
||||
{
|
||||
Credits = account.Credits,
|
||||
EarnedInRollingWindow = account.RecentEarnings.Count,
|
||||
RemainingEarningCapacity = Math.Max(
|
||||
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count),
|
||||
ExamTokens = account.ExamTokens,
|
||||
QBankResetTokens = account.QBankResetTokens,
|
||||
BankCycle = account.BankCycle
|
||||
};
|
||||
|
||||
private static CreditOperationResponse Response(
|
||||
CreditOperationResult result, UserCreditAccount account) => new()
|
||||
{
|
||||
Result = result,
|
||||
Status = ToStatus(account)
|
||||
};
|
||||
|
||||
private static SpinResponse SpinResult(
|
||||
CreditOperationResult result, UserCreditAccount account, SpinEvent? spinEvent) => new()
|
||||
{
|
||||
Result = result,
|
||||
RewardKey = spinEvent?.RewardKey ?? "",
|
||||
RewardLabel = spinEvent is null ? "" : SlotMachineEconomy.FindReward(spinEvent.RewardKey)?.Label ?? "",
|
||||
CoinsWon = spinEvent?.CoinsWon ?? 0,
|
||||
Status = ToStatus(account)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,33 +4,35 @@ 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 const string ModelVersion = "provisional-2";
|
||||
private const double PassingAccuracy = 0.60;
|
||||
private const double ScoreIntercept = 50.0;
|
||||
private const double ScoreScale = 250.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 weightedCorrect = 0.0;
|
||||
var weightSum = 0.0;
|
||||
var squaredWeightSum = 0.0;
|
||||
var categories = new Dictionary<string, ExamSimulationCategoryScore>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!questions.TryGetValue(item.QuestionExternalId, out var question))
|
||||
continue;
|
||||
questions.TryGetValue(item.QuestionExternalId, out var question);
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var isCorrect = response?.ChoiceId == question.CorrectChoiceId;
|
||||
var isCorrect = question is not null && 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);
|
||||
var difficulty = ResolveDifficulty(item, question);
|
||||
var weight = DifficultyWeight(difficulty);
|
||||
weightSum += weight;
|
||||
squaredWeightSum += weight * weight;
|
||||
if (isCorrect) weightedCorrect += weight;
|
||||
if (!categories.TryGetValue(item.BlueprintCategory, out var category))
|
||||
{
|
||||
category = new ExamSimulationCategoryScore();
|
||||
@@ -40,51 +42,53 @@ public static class ExamSimulationScoring
|
||||
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);
|
||||
var rawAccuracy = items.Count == 0 ? 0.0 : (double)correct / items.Count;
|
||||
var adjustedAccuracy = weightSum == 0.0 ? 0.0 : weightedCorrect / weightSum;
|
||||
var predicted = ToScore(adjustedAccuracy);
|
||||
var effectiveSampleSize = squaredWeightSum == 0.0 ? 0.0 : weightSum * weightSum / squaredWeightSum;
|
||||
var accuracySe = effectiveSampleSize == 0.0
|
||||
? 0.0
|
||||
: Math.Sqrt(adjustedAccuracy * (1.0 - adjustedAccuracy) / effectiveSampleSize);
|
||||
var accuracyMargin = 1.96 * accuracySe;
|
||||
var rangeLow = ToScore(Math.Clamp(adjustedAccuracy - accuracyMargin, 0.0, 1.0));
|
||||
var rangeHigh = ToScore(Math.Clamp(adjustedAccuracy + accuracyMargin, 0.0, 1.0));
|
||||
var passProbability = accuracySe == 0.0
|
||||
? adjustedAccuracy >= PassingAccuracy ? 1.0 : 0.0
|
||||
: NormalCdf((adjustedAccuracy - PassingAccuracy) / accuracySe);
|
||||
return new ExamSimulationScore
|
||||
{
|
||||
PredictedScore = predicted,
|
||||
RangeLow = Math.Clamp(predicted - range, 1, 300),
|
||||
RangeHigh = Math.Clamp(predicted + range, 1, 300),
|
||||
RangeLow = rangeLow,
|
||||
RangeHigh = rangeHigh,
|
||||
PassProbability = Math.Round(passProbability, 4),
|
||||
Correct = correct,
|
||||
Total = items.Count,
|
||||
SeenCount = seen,
|
||||
AbilityElo = Math.Round(ability, 1),
|
||||
RawAccuracy = Math.Round(rawAccuracy, 4),
|
||||
DifficultyAdjustedAccuracy = Math.Round(adjustedAccuracy, 4),
|
||||
AbilityElo = null,
|
||||
Categories = categories
|
||||
};
|
||||
}
|
||||
|
||||
private static double EstimateAbility(
|
||||
IReadOnlyList<ExamSimulationItem> items,
|
||||
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
|
||||
IReadOnlyDictionary<string, Question> questions)
|
||||
public static int ResolveDifficulty(ExamSimulationItem item, Question? question = null)
|
||||
{
|
||||
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;
|
||||
if (item.QuestionDifficulty is >= 1 and <= 5) return item.QuestionDifficulty;
|
||||
return question?.Difficulty is >= 1 and <= 5 ? question.Difficulty : 3;
|
||||
}
|
||||
|
||||
private static double Expected(double ability, int difficulty) =>
|
||||
1.0 / (1.0 + Math.Pow(10.0, (difficulty - ability) / 400.0));
|
||||
private static double DifficultyWeight(int difficulty) => difficulty switch
|
||||
{
|
||||
1 => 0.8,
|
||||
2 => 0.9,
|
||||
3 => 1.0,
|
||||
4 => 1.1,
|
||||
5 => 1.2,
|
||||
_ => 1.0
|
||||
};
|
||||
|
||||
private static int ToScore(double accuracy) =>
|
||||
Math.Clamp((int)Math.Round(ScoreIntercept + ScoreScale * accuracy), 1, 300);
|
||||
|
||||
private static double NormalCdf(double value) => 0.5 * (1.0 + Erf(value / Math.Sqrt(2.0)));
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class ExamSimulationScoringMigrationService : BackgroundService
|
||||
{
|
||||
private readonly ExamSimulationStore _simulations;
|
||||
private readonly ILogger<ExamSimulationScoringMigrationService> _logger;
|
||||
|
||||
public ExamSimulationScoringMigrationService(
|
||||
ExamSimulationStore simulations,
|
||||
ILogger<ExamSimulationScoringMigrationService> logger)
|
||||
{
|
||||
_simulations = simulations;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _simulations.BackfillScoringModelAsync(stoppingToken);
|
||||
_logger.LogInformation(
|
||||
"Exam simulation scoring migration completed; recalculated {UpdatedCount} simulation(s)",
|
||||
result.UpdatedCount);
|
||||
foreach (var reference in result.MissingQuestionIds)
|
||||
_logger.LogWarning(
|
||||
"Exam simulation scoring migration used neutral difficulty for missing question {Reference}",
|
||||
reference);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Exam simulation scoring migration failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public class ExamSimulationStore
|
||||
private readonly IMongoCollection<Step3Outcome> _outcomes;
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly QuestionAttemptStore _attempts;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly AnswerStatisticsStore _statistics;
|
||||
private readonly RatingStore _ratings;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
@@ -22,6 +23,7 @@ public class ExamSimulationStore
|
||||
MongoDatabaseProvider mongo,
|
||||
QuestionStore questions,
|
||||
QuestionAttemptStore attempts,
|
||||
CreditStore credits,
|
||||
AnswerStatisticsStore statistics,
|
||||
RatingStore ratings,
|
||||
ServiceMetrics metrics,
|
||||
@@ -31,6 +33,7 @@ public class ExamSimulationStore
|
||||
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
_credits = credits;
|
||||
_statistics = statistics;
|
||||
_ratings = ratings;
|
||||
_metrics = metrics;
|
||||
@@ -68,7 +71,8 @@ public class ExamSimulationStore
|
||||
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 bankCycle = await _credits.GetBankCycleAsync(userId);
|
||||
var seen = (await _attempts.GetUserQuestionIdsAsync(userId, bankCycle)).ToHashSet(StringComparer.Ordinal);
|
||||
var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
|
||||
var targets = BuildTargets();
|
||||
var selected = SelectForm(bank, seen, targets);
|
||||
@@ -87,6 +91,7 @@ public class ExamSimulationStore
|
||||
Position = offset,
|
||||
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
|
||||
QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000),
|
||||
QuestionDifficulty = question.Difficulty,
|
||||
WasSeen = seen.Contains(question.ExternalId),
|
||||
BlueprintCategory = BlueprintCategory(question)
|
||||
});
|
||||
@@ -124,6 +129,7 @@ public class ExamSimulationStore
|
||||
Position = PositionWithinBlock(index),
|
||||
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
|
||||
QuestionElo = 1000,
|
||||
QuestionDifficulty = question.Difficulty,
|
||||
WasSeen = false,
|
||||
BlueprintCategory = BlueprintCategory(question)
|
||||
}).ToList();
|
||||
@@ -169,15 +175,22 @@ public class ExamSimulationStore
|
||||
(item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive))
|
||||
.SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId) =>
|
||||
(await _simulations.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
|
||||
.SortByDescending(item => item.CompletedUtc).FirstOrDefaultAsync())?.Score;
|
||||
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId)
|
||||
{
|
||||
var simulation = await _simulations
|
||||
.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
|
||||
.SortByDescending(item => item.CompletedUtc)
|
||||
.FirstOrDefaultAsync();
|
||||
return simulation is null ? null : (await UpgradeScoringModelAsync(simulation)).Score;
|
||||
}
|
||||
|
||||
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);
|
||||
if (simulation is not null)
|
||||
simulation = await UpgradeScoringModelAsync(simulation);
|
||||
return simulation;
|
||||
}
|
||||
|
||||
@@ -191,6 +204,7 @@ public class ExamSimulationStore
|
||||
var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync();
|
||||
var hasMore = simulations.Count > limit;
|
||||
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
|
||||
await UpgradeScoringModelsAsync(simulations);
|
||||
return new CompletedExamSimulationPage
|
||||
{
|
||||
Items = simulations.Select(item => new CompletedExamSimulationSummary
|
||||
@@ -257,6 +271,7 @@ public class ExamSimulationStore
|
||||
var simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync();
|
||||
var hasMore = simulations.Count > limit;
|
||||
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
|
||||
await UpgradeScoringModelsAsync(simulations.Where(item => item.State == ExamSimulationStates.Completed));
|
||||
var last = simulations.LastOrDefault();
|
||||
return new ExamSimulationHistoryPage
|
||||
{
|
||||
@@ -333,23 +348,53 @@ public class ExamSimulationStore
|
||||
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 not null && IsResponseLocked(response))
|
||||
{
|
||||
if (ResponseContentMatches(response, request, item.PresentedChoiceIds))
|
||||
return simulation;
|
||||
throw new InvalidOperationException("A submitted simulation answer cannot be changed.");
|
||||
}
|
||||
if (response is null)
|
||||
{
|
||||
response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId };
|
||||
response = new ExamSimulationResponse
|
||||
{
|
||||
QuestionExternalId = request.QuestionExternalId,
|
||||
IsSubmitted = false
|
||||
};
|
||||
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.IsSubmitted = request.IsSubmitted;
|
||||
response.UpdatedUtc = DateTime.UtcNow;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
await ReplaceAsync(simulation);
|
||||
return simulation;
|
||||
}
|
||||
|
||||
public static bool IsResponseLocked(ExamSimulationResponse response) =>
|
||||
response.IsSubmitted == true ||
|
||||
(response.IsSubmitted is null && !string.IsNullOrEmpty(response.ChoiceId));
|
||||
|
||||
public static bool ResponseContentMatches(
|
||||
ExamSimulationResponse response,
|
||||
SaveSimulationResponseRequest request,
|
||||
IReadOnlyCollection<string> presentedChoiceIds)
|
||||
{
|
||||
var eliminated = request.EliminatedChoiceIds
|
||||
.Where(presentedChoiceIds.Contains)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
return response.ChoiceId == request.ChoiceId &&
|
||||
response.Flagged == request.Flagged &&
|
||||
response.EliminatedChoiceIds.ToHashSet(StringComparer.Ordinal).SetEquals(eliminated);
|
||||
}
|
||||
|
||||
public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
|
||||
{
|
||||
if (IsAlreadySubmittedState(simulation.State))
|
||||
return simulation;
|
||||
if (simulation.State != ExamSimulationStates.BlockActive)
|
||||
throw new InvalidOperationException("There is no active block to submit.");
|
||||
if (!expired && simulation.BlockDeadlineUtc is not null)
|
||||
@@ -366,6 +411,9 @@ public class ExamSimulationStore
|
||||
return simulation;
|
||||
}
|
||||
|
||||
public static bool IsAlreadySubmittedState(string state) =>
|
||||
state is ExamSimulationStates.BetweenBlocks or ExamSimulationStates.Completed;
|
||||
|
||||
public async Task AbandonAsync(ExamSimulation simulation)
|
||||
{
|
||||
simulation.State = ExamSimulationStates.Abandoned;
|
||||
@@ -433,7 +481,9 @@ public class ExamSimulationStore
|
||||
{
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
|
||||
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
|
||||
SnapshotDifficulties(simulation, questionMap);
|
||||
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
|
||||
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
|
||||
simulation.State = ExamSimulationStates.Completed;
|
||||
simulation.CompletedUtc = DateTime.UtcNow;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
@@ -443,7 +493,10 @@ public class ExamSimulationStore
|
||||
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);
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
item.QuestionExternalId, simulation.UserId, choiceId,
|
||||
choiceId == question.CorrectChoiceId, choiceId == "",
|
||||
item.PresentedChoiceIds, bankCycle: await _credits.GetBankCycleAsync(simulation.UserId));
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (!record.Inserted) continue;
|
||||
_metrics.AttemptCreated();
|
||||
@@ -468,6 +521,71 @@ public class ExamSimulationStore
|
||||
private Task ReplaceAsync(ExamSimulation simulation) =>
|
||||
_simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, simulation);
|
||||
|
||||
public async Task<ExamSimulationBackfillResult> BackfillScoringModelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<ExamSimulation>.Filter.Eq(item => item.State, ExamSimulationStates.Completed) &
|
||||
Builders<ExamSimulation>.Filter.Ne(item => item.ModelVersion, ExamSimulationScoring.ModelVersion);
|
||||
var simulations = await _simulations.Find(filter).ToListAsync(cancellationToken);
|
||||
var missingQuestionIds = new List<string>();
|
||||
var updated = 0;
|
||||
foreach (var simulation in simulations)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
|
||||
missingQuestionIds.AddRange(simulation.Items
|
||||
.Where(item => !questionMap.ContainsKey(item.QuestionExternalId))
|
||||
.Select(item => $"{simulation.Id}:{item.QuestionExternalId}"));
|
||||
SnapshotDifficulties(simulation, questionMap);
|
||||
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
|
||||
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
|
||||
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
await ReplaceAsync(simulation);
|
||||
updated++;
|
||||
}
|
||||
return new ExamSimulationBackfillResult(updated, missingQuestionIds);
|
||||
}
|
||||
|
||||
private async Task UpgradeScoringModelsAsync(IEnumerable<ExamSimulation> simulations)
|
||||
{
|
||||
foreach (var simulation in simulations)
|
||||
await UpgradeScoringModelAsync(simulation);
|
||||
}
|
||||
|
||||
private async Task<ExamSimulation> UpgradeScoringModelAsync(ExamSimulation simulation)
|
||||
{
|
||||
var needsDifficultySnapshot = simulation.Items.Any(item => item.QuestionDifficulty is < 1 or > 5);
|
||||
var needsScoreUpgrade = simulation.State == ExamSimulationStates.Completed &&
|
||||
simulation.ModelVersion != ExamSimulationScoring.ModelVersion;
|
||||
if (!needsDifficultySnapshot && !needsScoreUpgrade)
|
||||
return simulation;
|
||||
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
|
||||
SnapshotDifficulties(simulation, questionMap);
|
||||
if (simulation.State == ExamSimulationStates.Completed)
|
||||
{
|
||||
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
|
||||
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
|
||||
}
|
||||
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
await ReplaceAsync(simulation);
|
||||
return simulation;
|
||||
}
|
||||
|
||||
private static void SnapshotDifficulties(
|
||||
ExamSimulation simulation,
|
||||
IReadOnlyDictionary<string, Question> questions)
|
||||
{
|
||||
foreach (var item in simulation.Items)
|
||||
{
|
||||
questions.TryGetValue(item.QuestionExternalId, out var question);
|
||||
item.QuestionDifficulty = ExamSimulationScoring.ResolveDifficulty(item, question);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExamSimulationBackfillResult(int UpdatedCount, IReadOnlyList<string> MissingQuestionIds);
|
||||
|
||||
private static int BlockIndexForPosition(int position)
|
||||
{
|
||||
var consumed = 0;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class QuestionAttemptCycleMigrationCommand
|
||||
{
|
||||
public const string Confirmation = "MIGRATE_ATTEMPT_CYCLES";
|
||||
public const string LegacyIndexName = "QuestionExternalId_1_UserId_1";
|
||||
public const string CycleIndexName = "uq_question_user_cycle";
|
||||
|
||||
public static async Task<int> RunAsync(
|
||||
IConfiguration configuration, string[] args, TextWriter output)
|
||||
{
|
||||
var apply = args.Contains("--apply", StringComparer.Ordinal);
|
||||
var confirmation = Option(args, "--confirm");
|
||||
var mongo = new MongoDatabaseProvider(configuration);
|
||||
var attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
var missingFilter = new BsonDocumentFilterDefinition<QuestionAttempt>(
|
||||
new BsonDocument(nameof(QuestionAttempt.BankCycle),
|
||||
new BsonDocument("$exists", false)));
|
||||
var missing = await attempts.CountDocumentsAsync(missingFilter);
|
||||
var indexes = (await attempts.Indexes.ListAsync()).ToList();
|
||||
var names = indexes.Select(value => value["name"].AsString).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
await output.WriteLineAsync(
|
||||
$"QuestionAttempts missing BankCycle: {missing}; legacy index: {names.Contains(LegacyIndexName)}; cycle index: {names.Contains(CycleIndexName)}.");
|
||||
if (!apply)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
$"Dry run only. Re-run with --apply --confirm {Confirmation}.");
|
||||
return 0;
|
||||
}
|
||||
if (confirmation != Confirmation)
|
||||
{
|
||||
await output.WriteLineAsync($"Refusing migration: --confirm {Confirmation} is required.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (missing > 0)
|
||||
await attempts.UpdateManyAsync(
|
||||
missingFilter,
|
||||
Builders<QuestionAttempt>.Update.Set(value => value.BankCycle, 0));
|
||||
if (!names.Contains(CycleIndexName))
|
||||
await attempts.Indexes.CreateOneAsync(new CreateIndexModel<QuestionAttempt>(
|
||||
Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(value => value.QuestionExternalId)
|
||||
.Ascending(value => value.UserId)
|
||||
.Ascending(value => value.BankCycle),
|
||||
new CreateIndexOptions { Unique = true, Name = CycleIndexName }));
|
||||
if (names.Contains(LegacyIndexName))
|
||||
await attempts.Indexes.DropOneAsync(LegacyIndexName);
|
||||
|
||||
var remaining = await attempts.CountDocumentsAsync(missingFilter);
|
||||
await output.WriteLineAsync(
|
||||
$"Migration complete. Backfilled {missing}; missing BankCycle: {remaining}; dropped legacy index: {names.Contains(LegacyIndexName)}.");
|
||||
return remaining == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
private static string Option(string[] args, string name)
|
||||
{
|
||||
var index = Array.IndexOf(args, name);
|
||||
return index >= 0 && index + 1 < args.Length ? args[index + 1] : "";
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,11 @@ public class QuestionAttemptStore
|
||||
|
||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
.Ascending(a => a.UserId);
|
||||
.Ascending(a => a.UserId)
|
||||
.Ascending(a => a.BankCycle);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||
firstAttemptIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
|
||||
|
||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
@@ -38,12 +39,14 @@ public class QuestionAttemptStore
|
||||
bool isCorrect,
|
||||
bool isSkip = false,
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||
int durationSeconds = 0)
|
||||
int durationSeconds = 0,
|
||||
int bankCycle = 0)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
QuestionExternalId = questionExternalId,
|
||||
UserId = userId,
|
||||
BankCycle = bankCycle,
|
||||
ChoiceId = choiceId,
|
||||
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||
@@ -63,7 +66,8 @@ public class QuestionAttemptStore
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
var existing = await _col
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
|
||||
.Find(a => a.QuestionExternalId == questionExternalId &&
|
||||
a.UserId == userId && a.BankCycle == bankCycle)
|
||||
.FirstAsync();
|
||||
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
||||
}
|
||||
@@ -75,11 +79,17 @@ public class QuestionAttemptStore
|
||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null)
|
||||
{
|
||||
var filter = Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId);
|
||||
if (bankCycle.HasValue)
|
||||
filter &= Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value);
|
||||
return
|
||||
_col.Distinct(
|
||||
attempt => attempt.QuestionExternalId,
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
|
||||
filter)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 2v6.29a2 2 0 0 1-.5 1.32L3.9 17.8A2 2 0 0 0 5.4 21h13.2a2 2 0 0 0 1.5-3.2l-5.6-8.19A2 2 0 0 1 14 8.29V2" />
|
||||
<path d="M6.5 15h11" />
|
||||
<path d="M8.5 2h7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c2ud26rg3exo1"
|
||||
path="res://.godot/imported/flask-conical.svg-f4d9011dce33718bd3c2a0f719a8656a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/flask-conical.svg"
|
||||
dest_files=["res://.godot/imported/flask-conical.svg-f4d9011dce33718bd3c2a0f719a8656a.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 3h12l4 6-10 12L2 9Z" />
|
||||
<path d="M11 3 8 9l4 12 4-12-3-6" />
|
||||
<path d="M2 9h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://caty3viul0634"
|
||||
path="res://.godot/imported/gem.svg-33acfe3fd8cfb097f65cc061d34abd2e.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/gem.svg"
|
||||
dest_files=["res://.godot/imported/gem.svg-33acfe3fd8cfb097f65cc061d34abd2e.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cwt1kxtnhfgqo"
|
||||
path="res://.godot/imported/rotate-ccw.svg-72097dc869bb37466442f5645fc6d506.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/rotate-ccw.svg"
|
||||
dest_files=["res://.godot/imported/rotate-ccw.svg-72097dc869bb37466442f5645fc6d506.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://jwyvc10c44uu"
|
||||
path="res://.godot/imported/cabinet_frame.png-97e2b6084ba7b318c962ef3eba634aa6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/cabinet_frame.png"
|
||||
dest_files=["res://.godot/imported/cabinet_frame.png-97e2b6084ba7b318c962ef3eba634aa6.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cqrjyobyp5h45"
|
||||
path="res://.godot/imported/lever.png-08544c384e43fad84ca6f4c75fce0c0f.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/lever.png"
|
||||
dest_files=["res://.godot/imported/lever.png-08544c384e43fad84ca6f4c75fce0c0f.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bgp8erpmjoel7"
|
||||
path="res://.godot/imported/slot_coin.png-0cc970c575e374ae6106c943cfe43ee1.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_coin.png"
|
||||
dest_files=["res://.godot/imported/slot_coin.png-0cc970c575e374ae6106c943cfe43ee1.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bi1vl8p2q6bgn"
|
||||
path="res://.godot/imported/slot_coin_stack.png-f16acecc5a2cc4262b0bf33096e5a7d6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_coin_stack.png"
|
||||
dest_files=["res://.godot/imported/slot_coin_stack.png-f16acecc5a2cc4262b0bf33096e5a7d6.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dd00eb70k5wbn"
|
||||
path="res://.godot/imported/slot_gem.png-2012a867d096c962431a19f1d3e3a5c9.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_gem.png"
|
||||
dest_files=["res://.godot/imported/slot_gem.png-2012a867d096c962431a19f1d3e3a5c9.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dqmvdujjawr5f"
|
||||
path="res://.godot/imported/slot_gift.png-8717ecd0f5473181fb969b2aa00068db.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_gift.png"
|
||||
dest_files=["res://.godot/imported/slot_gift.png-8717ecd0f5473181fb969b2aa00068db.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c8r8uookc1ngx"
|
||||
path="res://.godot/imported/slot_potion.png-ecd877764f2c68367f1819752498d30a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_potion.png"
|
||||
dest_files=["res://.godot/imported/slot_potion.png-ecd877764f2c68367f1819752498d30a.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://fsejopj5yup5"
|
||||
path="res://.godot/imported/slot_shield.png-f53f74d032415edb239232a0bab0bef4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_shield.png"
|
||||
dest_files=["res://.godot/imported/slot_shield.png-f53f74d032415edb239232a0bab0bef4.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -256,18 +256,26 @@ func restore_simulation_responses(saved_responses: Array) -> void:
|
||||
"is_correct": false,
|
||||
"eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(),
|
||||
"flagged": bool(response.get("flagged", false)),
|
||||
"elapsed_seconds": int(response.get("elapsedSeconds", 0))
|
||||
"elapsed_seconds": int(response.get("elapsedSeconds", 0)),
|
||||
"is_submitted": _simulation_response_is_submitted(response)
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
func _simulation_response_is_submitted(response: Dictionary) -> bool:
|
||||
var submitted: Variant = response.get("isSubmitted", null)
|
||||
if submitted == null:
|
||||
return not str(response.get("choiceId", "")).is_empty()
|
||||
return bool(submitted)
|
||||
|
||||
|
||||
func get_current_question() -> Dictionary:
|
||||
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
|
||||
return {}
|
||||
return active_questions[current_index]
|
||||
|
||||
|
||||
func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
|
||||
func answer_current(choice_id: String, eliminated: Array, flagged: bool, is_submitted := false) -> Dictionary:
|
||||
var question := get_current_question()
|
||||
if question.is_empty():
|
||||
return {}
|
||||
@@ -284,6 +292,7 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dict
|
||||
answers[current_index] = answer
|
||||
if session_kind == "simulation":
|
||||
answer["is_correct"] = false
|
||||
answer["is_submitted"] = is_submitted
|
||||
answers[current_index] = answer
|
||||
session_changed.emit()
|
||||
return answer
|
||||
|
||||
@@ -2,16 +2,28 @@ extends Node
|
||||
|
||||
signal credits_changed
|
||||
signal credits_failed(message: String)
|
||||
signal store_operation_completed(message: String)
|
||||
signal spin_resolved(reward_key: String, reward_label: String, coins_won: int)
|
||||
signal spin_config_loaded
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
|
||||
|
||||
var credits: int = 0
|
||||
var exam_tokens: int = 0
|
||||
var qbank_reset_tokens: int = 0
|
||||
var bank_cycle: int = 0
|
||||
var products: Array = []
|
||||
var spin_cost := 50
|
||||
var spin_rewards: Array = []
|
||||
var loaded := false
|
||||
var loading := false
|
||||
var request: HTTPRequest
|
||||
var action_request: HTTPRequest
|
||||
var spin_config_request: HTTPRequest
|
||||
var request_token := ""
|
||||
var api_url := DEFAULT_URL
|
||||
var pending_store_action := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -22,6 +34,14 @@ func _ready() -> void:
|
||||
request.timeout = 20.0
|
||||
request.request_completed.connect(_on_completed)
|
||||
add_child(request)
|
||||
action_request = HTTPRequest.new()
|
||||
action_request.timeout = 20.0
|
||||
action_request.request_completed.connect(_on_action_completed)
|
||||
add_child(action_request)
|
||||
spin_config_request = HTTPRequest.new()
|
||||
spin_config_request.timeout = 20.0
|
||||
spin_config_request.request_completed.connect(_on_spin_config_completed)
|
||||
add_child(spin_config_request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
|
||||
|
||||
@@ -39,6 +59,10 @@ func refresh() -> void:
|
||||
|
||||
func apply_status(parsed: Dictionary) -> void:
|
||||
credits = int(parsed.get("credits", credits))
|
||||
exam_tokens = int(parsed.get("examTokens", exam_tokens))
|
||||
qbank_reset_tokens = int(parsed.get("qBankResetTokens", qbank_reset_tokens))
|
||||
bank_cycle = int(parsed.get("bankCycle", bank_cycle))
|
||||
products = parsed.get("products", products)
|
||||
loaded = true
|
||||
credits_changed.emit()
|
||||
|
||||
@@ -58,9 +82,97 @@ func _on_completed(result: int, response_code: int, _headers: PackedStringArray,
|
||||
apply_status(parsed)
|
||||
|
||||
|
||||
func purchase(sku: String) -> void:
|
||||
pending_store_action = "purchase"
|
||||
_post("/purchases", {"sku": sku, "operationId": _operation_id("purchase")})
|
||||
|
||||
|
||||
func reset_qbank() -> void:
|
||||
pending_store_action = "reset"
|
||||
_post("/qbank-reset", {"confirmation": "RESET_QBANK", "operationId": _operation_id("reset")})
|
||||
|
||||
|
||||
func spin() -> void:
|
||||
pending_store_action = "spin"
|
||||
_post("/spin", {"operationId": _operation_id("spin")})
|
||||
|
||||
|
||||
func fetch_spin_config() -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
|
||||
spin_config_request.request(api_url + "/spin-config", headers, HTTPClient.METHOD_GET)
|
||||
|
||||
|
||||
func _on_spin_config_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return
|
||||
spin_cost = int(parsed.get("spinCost", spin_cost))
|
||||
spin_rewards = parsed.get("rewards", spin_rewards)
|
||||
spin_config_loaded.emit()
|
||||
|
||||
|
||||
func _post(path: String, payload: Dictionary) -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
pending_store_action = ""
|
||||
credits_failed.emit("You're signed out. Sign back in and try again.")
|
||||
return
|
||||
var headers := PackedStringArray([
|
||||
"Authorization: Bearer %s" % AuthState.access_token,
|
||||
"Content-Type: application/json"
|
||||
])
|
||||
var error := action_request.request(api_url + path, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if error != OK:
|
||||
credits_failed.emit("The Rz Coin Store could not complete that action.")
|
||||
|
||||
|
||||
func _on_action_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
credits_failed.emit("The Rz Coin Store could not complete that action.")
|
||||
return
|
||||
if response_code >= 200 and response_code < 300 and typeof(parsed) == TYPE_DICTIONARY:
|
||||
if pending_store_action == "spin":
|
||||
var status: Variant = parsed.get("status", {})
|
||||
if typeof(status) == TYPE_DICTIONARY:
|
||||
apply_status(status)
|
||||
pending_store_action = ""
|
||||
spin_resolved.emit(
|
||||
str(parsed.get("rewardKey", "")),
|
||||
str(parsed.get("rewardLabel", "")),
|
||||
int(parsed.get("coinsWon", 0)))
|
||||
return
|
||||
apply_status(parsed)
|
||||
if pending_store_action == "reset":
|
||||
ProfileService.reset_qbank_eligibility()
|
||||
pending_store_action = ""
|
||||
store_operation_completed.emit("Your Rz Coin Store balance was updated.")
|
||||
return
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
var status: Variant = parsed.get("credits", {})
|
||||
if typeof(status) == TYPE_DICTIONARY:
|
||||
apply_status(status)
|
||||
credits_failed.emit(str(parsed.get("message", "The store action could not be completed.")))
|
||||
else:
|
||||
credits_failed.emit("The store action could not be completed.")
|
||||
pending_store_action = ""
|
||||
|
||||
|
||||
func _operation_id(prefix: String) -> String:
|
||||
return "%s:%s:%d" % [prefix, str(AuthState.username), Time.get_ticks_usec()]
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
credits = 0
|
||||
exam_tokens = 0
|
||||
qbank_reset_tokens = 0
|
||||
bank_cycle = 0
|
||||
products = []
|
||||
spin_rewards = []
|
||||
loaded = false
|
||||
loading = false
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
|
||||
@@ -8,6 +8,7 @@ signal state_changed
|
||||
signal request_failed(message: String)
|
||||
signal outcome_changed
|
||||
signal history_changed
|
||||
signal exam_token_required
|
||||
|
||||
var api_url := DEFAULT_API_URL
|
||||
var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes"
|
||||
@@ -22,6 +23,8 @@ var is_loading := false
|
||||
var pending_action := ""
|
||||
var request_queue: Array = []
|
||||
var request_access_token := ""
|
||||
var last_failed_action := ""
|
||||
var submit_recovery_attempts := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -67,7 +70,7 @@ func start_next_block() -> void:
|
||||
_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:
|
||||
func save_response(question_id: String, choice_id: String, eliminated: Array, flagged: bool, elapsed_seconds: int, is_submitted := false) -> void:
|
||||
if simulation.is_empty():
|
||||
return
|
||||
_send("save", HTTPClient.METHOD_PUT, "/%s/responses" % str(simulation.get("id", "")), {
|
||||
@@ -75,13 +78,15 @@ func save_response(question_id: String, choice_id: String, eliminated: Array, fl
|
||||
"choiceId": choice_id,
|
||||
"eliminatedChoiceIds": eliminated,
|
||||
"flagged": flagged,
|
||||
"elapsedSeconds": elapsed_seconds
|
||||
"elapsedSeconds": elapsed_seconds,
|
||||
"isSubmitted": is_submitted
|
||||
})
|
||||
|
||||
|
||||
func submit_block() -> void:
|
||||
if simulation.is_empty():
|
||||
return
|
||||
submit_recovery_attempts = 0
|
||||
_send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", "")))
|
||||
|
||||
|
||||
@@ -140,6 +145,7 @@ func _send_absolute(action: String, method: int, url: String, payload: Dictionar
|
||||
if err != OK:
|
||||
is_loading = false
|
||||
pending_action = ""
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation request could not be started.")
|
||||
|
||||
|
||||
@@ -151,12 +157,30 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_access_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
if _is_stale_submit_failure(action):
|
||||
return
|
||||
if _should_reconcile_submit_failure(action, response_code):
|
||||
submit_recovery_attempts += 1
|
||||
_send(
|
||||
"submit_reconcile",
|
||||
HTTPClient.METHOD_GET,
|
||||
"/%s" % str(simulation.get("id", "")).uri_encode())
|
||||
return
|
||||
var server_message := body.get_string_from_utf8().strip_edges()
|
||||
var error_parsed: Variant = JSON.parse_string(server_message)
|
||||
if typeof(error_parsed) == TYPE_DICTIONARY:
|
||||
var credit_status: Variant = error_parsed.get("credits", {})
|
||||
if typeof(credit_status) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_status(credit_status)
|
||||
if str(error_parsed.get("code", "")) == "exam_token_required":
|
||||
exam_token_required.emit()
|
||||
server_message = str(error_parsed.get("message", server_message))
|
||||
if server_message.length() > 240:
|
||||
server_message = server_message.left(240)
|
||||
var message := "Simulation request failed with status %d." % response_code
|
||||
if not server_message.is_empty():
|
||||
message = "%s %s" % [message, server_message]
|
||||
last_failed_action = action
|
||||
request_failed.emit(message)
|
||||
return
|
||||
if response_code == 204:
|
||||
@@ -169,6 +193,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation API returned an invalid response.")
|
||||
return
|
||||
if action == "readiness":
|
||||
@@ -190,6 +215,25 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
return
|
||||
simulation = _normalize_simulation(parsed)
|
||||
state_changed.emit()
|
||||
if action == "submit":
|
||||
submit_recovery_attempts = 0
|
||||
elif action == "submit_reconcile":
|
||||
if str(simulation.get("state", "")) == "block_active":
|
||||
_send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", "")))
|
||||
else:
|
||||
submit_recovery_attempts = 0
|
||||
|
||||
|
||||
func _is_stale_submit_failure(action: String) -> bool:
|
||||
return action == "submit" and str(simulation.get("state", "")) in ["between_blocks", "completed"]
|
||||
|
||||
|
||||
func _should_reconcile_submit_failure(action: String, response_code: int) -> bool:
|
||||
return (
|
||||
action == "submit" and
|
||||
response_code == 409 and
|
||||
submit_recovery_attempts == 0 and
|
||||
str(simulation.get("state", "")) == "block_active")
|
||||
|
||||
|
||||
func _process_request_queue() -> void:
|
||||
|
||||
@@ -130,6 +130,14 @@ func reset_qbank_memory() -> void:
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func reset_qbank_eligibility() -> void:
|
||||
_ensure_tracking_fields()
|
||||
profile["completed_question_ids"] = []
|
||||
profile["correct_question_ids"] = []
|
||||
save_profile()
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func apply_question_results(results: Dictionary) -> void:
|
||||
_ensure_daily_current()
|
||||
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
|
||||
|
||||
@@ -297,7 +297,7 @@ func _build_performance_summary(dark: bool) -> void:
|
||||
if combined != null:
|
||||
prediction_value = "%d" % int(combined)
|
||||
prediction_detail = "Range %d-%d" % [int(CCSService.prediction.get("rangeLow", 0)), int(CCSService.prediction.get("rangeHigh", 0))]
|
||||
_add_metric(row, "Combined Prediction", prediction_value, prediction_detail, dark)
|
||||
_add_metric(row, "Provisional Combined Estimate", prediction_value, prediction_detail, dark)
|
||||
|
||||
|
||||
func _add_metric(parent: BoxContainer, caption: String, value: String, detail: String, dark: bool) -> void:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
extends PanelContainer
|
||||
|
||||
signal store_requested
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg")
|
||||
const STAR_ICON := preload("res://assets/icons/lucide/star.svg")
|
||||
@@ -21,6 +22,9 @@ var _initialized := false
|
||||
|
||||
func _ready() -> void:
|
||||
size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
tooltip_text = "Open Rz Coin Store"
|
||||
gui_input.connect(_on_gui_input)
|
||||
_build_ui()
|
||||
refresh_theme_controls()
|
||||
_on_credits_changed()
|
||||
@@ -30,6 +34,11 @@ func _ready() -> void:
|
||||
resized.connect(func(): pivot_offset = size * 0.5)
|
||||
|
||||
|
||||
func _on_gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||
store_requested.emit()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 7)
|
||||
|
||||
@@ -94,7 +94,7 @@ func _build() -> void:
|
||||
readiness_segment_bar = DashboardSegmentBar.new()
|
||||
readiness_segment_bar.custom_minimum_size = Vector2(0, 10)
|
||||
_add_stat_card("readiness", "Exam Readiness", "chart-no-axes-column-increasing", Brand.PURPLE, readiness_segment_bar,
|
||||
"Predicted pass probability from your most recent completed exam simulation.")
|
||||
"Heuristic pass probability from your most recent completed exam simulation.")
|
||||
|
||||
accuracy_progress_bar = ProgressBar.new()
|
||||
accuracy_progress_bar.show_percentage = false
|
||||
@@ -254,8 +254,8 @@ func _render_readiness_card(data: Dictionary) -> void:
|
||||
var label := str(label_value)
|
||||
var pass_percent := int(data.get("examReadinessProjectedPassPercent", 0))
|
||||
var status_color := Brand.SUCCESS if label == "High" else (Brand.AMBER if label == "Medium" else Brand.MAGENTA)
|
||||
var caption := "You're on track!" if label != "Low" else "Keep practicing"
|
||||
_set_stat_card("readiness", label, "Projected pass: %d%%" % pass_percent, status_color, caption)
|
||||
var caption := "Provisional estimate" if label != "Low" else "Keep practicing"
|
||||
_set_stat_card("readiness", label, "Heuristic pass: %d%%" % pass_percent, status_color, caption)
|
||||
(metric_values.get("readiness") as Label).add_theme_color_override("font_color", status_color)
|
||||
readiness_segment_bar.set_progress(int(round(pass_percent / 100.0 * 4.0)), 4, status_color, dark)
|
||||
|
||||
|
||||
@@ -28,10 +28,7 @@ var sidebar_content: VBoxContainer
|
||||
var drawer_backdrop: ColorRect
|
||||
var drawer_open := false
|
||||
var menu_button: Button
|
||||
var account_button: Button
|
||||
var account_chevron_button: Button
|
||||
var credits_badge: PanelContainer
|
||||
var account_menu: PanelContainer
|
||||
var nav_buttons: Dictionary = {}
|
||||
var current_nav_key := "home"
|
||||
var compact_shell := false
|
||||
@@ -56,8 +53,6 @@ func _update_responsive_layout() -> void:
|
||||
_apply_screen_margins(current_screen_name)
|
||||
if feedback_button != null:
|
||||
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
|
||||
if account_chevron_button != null:
|
||||
account_chevron_button.visible = size.x >= 480.0
|
||||
if drawer_open and not compact_shell:
|
||||
_close_drawer()
|
||||
if app_menu != null:
|
||||
@@ -69,7 +64,6 @@ func _update_responsive_layout() -> void:
|
||||
|
||||
func show_screen(screen_name: String, payload: Variant = null) -> void:
|
||||
_close_app_menu()
|
||||
_close_account_menu()
|
||||
_close_drawer()
|
||||
for child in screens_host.get_children():
|
||||
if child.visible and child.has_method("on_hide"):
|
||||
@@ -188,25 +182,10 @@ func _build_ui() -> void:
|
||||
header.add_child(theme_button)
|
||||
_refresh_theme_button()
|
||||
|
||||
account_button = Button.new()
|
||||
account_button.tooltip_text = "Open account menu"
|
||||
account_button.custom_minimum_size = Vector2(44, 44)
|
||||
account_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
account_button.pressed.connect(_toggle_account_menu)
|
||||
_style_account_avatar(account_button)
|
||||
header.add_child(account_button)
|
||||
|
||||
account_chevron_button = Button.new()
|
||||
account_chevron_button.icon = preload("res://assets/icons/lucide/chevron-down.svg")
|
||||
account_chevron_button.tooltip_text = "Open account menu"
|
||||
account_chevron_button.custom_minimum_size = Vector2(28, 44)
|
||||
account_chevron_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
account_chevron_button.add_theme_constant_override("icon_max_width", 17)
|
||||
account_chevron_button.pressed.connect(_toggle_account_menu)
|
||||
_style_account_chevron(account_chevron_button)
|
||||
header.add_child(account_chevron_button)
|
||||
|
||||
credits_badge = CreditsBadge.new()
|
||||
credits_badge.store_requested.connect(func():
|
||||
if Brand.is_uat_environment():
|
||||
show_screen("rz_store"))
|
||||
header.add_child(credits_badge)
|
||||
|
||||
var screens_container := MarginContainer.new()
|
||||
@@ -355,6 +334,11 @@ func _build_ui() -> void:
|
||||
donations.main = self
|
||||
screens_container.add_child(donations)
|
||||
screens["donations"] = donations
|
||||
var rz_store := preload("res://scripts/ui/rz_store_screen.gd").new()
|
||||
rz_store.name = "RzCoinStore"
|
||||
rz_store.main = self
|
||||
screens_container.add_child(rz_store)
|
||||
screens["rz_store"] = rz_store
|
||||
|
||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||
feedback_dialog.name = "AppFeedbackDialog"
|
||||
@@ -369,7 +353,6 @@ func _build_ui() -> void:
|
||||
legal_document_dialog = preload("res://scripts/ui/legal_document_dialog.gd").new()
|
||||
legal_document_dialog.name = "LegalDocumentDialog"
|
||||
add_child(legal_document_dialog)
|
||||
_build_account_menu()
|
||||
_build_drawer()
|
||||
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
@@ -433,6 +416,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
||||
container.add_child(HSeparator.new())
|
||||
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
|
||||
_add_nav_button(container, "performance", "Profile", "user-round", true)
|
||||
_add_nav_button(container, "rz_store", "RZ Shop", "star", true, Brand.is_uat_environment())
|
||||
_add_nav_button(container, "settings", "Settings", "settings", true)
|
||||
_add_nav_button(container, "logout", "Log out", "log-out", true)
|
||||
|
||||
@@ -476,6 +460,7 @@ func _on_navigation_pressed(key: String) -> void:
|
||||
"notes": show_screen("notes")
|
||||
"bookmarks": show_screen("bookmarks")
|
||||
"donations": show_screen("donations")
|
||||
"rz_store": show_screen("rz_store")
|
||||
"logout": _logout()
|
||||
|
||||
|
||||
@@ -487,6 +472,7 @@ func _update_header_title(screen_name: String) -> void:
|
||||
"selection": "Question Bank",
|
||||
"dynamic_questions": "Dynamic Questions",
|
||||
"simulation_history": "Exam Simulations",
|
||||
"simulation_results": "Exam Simulations",
|
||||
"ccs_hub": "CCS Cases",
|
||||
"flashcards_hub": "Flashcards",
|
||||
"flashcards_study": "Flashcards",
|
||||
@@ -499,27 +485,6 @@ func _update_header_title(screen_name: String) -> void:
|
||||
header_title.text = str(titles.get(screen_name, ""))
|
||||
|
||||
|
||||
func _build_account_menu() -> void:
|
||||
account_menu = PanelContainer.new()
|
||||
account_menu.visible = false
|
||||
account_menu.top_level = true
|
||||
account_menu.custom_minimum_size = Vector2(230, 230)
|
||||
account_menu.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
add_child(account_menu)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 8)
|
||||
account_menu.add_child(content)
|
||||
for spec in [["Profile", "user-round", "performance"], ["Settings", "settings", "settings"], ["Log out", "log-out", "logout"]]:
|
||||
var button := Button.new()
|
||||
button.text = spec[0]
|
||||
button.icon = load("res://assets/icons/lucide/%s.svg" % spec[1])
|
||||
button.add_theme_constant_override("icon_max_width", 22)
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.custom_minimum_size = Vector2(0, 46)
|
||||
button.pressed.connect(_on_navigation_pressed.bind(spec[2]))
|
||||
content.add_child(button)
|
||||
|
||||
|
||||
func _build_drawer() -> void:
|
||||
drawer_backdrop = ColorRect.new()
|
||||
drawer_backdrop.visible = false
|
||||
@@ -561,25 +526,6 @@ func _on_drawer_backdrop_input(event: InputEvent) -> void:
|
||||
_close_drawer()
|
||||
|
||||
|
||||
func _toggle_account_menu() -> void:
|
||||
if account_menu == null:
|
||||
return
|
||||
if account_menu.visible:
|
||||
_close_account_menu()
|
||||
return
|
||||
account_menu.visible = true
|
||||
account_menu.size = account_menu.custom_minimum_size
|
||||
account_menu.position = Vector2(
|
||||
get_viewport_rect().size.x - account_menu.size.x - 24,
|
||||
82
|
||||
)
|
||||
|
||||
|
||||
func _close_account_menu() -> void:
|
||||
if account_menu != null:
|
||||
account_menu.visible = false
|
||||
|
||||
|
||||
func _screen_uses_shell(screen_name: String) -> bool:
|
||||
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player"]
|
||||
|
||||
@@ -671,32 +617,6 @@ func _style_header_feedback_button(button: Button) -> void:
|
||||
button.add_theme_color_override("icon_hover_color", Brand.SKY_BLUE)
|
||||
|
||||
|
||||
func _style_account_avatar(button: Button) -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var normal := StyleBoxFlat.new()
|
||||
normal.bg_color = Color("26324f") if dark else Color("dce5f5")
|
||||
normal.corner_radius_top_left = 22
|
||||
normal.corner_radius_top_right = 22
|
||||
normal.corner_radius_bottom_left = 22
|
||||
normal.corner_radius_bottom_right = 22
|
||||
var hover := normal.duplicate() as StyleBoxFlat
|
||||
hover.bg_color = Color("344465") if dark else Color("cad7ed")
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", hover)
|
||||
button.add_theme_color_override("font_color", Color("f8fafc") if dark else Color("172033"))
|
||||
button.add_theme_color_override("font_hover_color", Color("ffffff") if dark else Color("0f172a"))
|
||||
|
||||
|
||||
func _style_account_chevron(button: Button) -> void:
|
||||
var empty := StyleBoxEmpty.new()
|
||||
button.add_theme_stylebox_override("normal", empty)
|
||||
button.add_theme_stylebox_override("hover", empty)
|
||||
button.add_theme_stylebox_override("pressed", empty)
|
||||
button.add_theme_color_override("icon_normal_color", Style.MUTED)
|
||||
button.add_theme_color_override("icon_hover_color", Style.TEXT)
|
||||
|
||||
|
||||
func _style_shell_theme_button(button: Button) -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var normal := StyleBoxFlat.new()
|
||||
@@ -728,36 +648,18 @@ func _input(event: InputEvent) -> void:
|
||||
_close_drawer()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if account_menu != null and account_menu.visible:
|
||||
_close_account_menu()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if current_screen_name in ["player", "ccs_player", "flashcards_study"]:
|
||||
_toggle_app_menu()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _refresh_header_controls() -> void:
|
||||
if account_button != null:
|
||||
account_button.text = _account_initials(AuthState.username)
|
||||
if feedback_button != null:
|
||||
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
|
||||
feedback_button.icon = preload("res://assets/icons/lucide/message-square.svg")
|
||||
feedback_button.add_theme_constant_override("icon_max_width", 21)
|
||||
|
||||
|
||||
func _account_initials(username: String) -> String:
|
||||
var cleaned := ""
|
||||
for character in username:
|
||||
if character.to_lower() != character.to_upper():
|
||||
cleaned += character
|
||||
elif character.is_valid_int():
|
||||
cleaned += character
|
||||
if cleaned.length() >= 2:
|
||||
break
|
||||
return cleaned.to_upper() if not cleaned.is_empty() else "ME"
|
||||
|
||||
|
||||
func open_feedback_dialog() -> void:
|
||||
if not AuthState.is_logged_in or feedback_dialog == null:
|
||||
return
|
||||
@@ -795,14 +697,8 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
|
||||
if app_background != null:
|
||||
app_background.color = Brand.background_color(is_dark_mode)
|
||||
app_background.material = Brand.background_material(is_dark_mode)
|
||||
if account_menu != null:
|
||||
account_menu.add_theme_stylebox_override("panel", Brand.panel_box(is_dark_mode))
|
||||
if feedback_button != null:
|
||||
_style_header_feedback_button(feedback_button)
|
||||
if account_button != null:
|
||||
_style_account_avatar(account_button)
|
||||
if account_chevron_button != null:
|
||||
_style_account_chevron(account_chevron_button)
|
||||
if credits_badge != null:
|
||||
credits_badge.refresh_theme_controls()
|
||||
if sidebar != null:
|
||||
@@ -960,15 +856,8 @@ func exit_current_run() -> void:
|
||||
if current_screen_name == "flashcards_study":
|
||||
show_screen("flashcards_hub")
|
||||
return
|
||||
var was_core_run := BlockSessionService.session_kind == "core"
|
||||
var was_review_run := BlockSessionService.session_kind == "review"
|
||||
var was_dynamic_run := BlockSessionService.session_kind == "dynamic"
|
||||
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
|
||||
BlockSessionService.finish_block()
|
||||
elif BlockSessionService.session_kind == "simulation":
|
||||
BlockSessionService.block_started = false
|
||||
if was_review_run or was_dynamic_run:
|
||||
DashboardService.refresh()
|
||||
show_screen("dashboard")
|
||||
else:
|
||||
show_screen("selection", "exam" if was_core_run else "step3")
|
||||
show_screen("dashboard")
|
||||
|
||||
@@ -29,4 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
||||
key = "bookmarks"
|
||||
elif screen_name == "donations":
|
||||
key = "donations"
|
||||
elif screen_name == "rz_store":
|
||||
key = "rz_store"
|
||||
return key
|
||||
|
||||
@@ -4,6 +4,7 @@ const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
||||
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
|
||||
const SlotMachinePanel := preload("res://scripts/ui/rz_slot_machine_screen.gd")
|
||||
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
|
||||
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
|
||||
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
|
||||
@@ -60,6 +61,8 @@ var stem_plain_text := ""
|
||||
var explanation_plain_text := ""
|
||||
var highlight_drag_source := ""
|
||||
var timer_color_state := ""
|
||||
var last_timer_bar_value := -1.0
|
||||
var last_timer_display_seconds := -1
|
||||
var pending_correct_advance := false
|
||||
var question_text_scale := 1.0
|
||||
var presented_choices: Array = []
|
||||
@@ -92,6 +95,7 @@ var text_smaller_button: Button
|
||||
var text_larger_button: Button
|
||||
var labs_button: Button
|
||||
var calculator_button: Button
|
||||
var slot_machine_button: Button
|
||||
var exit_button: Button
|
||||
var clear_highlight_button: Button
|
||||
var flag_button: Button
|
||||
@@ -125,6 +129,7 @@ var labs_overlay: Control
|
||||
var labs_panel: PanelContainer
|
||||
var labs_content: VBoxContainer
|
||||
var calculator_panel
|
||||
var slot_machine_panel
|
||||
var report_overlay: Control
|
||||
var report_panel: PanelContainer
|
||||
var report_form_content: VBoxContainer
|
||||
@@ -345,6 +350,18 @@ func _ready() -> void:
|
||||
UISoundService.set_button_sound(calculator_button, "open")
|
||||
calculator_button.pressed.connect(_toggle_calculator)
|
||||
|
||||
slot_machine_button = Button.new()
|
||||
slot_machine_button.name = "SlotMachineButton"
|
||||
slot_machine_button.tooltip_text = "Open RZ Slot Machine"
|
||||
slot_machine_button.icon = preload("res://assets/icons/lucide/gem.svg")
|
||||
slot_machine_button.custom_minimum_size = Vector2(44, 44)
|
||||
slot_machine_button.add_theme_constant_override("icon_max_width", 22)
|
||||
slot_machine_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
slot_machine_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
slot_machine_button.focus_mode = Control.FOCUS_NONE
|
||||
UISoundService.set_button_sound(slot_machine_button, "open")
|
||||
slot_machine_button.pressed.connect(_toggle_slot_machine)
|
||||
|
||||
labs_button = Button.new()
|
||||
labs_button.tooltip_text = "Review lab results"
|
||||
labs_button.custom_minimum_size = Vector2(44, 44)
|
||||
@@ -430,6 +447,7 @@ func _ready() -> void:
|
||||
question_toolbar.add_child(text_smaller_button)
|
||||
question_toolbar.add_child(text_larger_button)
|
||||
question_toolbar.add_child(calculator_button)
|
||||
question_toolbar.add_child(slot_machine_button)
|
||||
question_toolbar.add_child(labs_button)
|
||||
question_toolbar.add_child(clear_highlight_button)
|
||||
question_toolbar.add_child(flag_button)
|
||||
@@ -544,7 +562,10 @@ func _ready() -> void:
|
||||
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = Vector2(0, 68)
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
button.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||||
Style.style_answer_button(button)
|
||||
UISoundService.set_button_sound(button, "select")
|
||||
_set_scaled_button_font(button, 17)
|
||||
@@ -654,6 +675,7 @@ func _ready() -> void:
|
||||
|
||||
actions = HBoxContainer.new()
|
||||
actions.add_theme_constant_override("separation", 16)
|
||||
actions.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
card_content.add_child(actions)
|
||||
|
||||
skip_button = Button.new()
|
||||
@@ -683,10 +705,6 @@ func _ready() -> void:
|
||||
bottom_progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
bottom_progress_group.add_child(bottom_progress_bar)
|
||||
|
||||
# The timer readout still runs (BlockSessionService pacing/scoring depends on
|
||||
# it, and _update_timer_bar keeps its text current), it's just no longer
|
||||
# parented into the bottom bar - the caption + digits and the "Ctrl+Enter"
|
||||
# hint were clutter next to the Submit button.
|
||||
timer_caption_label = Label.new()
|
||||
timer_caption_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
_set_scaled_label(timer_caption_label, 12, Brand.SLATE)
|
||||
@@ -695,6 +713,7 @@ func _ready() -> void:
|
||||
next_button.text = "Submit Block" if BlockSessionService.session_kind == "simulation" and BlockSessionService.current_index + 1 == BlockSessionService.active_questions.size() else "Next" if BlockSessionService.session_kind == "simulation" else "Submit"
|
||||
next_button.tooltip_text = ""
|
||||
next_button.custom_minimum_size = Vector2(140, 48)
|
||||
next_button.size_flags_horizontal = Control.SIZE_SHRINK_END
|
||||
Style.style_primary_button(next_button)
|
||||
next_button.pressed.connect(_submit_or_next)
|
||||
actions.add_child(next_button)
|
||||
@@ -702,6 +721,8 @@ func _ready() -> void:
|
||||
timer_footer = HBoxContainer.new()
|
||||
timer_footer.add_theme_constant_override("separation", 12)
|
||||
card_content.add_child(timer_footer)
|
||||
timer_footer.add_child(timer_caption_label)
|
||||
timer_footer.add_child(timer_label)
|
||||
timer_bar = ProgressBar.new()
|
||||
timer_bar.show_percentage = false
|
||||
timer_bar.min_value = 0.0
|
||||
@@ -723,6 +744,9 @@ func _ready() -> void:
|
||||
_build_labs_modal()
|
||||
calculator_panel = CalculatorPanel.new()
|
||||
add_child(calculator_panel)
|
||||
slot_machine_panel = SlotMachinePanel.new()
|
||||
slot_machine_panel.main = main
|
||||
add_child(slot_machine_panel)
|
||||
_build_report_modal()
|
||||
_build_media_viewer()
|
||||
resized.connect(_fit_modal_panels)
|
||||
@@ -808,10 +832,10 @@ func _refresh_responsive_layout() -> void:
|
||||
header_toolbar_host.reparent(target_parent)
|
||||
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
|
||||
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
# 12 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
|
||||
# rank/ELO + points badge, with 8px gaps between all 14 - anything less
|
||||
# 13 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
|
||||
# rank/ELO + points badge, with 8px gaps between all 15 - anything less
|
||||
# than this wraps onto a second row.
|
||||
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 960.0
|
||||
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 1020.0
|
||||
if size.x < 560.0:
|
||||
nav_palette.columns = 5
|
||||
else:
|
||||
@@ -1067,7 +1091,7 @@ func _apply_question_theme() -> void:
|
||||
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
||||
if navigator_heading != null:
|
||||
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
||||
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, slot_machine_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
||||
if control == null:
|
||||
continue
|
||||
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
|
||||
@@ -1083,6 +1107,8 @@ func _apply_question_theme() -> void:
|
||||
_style_theme_toggle_button(dark)
|
||||
if calculator_panel != null:
|
||||
calculator_panel.refresh_theme_controls()
|
||||
if slot_machine_panel != null:
|
||||
slot_machine_panel.refresh_theme_controls()
|
||||
if credits_badge != null:
|
||||
credits_badge.refresh_theme_controls()
|
||||
if skip_button != null:
|
||||
@@ -1133,21 +1159,26 @@ func on_hide() -> void:
|
||||
if calculator_panel != null:
|
||||
calculator_panel.close_panel()
|
||||
calculator_panel.reset_calculation()
|
||||
if slot_machine_panel != null:
|
||||
slot_machine_panel.close_panel()
|
||||
|
||||
|
||||
func _save_simulation_current_response() -> void:
|
||||
func _save_simulation_current_response(is_submitted := false) -> void:
|
||||
if BlockSessionService.session_kind != "simulation":
|
||||
return
|
||||
if bool(current_answer.get("is_submitted", false)) and not is_submitted:
|
||||
return
|
||||
var question := BlockSessionService.get_current_question()
|
||||
if question.is_empty():
|
||||
return
|
||||
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
||||
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged, is_submitted)
|
||||
ExamSimulationService.save_response(
|
||||
str(question.get("id", "")),
|
||||
selected_choice,
|
||||
eliminated,
|
||||
flagged,
|
||||
int(BlockSessionService.get_current_elapsed_seconds()))
|
||||
int(BlockSessionService.get_current_elapsed_seconds()),
|
||||
is_submitted)
|
||||
|
||||
|
||||
func _on_simulation_state_changed() -> void:
|
||||
@@ -1167,7 +1198,15 @@ func _on_simulation_state_changed() -> void:
|
||||
func _on_simulation_request_failed(message: String) -> void:
|
||||
if BlockSessionService.session_kind != "simulation":
|
||||
return
|
||||
if str(ExamSimulationService.simulation.get("state", "")) in ["between_blocks", "completed"]:
|
||||
simulation_submit_pending = false
|
||||
return
|
||||
simulation_submit_pending = false
|
||||
if ExamSimulationService.last_failed_action != "submit":
|
||||
# A per-question autosave failing shouldn't interrupt a timed exam with a
|
||||
# blocking "Retry" - the next answer/navigation naturally retries it, and
|
||||
# the final submit sends the complete, authoritative state regardless.
|
||||
return
|
||||
next_button.disabled = false
|
||||
next_button.text = "Retry"
|
||||
next_button.tooltip_text = message
|
||||
@@ -1268,6 +1307,8 @@ func _load_question() -> void:
|
||||
flagged = false
|
||||
selected_choice = ""
|
||||
current_answer = {}
|
||||
last_timer_bar_value = -1.0
|
||||
last_timer_display_seconds = -1
|
||||
flag_button.button_pressed = false
|
||||
_refresh_toolbar_state()
|
||||
_clear_answer_review()
|
||||
@@ -1550,7 +1591,7 @@ func _submit_or_next() -> void:
|
||||
if BlockSessionService.session_kind == "simulation":
|
||||
if selected_choice == "":
|
||||
return
|
||||
_save_simulation_current_response()
|
||||
_save_simulation_current_response(true)
|
||||
var pending_index := _next_pending_simulation_index()
|
||||
if pending_index >= 0:
|
||||
BlockSessionService.move_to(pending_index)
|
||||
@@ -2094,7 +2135,10 @@ func _update_timer_bar() -> void:
|
||||
return
|
||||
var is_core_timer := BlockSessionService.session_kind == "core"
|
||||
var ratio := BlockSessionService.get_session_timer_ratio()
|
||||
timer_bar.value = ratio * 100.0
|
||||
var next_value := ratio * 100.0
|
||||
if last_timer_bar_value < 0.0 or absf(next_value - last_timer_bar_value) >= 0.1:
|
||||
last_timer_bar_value = next_value
|
||||
timer_bar.value = next_value
|
||||
if is_core_timer and timer_label.visible:
|
||||
_set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())))
|
||||
var color_state := "blue"
|
||||
@@ -2114,6 +2158,9 @@ func _set_timer_display(total_seconds: int) -> void:
|
||||
if timer_label == null:
|
||||
return
|
||||
var safe_seconds := maxi(0, total_seconds)
|
||||
if safe_seconds == last_timer_display_seconds:
|
||||
return
|
||||
last_timer_display_seconds = safe_seconds
|
||||
timer_label.text = "%02d:%02d" % [int(floor(float(safe_seconds) / 60.0)), safe_seconds % 60]
|
||||
|
||||
|
||||
@@ -2373,6 +2420,10 @@ func _refresh_toolbar_state() -> void:
|
||||
bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question")
|
||||
if calculator_button != null:
|
||||
calculator_button.visible = true
|
||||
if slot_machine_button != null:
|
||||
slot_machine_button.visible = Brand.is_uat_environment()
|
||||
if credits_badge != null:
|
||||
credits_badge.visible = BlockSessionService.session_kind != "simulation"
|
||||
_apply_question_theme()
|
||||
|
||||
|
||||
@@ -2381,6 +2432,11 @@ func _toggle_calculator() -> void:
|
||||
calculator_panel.toggle_panel()
|
||||
|
||||
|
||||
func _toggle_slot_machine() -> void:
|
||||
if slot_machine_panel != null:
|
||||
slot_machine_panel.toggle_panel()
|
||||
|
||||
|
||||
func _toggle_bookmark_current_question() -> void:
|
||||
var question := BlockSessionService.get_current_question()
|
||||
if not question.is_empty():
|
||||
@@ -3338,7 +3394,9 @@ func _apply_saved_choice_state() -> void:
|
||||
|
||||
|
||||
func _is_correct_answer_locked() -> bool:
|
||||
return bool(current_answer.get("is_correct", false))
|
||||
return bool(current_answer.get("is_correct", false)) or (
|
||||
BlockSessionService.session_kind == "simulation" and
|
||||
bool(current_answer.get("is_submitted", false)))
|
||||
|
||||
|
||||
func _refresh_submit_state() -> void:
|
||||
@@ -3383,7 +3441,9 @@ func _style_choice_button_for_state(index: int, choice_id: String) -> void:
|
||||
var dark: bool = true if theme_service == null else theme_service.is_dark_mode()
|
||||
var state := "default"
|
||||
var correct_id := str(BlockSessionService.get_current_question().get("correct_choice_id", ""))
|
||||
var reviewing := pending_correct_advance or not current_answer.is_empty()
|
||||
# Exam simulations don't reveal right/wrong - you move on after submitting,
|
||||
# same as a real exam - so only "selected"/"eliminated" styling applies here.
|
||||
var reviewing := BlockSessionService.session_kind != "simulation" and (pending_correct_advance or not current_answer.is_empty())
|
||||
if reviewing and choice_id == correct_id:
|
||||
state = "correct"
|
||||
elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id != "" and choice_id != correct_id):
|
||||
@@ -3505,6 +3565,7 @@ func _build_followup_panel(_index: int, choice_id: String) -> void:
|
||||
"selected_option_text": "",
|
||||
"was_correct": false,
|
||||
"resolved": false,
|
||||
"collapsed": false,
|
||||
"has_authored_choices": bool(followup.get("has_authored_choices", false)),
|
||||
"is_main_correct": false
|
||||
}
|
||||
@@ -3576,6 +3637,7 @@ func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_se
|
||||
state["selected_option_text"] = selected_option_text
|
||||
state["was_correct"] = is_correct
|
||||
state["resolved"] = true
|
||||
state["collapsed"] = false
|
||||
followup_states[resolved_choice_id] = state
|
||||
_render_followup_state(resolved_choice_id)
|
||||
|
||||
@@ -3602,6 +3664,8 @@ func _render_followup_state(choice_id: String) -> void:
|
||||
_style_followup_card(index, state)
|
||||
if not bool(state.get("resolved", false)):
|
||||
_render_active_followup(index, state)
|
||||
elif bool(state.get("collapsed", false)):
|
||||
_build_resolved_followup_toggle(index, state, false)
|
||||
else:
|
||||
_build_resolved_followup(index, state)
|
||||
choice_followup_panels[index].visible = true
|
||||
@@ -3610,13 +3674,95 @@ func _render_followup_state(choice_id: String) -> void:
|
||||
|
||||
func _build_resolved_followup(index: int, state: Dictionary) -> void:
|
||||
var content := choice_followup_contents[index]
|
||||
_build_resolved_followup_toggle(index, state, true)
|
||||
|
||||
var prompt := Label.new()
|
||||
prompt.name = "FollowupPrompt"
|
||||
prompt.text = str(state.get("prompt", "What finding in the stem makes this answer less appropriate?"))
|
||||
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_set_scaled_label(prompt, 14, Brand.text_color(_followup_is_dark()))
|
||||
content.add_child(prompt)
|
||||
|
||||
var selected_id := str(state.get("selected_option_id", ""))
|
||||
var correct_id := str(state.get("correct_choice_id", ""))
|
||||
var options: Array = state.get("choices", [])
|
||||
for option_index in range(options.size()):
|
||||
var option = options[option_index]
|
||||
if typeof(option) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var option_id := str(option.get("id", ""))
|
||||
var option_button := Button.new()
|
||||
option_button.name = "FollowupOption_%s" % option_id
|
||||
option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))]
|
||||
option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
option_button.disabled = true
|
||||
option_button.custom_minimum_size = Vector2(0, 38)
|
||||
_set_scaled_button_font(option_button, 14)
|
||||
_style_review_followup_option(
|
||||
option_button,
|
||||
_followup_is_dark(),
|
||||
option_id,
|
||||
selected_id,
|
||||
correct_id,
|
||||
true
|
||||
)
|
||||
content.add_child(option_button)
|
||||
|
||||
var explanation := Label.new()
|
||||
explanation.name = "FollowupExplanation"
|
||||
explanation.text = _strip_verdict_prefix(_choice_review_explanation(str(state.get("choice_id", "")), state))
|
||||
explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark()))
|
||||
content.add_child(explanation)
|
||||
|
||||
|
||||
func _build_resolved_followup_toggle(index: int, state: Dictionary, expanded: bool) -> void:
|
||||
var content := choice_followup_contents[index]
|
||||
var dark := _followup_is_dark()
|
||||
var has_authored_choices := bool(state.get("has_authored_choices", not state.get("choices", []).is_empty()))
|
||||
var was_correct := bool(state.get("was_correct", false))
|
||||
var accent := Brand.SUCCESS if was_correct else Color("fb5f6d") if has_authored_choices else Brand.PURPLE
|
||||
var row := HBoxContainer.new()
|
||||
row.name = "FollowupResult"
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
content.add_child(row)
|
||||
|
||||
var result := Label.new()
|
||||
result.name = "FollowupVerdict"
|
||||
result.text = ("Correct" if was_correct else "Incorrect") if has_authored_choices else "Review complete"
|
||||
result.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_set_scaled_label(result, 14, accent)
|
||||
row.add_child(result)
|
||||
|
||||
var toggle := Button.new()
|
||||
toggle.name = "FollowupToggle"
|
||||
toggle.flat = true
|
||||
toggle.text = "Minimize" if expanded else "Expand"
|
||||
toggle.tooltip_text = "Minimize follow-up review" if expanded else "Expand follow-up review"
|
||||
toggle.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg")
|
||||
toggle.icon_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
toggle.custom_minimum_size = Vector2(110, 30)
|
||||
toggle.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
toggle.add_theme_color_override("font_hover_color", accent)
|
||||
toggle.add_theme_color_override("icon_normal_color", Brand.text_color(dark))
|
||||
toggle.add_theme_color_override("icon_hover_color", accent)
|
||||
_set_scaled_button_font(toggle, 13)
|
||||
toggle.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", ""))))
|
||||
row.add_child(toggle)
|
||||
|
||||
|
||||
func _toggle_followup_state(choice_id: String) -> void:
|
||||
if not followup_states.has(choice_id):
|
||||
return
|
||||
var state: Dictionary = followup_states[choice_id]
|
||||
if not bool(state.get("resolved", false)):
|
||||
return
|
||||
state["collapsed"] = not bool(state.get("collapsed", false))
|
||||
followup_states[choice_id] = state
|
||||
_render_followup_state(choice_id)
|
||||
_apply_saved_choice_state()
|
||||
|
||||
|
||||
func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContainer:
|
||||
var body := PanelContainer.new()
|
||||
body.add_theme_stylebox_override("panel", _followup_body_box(_followup_is_dark(), is_correct))
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
extends PanelContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const CABINET_FRAME := preload("res://assets/slot_machine/cabinet_frame.png")
|
||||
const LEVER_TEXTURE := preload("res://assets/slot_machine/lever.png")
|
||||
const SLOT_COIN := preload("res://assets/slot_machine/slot_coin.png")
|
||||
const SLOT_GEM := preload("res://assets/slot_machine/slot_gem.png")
|
||||
const SLOT_POTION := preload("res://assets/slot_machine/slot_potion.png")
|
||||
const SLOT_SHIELD := preload("res://assets/slot_machine/slot_shield.png")
|
||||
const SLOT_COIN_STACK := preload("res://assets/slot_machine/slot_coin_stack.png")
|
||||
const SLOT_GIFT := preload("res://assets/slot_machine/slot_gift.png")
|
||||
const CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg")
|
||||
const REEL_SYMBOLS := [SLOT_COIN, SLOT_GEM, SLOT_POTION, SLOT_SHIELD, SLOT_COIN_STACK, SLOT_GIFT]
|
||||
# fraction-of-image rect of the cabinet's empty reel window, measured against cabinet_frame.png
|
||||
const WINDOW_RECT := Rect2(0.203, 0.246, 0.591, 0.604)
|
||||
const REWARD_ICONS := {
|
||||
"jackpot": {"texture": SLOT_COIN, "modulate": false},
|
||||
"epic": {"texture": SLOT_GEM, "modulate": false},
|
||||
"great": {"texture": SLOT_SHIELD, "modulate": false},
|
||||
"exam_token": {"texture": CLOCK_ICON, "modulate": true},
|
||||
"qbank_reset": {"texture": SLOT_GIFT, "modulate": false},
|
||||
"nice": {"texture": SLOT_COIN_STACK, "modulate": false},
|
||||
"good_luck": {"texture": SLOT_COIN, "modulate": false, "tint": true},
|
||||
}
|
||||
const MACHINE_SIZE := 380.0
|
||||
const CELL := 71.0
|
||||
const REEL_SEPARATION := 4
|
||||
|
||||
var main: Control
|
||||
|
||||
var drag_active := false
|
||||
var drag_offset := Vector2.ZERO
|
||||
var has_been_positioned := false
|
||||
|
||||
var machine_root: Control
|
||||
var lever_button: TextureButton
|
||||
var reel_states: Array = []
|
||||
var status_label: Label
|
||||
|
||||
var _spinning := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "RzSlotMachinePanel"
|
||||
visible = false
|
||||
top_level = true
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
custom_minimum_size = Vector2(440, 470)
|
||||
size = custom_minimum_size
|
||||
|
||||
_build_ui()
|
||||
|
||||
if not get_viewport().size_changed.is_connected(_on_viewport_size_changed):
|
||||
get_viewport().size_changed.connect(_on_viewport_size_changed)
|
||||
_on_viewport_size_changed()
|
||||
|
||||
CreditsService.credits_changed.connect(_refresh_spin_button)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls())
|
||||
refresh_theme_controls()
|
||||
_refresh_spin_button()
|
||||
|
||||
|
||||
func open_panel() -> void:
|
||||
visible = true
|
||||
move_to_front()
|
||||
if not has_been_positioned:
|
||||
var viewport_size := get_viewport_rect().size
|
||||
position = Vector2(maxf(12.0, viewport_size.x - size.x - 24.0), 72.0)
|
||||
has_been_positioned = true
|
||||
_clamp_to_viewport()
|
||||
CreditsService.refresh()
|
||||
CreditsService.fetch_spin_config()
|
||||
_refresh_spin_button()
|
||||
|
||||
|
||||
func close_panel() -> void:
|
||||
visible = false
|
||||
drag_active = false
|
||||
|
||||
|
||||
func toggle_panel() -> void:
|
||||
if visible:
|
||||
close_panel()
|
||||
else:
|
||||
open_panel()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
add_child(margin)
|
||||
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
margin.add_child(content)
|
||||
|
||||
_build_title_bar(content)
|
||||
_build_machine(content)
|
||||
|
||||
status_label = Label.new()
|
||||
status_label.add_theme_font_override("font", Brand.font)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
content.add_child(status_label)
|
||||
|
||||
|
||||
func _build_title_bar(content: VBoxContainer) -> void:
|
||||
var title_bar := HBoxContainer.new()
|
||||
title_bar.name = "SlotMachineTitleBar"
|
||||
title_bar.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
title_bar.custom_minimum_size.y = 34
|
||||
title_bar.gui_input.connect(_on_title_bar_gui_input)
|
||||
content.add_child(title_bar)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
title_bar.add_child(spacer)
|
||||
|
||||
var close := Button.new()
|
||||
close.name = "SlotMachineClose"
|
||||
close.text = "×"
|
||||
close.tooltip_text = "Close slot machine"
|
||||
close.custom_minimum_size = Vector2(36, 32)
|
||||
close.focus_mode = Control.FOCUS_NONE
|
||||
close.pressed.connect(close_panel)
|
||||
title_bar.add_child(close)
|
||||
|
||||
|
||||
func _build_machine(parent: Control) -> void:
|
||||
var center := CenterContainer.new()
|
||||
parent.add_child(center)
|
||||
|
||||
machine_root = Control.new()
|
||||
machine_root.custom_minimum_size = Vector2(MACHINE_SIZE, MACHINE_SIZE)
|
||||
center.add_child(machine_root)
|
||||
|
||||
var frame := TextureRect.new()
|
||||
frame.texture = CABINET_FRAME
|
||||
frame.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
frame.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
frame.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
|
||||
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
machine_root.add_child(frame)
|
||||
|
||||
var window := Control.new()
|
||||
window.anchor_left = WINDOW_RECT.position.x
|
||||
window.anchor_top = WINDOW_RECT.position.y
|
||||
window.anchor_right = WINDOW_RECT.position.x + WINDOW_RECT.size.x
|
||||
window.anchor_bottom = WINDOW_RECT.position.y + WINDOW_RECT.size.y
|
||||
window.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
machine_root.add_child(window)
|
||||
|
||||
var reel_center := CenterContainer.new()
|
||||
reel_center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
window.add_child(reel_center)
|
||||
|
||||
var reels_grid := HBoxContainer.new()
|
||||
reels_grid.add_theme_constant_override("separation", REEL_SEPARATION)
|
||||
reel_center.add_child(reels_grid)
|
||||
|
||||
reel_states.clear()
|
||||
for _column_index in range(3):
|
||||
var clip := Control.new()
|
||||
clip.clip_contents = true
|
||||
clip.custom_minimum_size = Vector2(CELL, CELL * 3)
|
||||
reels_grid.add_child(clip)
|
||||
|
||||
var strip := VBoxContainer.new()
|
||||
strip.add_theme_constant_override("separation", 0)
|
||||
clip.add_child(strip)
|
||||
|
||||
var icons: Array = []
|
||||
for _row_index in range(4):
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
|
||||
icon.custom_minimum_size = Vector2(CELL, CELL)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
strip.add_child(icon)
|
||||
icons.append(icon)
|
||||
|
||||
reel_states.append({
|
||||
"strip": strip,
|
||||
"icons": icons,
|
||||
"step": CELL,
|
||||
"speed": 420.0,
|
||||
"spinning": false,
|
||||
"stop_requested": false,
|
||||
"target_texture": null,
|
||||
"recycles_after_target": -1,
|
||||
"done": true,
|
||||
})
|
||||
|
||||
lever_button = TextureButton.new()
|
||||
lever_button.texture_normal = LEVER_TEXTURE
|
||||
lever_button.ignore_texture_size = true
|
||||
lever_button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
|
||||
lever_button.anchor_left = 0.80
|
||||
lever_button.anchor_right = 1.06
|
||||
lever_button.anchor_top = 0.24
|
||||
lever_button.anchor_bottom = 0.88
|
||||
lever_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
lever_button.tooltip_text = "Pull to spin"
|
||||
lever_button.resized.connect(func(): lever_button.pivot_offset = Vector2(lever_button.size.x * 0.5, lever_button.size.y * 0.92))
|
||||
lever_button.pressed.connect(_on_lever_pulled)
|
||||
machine_root.add_child(lever_button)
|
||||
|
||||
|
||||
func refresh_theme_controls() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||||
|
||||
|
||||
func _refresh_spin_button() -> void:
|
||||
if lever_button == null:
|
||||
return
|
||||
lever_button.disabled = _spinning or CreditsService.credits < CreditsService.spin_cost
|
||||
|
||||
|
||||
func _on_lever_pulled() -> void:
|
||||
_play_lever_pull_animation()
|
||||
_on_spin_pressed()
|
||||
|
||||
|
||||
func _play_lever_pull_animation() -> void:
|
||||
var tween := create_tween()
|
||||
tween.tween_property(lever_button, "rotation_degrees", -20.0, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
tween.tween_property(lever_button, "rotation_degrees", 0.0, 0.3).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
|
||||
|
||||
|
||||
func _on_spin_pressed() -> void:
|
||||
if _spinning:
|
||||
return
|
||||
_spinning = true
|
||||
_refresh_spin_button()
|
||||
await _do_one_spin()
|
||||
_spinning = false
|
||||
_refresh_spin_button()
|
||||
|
||||
|
||||
func _do_one_spin() -> bool:
|
||||
for state in reel_states:
|
||||
state["stop_requested"] = false
|
||||
state["target_texture"] = null
|
||||
state["recycles_after_target"] = -1
|
||||
state["done"] = false
|
||||
_spin_reel(state)
|
||||
|
||||
var result := await _await_spin_result()
|
||||
|
||||
if not result.get("ok", false):
|
||||
for state in reel_states:
|
||||
state["target_texture"] = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
|
||||
state["stop_requested"] = true
|
||||
await _await_reels_done()
|
||||
status_label.text = str(result.get("message", "The spin could not be completed."))
|
||||
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
|
||||
return false
|
||||
|
||||
var reward_key := str(result.get("reward_key", ""))
|
||||
var reward_texture := _icon_for_reward(reward_key)
|
||||
for index in range(reel_states.size()):
|
||||
var state: Dictionary = reel_states[index]
|
||||
get_tree().create_timer(index * 0.25).timeout.connect(func():
|
||||
state["target_texture"] = reward_texture
|
||||
state["stop_requested"] = true)
|
||||
await _await_reels_done()
|
||||
|
||||
var reward_label := str(result.get("reward_label", ""))
|
||||
var coins_won := int(result.get("coins_won", 0))
|
||||
status_label.text = "You won: %s" % reward_label
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
if coins_won > 0:
|
||||
_play_coin_burst(coins_won)
|
||||
elif main != null:
|
||||
var credits_badge: Object = main.get("credits_badge")
|
||||
if credits_badge != null:
|
||||
credits_badge.bump()
|
||||
return true
|
||||
|
||||
|
||||
func _icon_for_reward(key: String) -> Texture2D:
|
||||
var spec: Dictionary = REWARD_ICONS.get(key, {})
|
||||
var texture: Texture2D = spec.get("texture", SLOT_COIN)
|
||||
return texture if texture != null else SLOT_COIN
|
||||
|
||||
|
||||
func _await_reels_done() -> void:
|
||||
while true:
|
||||
var all_done := true
|
||||
for state in reel_states:
|
||||
if not state.get("done", false):
|
||||
all_done = false
|
||||
break
|
||||
if all_done:
|
||||
return
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
func _await_spin_result() -> Dictionary:
|
||||
var result := {}
|
||||
var on_resolved := func(reward_key: String, reward_label: String, coins_won: int):
|
||||
result.merge({"ok": true, "reward_key": reward_key, "reward_label": reward_label, "coins_won": coins_won}, true)
|
||||
var on_failed := func(message: String):
|
||||
result.merge({"ok": false, "message": message}, true)
|
||||
CreditsService.spin_resolved.connect(on_resolved, CONNECT_ONE_SHOT)
|
||||
CreditsService.credits_failed.connect(on_failed, CONNECT_ONE_SHOT)
|
||||
CreditsService.spin()
|
||||
var deadline := Time.get_ticks_msec() + 20000
|
||||
while result.is_empty() and Time.get_ticks_msec() < deadline:
|
||||
await get_tree().process_frame
|
||||
if result.is_empty():
|
||||
result = {"ok": false, "message": "The spin timed out. Check your connection and try again."}
|
||||
if CreditsService.spin_resolved.is_connected(on_resolved):
|
||||
CreditsService.spin_resolved.disconnect(on_resolved)
|
||||
if CreditsService.credits_failed.is_connected(on_failed):
|
||||
CreditsService.credits_failed.disconnect(on_failed)
|
||||
return result
|
||||
|
||||
|
||||
func _spin_reel(state: Dictionary) -> void:
|
||||
state["spinning"] = true
|
||||
var strip: VBoxContainer = state["strip"]
|
||||
var icons: Array = state["icons"]
|
||||
var step: float = state["step"]
|
||||
var speed: float = state["speed"]
|
||||
while true:
|
||||
var current_speed: float = speed if state["recycles_after_target"] == -1 else speed * 0.45
|
||||
strip.position.y -= current_speed * get_process_delta_time()
|
||||
if strip.position.y <= -step:
|
||||
strip.position.y += step
|
||||
var front: TextureRect = icons.pop_front()
|
||||
strip.move_child(front, strip.get_child_count() - 1)
|
||||
icons.append(front)
|
||||
if state["stop_requested"] and state["recycles_after_target"] == -1:
|
||||
front.texture = state["target_texture"]
|
||||
state["recycles_after_target"] = 0
|
||||
else:
|
||||
front.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
|
||||
if state["recycles_after_target"] >= 0:
|
||||
state["recycles_after_target"] += 1
|
||||
if state["recycles_after_target"] == 2:
|
||||
strip.position.y = 0.0
|
||||
break
|
||||
await get_tree().process_frame
|
||||
state["spinning"] = false
|
||||
state["done"] = true
|
||||
|
||||
|
||||
func _play_coin_burst(coins_won: int) -> void:
|
||||
if main == null:
|
||||
return
|
||||
var credits_badge: Control = main.get("credits_badge")
|
||||
if credits_badge == null or not is_instance_valid(credits_badge):
|
||||
return
|
||||
var origin := machine_root.get_global_rect().get_center()
|
||||
var target := credits_badge.get_global_rect().get_center()
|
||||
var coin_count := clampi(3 + coins_won / 500, 4, 10)
|
||||
var flight_time := 0.55
|
||||
var stagger := 0.035
|
||||
for index in range(coin_count):
|
||||
var coin := TextureRect.new()
|
||||
coin.texture = SLOT_COIN
|
||||
coin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
coin.custom_minimum_size = Vector2(16, 16)
|
||||
coin.size = Vector2(16, 16)
|
||||
coin.top_level = true
|
||||
coin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
coin.pivot_offset = Vector2(8, 8)
|
||||
var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0))
|
||||
coin.global_position = origin + jitter - coin.size * 0.5
|
||||
add_child(coin)
|
||||
var tween := create_tween()
|
||||
tween.tween_interval(index * stagger)
|
||||
tween.set_parallel(true)
|
||||
tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
|
||||
tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
|
||||
tween.set_parallel(false)
|
||||
tween.tween_callback(coin.queue_free)
|
||||
var settle_time := (coin_count - 1) * stagger + flight_time
|
||||
get_tree().create_timer(settle_time).timeout.connect(func():
|
||||
if is_instance_valid(credits_badge):
|
||||
credits_badge.bump())
|
||||
|
||||
|
||||
func _on_title_bar_gui_input(event: InputEvent) -> void:
|
||||
var touch_event := event as InputEventScreenTouch
|
||||
if touch_event != null:
|
||||
drag_active = touch_event.pressed
|
||||
if drag_active:
|
||||
drag_offset = touch_event.position
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
var touch_drag := event as InputEventScreenDrag
|
||||
if touch_drag != null and drag_active:
|
||||
global_position += touch_drag.relative
|
||||
_clamp_to_viewport()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
var mouse_event := event as InputEventMouseButton
|
||||
if mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_LEFT:
|
||||
drag_active = mouse_event.pressed
|
||||
if drag_active:
|
||||
drag_offset = mouse_event.global_position - global_position
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
var motion := event as InputEventMouseMotion
|
||||
if motion != null and drag_active:
|
||||
global_position = motion.global_position - drag_offset
|
||||
_clamp_to_viewport()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _on_viewport_size_changed() -> void:
|
||||
_clamp_to_viewport()
|
||||
|
||||
|
||||
func _clamp_to_viewport() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
position.x = clampf(position.x, 12.0, maxf(12.0, viewport_size.x - size.x - 12.0))
|
||||
position.y = clampf(position.y, 12.0, maxf(12.0, viewport_size.y - size.y - 12.0))
|
||||
@@ -0,0 +1 @@
|
||||
uid://col1hauyr27rn
|
||||
@@ -0,0 +1,549 @@
|
||||
extends VBoxContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const ICON_BY_SKU := {
|
||||
"exam_token_1": "clock-3",
|
||||
"qbank_reset_1": "rotate-ccw",
|
||||
}
|
||||
const FEATURES_BY_SKU := {
|
||||
"exam_token_1": ["Full 6-block exam", "Timed & scored", "Performance analytics"],
|
||||
"qbank_reset_1": ["Reset eligibility", "Keep your stats", "Smart spaced retention"],
|
||||
}
|
||||
const POPULAR_SKUS := ["exam_token_1"]
|
||||
const TAB_DEFS := [
|
||||
{"label": "Featured", "icon": "star", "active": true},
|
||||
{"label": "Tokens", "icon": "clipboard-list", "active": false},
|
||||
{"label": "Boosts", "icon": "activity", "active": false},
|
||||
{"label": "Content", "icon": "book-open", "active": false},
|
||||
{"label": "Customization", "icon": "settings", "active": false},
|
||||
{"label": "Bundles", "icon": "clipboard-plus", "active": false},
|
||||
]
|
||||
|
||||
var main: Control
|
||||
var subtitle_label: Label
|
||||
var summary_card: PanelContainer
|
||||
var summary_icon_panel: PanelContainer
|
||||
var summary_icon: TextureRect
|
||||
var balance_value_label: Label
|
||||
var coin_caption_label: Label
|
||||
var inventory_label: Label
|
||||
var tab_controls: Array = []
|
||||
var section_heading: Label
|
||||
var section_subtitle: Label
|
||||
var products_grid: HFlowContainer
|
||||
var status_label: Label
|
||||
var confirm_dialog: ConfirmationDialog
|
||||
var footer_card: PanelContainer
|
||||
var footer_icon_panel: PanelContainer
|
||||
var footer_icon: TextureRect
|
||||
var footer_heading: Label
|
||||
var footer_description: Label
|
||||
var pending_sku := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 18)
|
||||
|
||||
_build_header()
|
||||
_build_summary_card()
|
||||
_build_tabs()
|
||||
|
||||
section_heading = Label.new()
|
||||
section_heading.text = "Featured"
|
||||
section_heading.add_theme_font_override("font", Brand.font)
|
||||
section_heading.add_theme_font_size_override("font_size", 18)
|
||||
add_child(section_heading)
|
||||
section_subtitle = Label.new()
|
||||
section_subtitle.text = "Hand-picked items to supercharge your Step 3 prep."
|
||||
section_subtitle.add_theme_font_override("font", Brand.font)
|
||||
section_subtitle.add_theme_font_size_override("font_size", 12)
|
||||
section_subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
add_child(section_subtitle)
|
||||
|
||||
products_grid = HFlowContainer.new()
|
||||
products_grid.add_theme_constant_override("h_separation", 14)
|
||||
products_grid.add_theme_constant_override("v_separation", 14)
|
||||
add_child(products_grid)
|
||||
|
||||
status_label = Label.new()
|
||||
status_label.add_theme_font_override("font", Brand.font)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(status_label)
|
||||
|
||||
confirm_dialog = ConfirmationDialog.new()
|
||||
confirm_dialog.confirmed.connect(_confirm_purchase)
|
||||
add_child(confirm_dialog)
|
||||
|
||||
_build_footer()
|
||||
|
||||
CreditsService.credits_changed.connect(_refresh)
|
||||
CreditsService.credits_failed.connect(_show_error)
|
||||
CreditsService.store_operation_completed.connect(_show_message)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_theme_controls(); _refresh())
|
||||
_refresh()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
CreditsService.refresh()
|
||||
_refresh()
|
||||
|
||||
|
||||
func _build_header() -> void:
|
||||
var title := Label.new()
|
||||
title.text = "RZ Coin Shop"
|
||||
title.add_theme_font_override("font", Brand.font)
|
||||
title.add_theme_font_size_override("font_size", 30)
|
||||
add_child(title)
|
||||
subtitle_label = Label.new()
|
||||
subtitle_label.text = "Spend your RZ Coins on premium content and boosts."
|
||||
subtitle_label.add_theme_font_override("font", Brand.font)
|
||||
subtitle_label.add_theme_font_size_override("font_size", 14)
|
||||
subtitle_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
add_child(subtitle_label)
|
||||
|
||||
|
||||
func _build_summary_card() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
summary_card = PanelContainer.new()
|
||||
summary_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
|
||||
summary_card.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
add_child(summary_card)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
summary_card.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(row)
|
||||
|
||||
summary_icon_panel = PanelContainer.new()
|
||||
summary_icon_panel.custom_minimum_size = Vector2(40, 40)
|
||||
summary_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
summary_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 20))
|
||||
summary_icon = TextureRect.new()
|
||||
summary_icon.texture = load("res://assets/icons/lucide/star.svg")
|
||||
summary_icon.custom_minimum_size = Vector2(22, 22)
|
||||
summary_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
summary_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
summary_icon.modulate = Brand.AMBER
|
||||
summary_icon_panel.add_child(summary_icon)
|
||||
row.add_child(summary_icon_panel)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
copy.add_theme_constant_override("separation", 0)
|
||||
balance_value_label = Label.new()
|
||||
balance_value_label.add_theme_font_override("font", Brand.font)
|
||||
balance_value_label.add_theme_font_size_override("font_size", 22)
|
||||
balance_value_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
copy.add_child(balance_value_label)
|
||||
coin_caption_label = Label.new()
|
||||
coin_caption_label.text = "RZ Coins"
|
||||
coin_caption_label.add_theme_font_override("font", Brand.font)
|
||||
coin_caption_label.add_theme_font_size_override("font_size", 12)
|
||||
coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
copy.add_child(coin_caption_label)
|
||||
inventory_label = Label.new()
|
||||
inventory_label.add_theme_font_override("font", Brand.font)
|
||||
inventory_label.add_theme_font_size_override("font_size", 12)
|
||||
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
copy.add_child(inventory_label)
|
||||
row.add_child(copy)
|
||||
|
||||
|
||||
func _build_tabs() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
tab_controls.clear()
|
||||
|
||||
var tabs_row := HFlowContainer.new()
|
||||
tabs_row.add_theme_constant_override("h_separation", 20)
|
||||
tabs_row.add_theme_constant_override("v_separation", 8)
|
||||
add_child(tabs_row)
|
||||
|
||||
for tab_def_variant in TAB_DEFS:
|
||||
var tab_def: Dictionary = tab_def_variant
|
||||
var tab := VBoxContainer.new()
|
||||
tab.add_theme_constant_override("separation", 6)
|
||||
tabs_row.add_child(tab)
|
||||
|
||||
var content := HBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 6)
|
||||
tab.add_child(content)
|
||||
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/%s.svg" % str(tab_def.get("icon", "star")))
|
||||
icon.custom_minimum_size = Vector2(16, 16)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
content.add_child(icon)
|
||||
|
||||
var label := Label.new()
|
||||
label.text = str(tab_def.get("label", ""))
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
content.add_child(label)
|
||||
|
||||
var underline := ColorRect.new()
|
||||
underline.custom_minimum_size = Vector2(0, 2)
|
||||
tab.add_child(underline)
|
||||
|
||||
var active := bool(tab_def.get("active", false))
|
||||
tab_controls.append({"icon": icon, "label": label, "underline": underline, "active": active})
|
||||
|
||||
var divider := ColorRect.new()
|
||||
divider.custom_minimum_size = Vector2(0, 1)
|
||||
add_child(divider)
|
||||
tab_controls.append({"divider": divider})
|
||||
|
||||
_apply_tab_theme(dark)
|
||||
|
||||
|
||||
func _apply_tab_theme(dark: bool) -> void:
|
||||
for entry_variant in tab_controls:
|
||||
var entry: Dictionary = entry_variant
|
||||
if entry.has("divider"):
|
||||
(entry["divider"] as ColorRect).color = Color("2a365a") if dark else Brand.LIGHT_BORDER
|
||||
continue
|
||||
var active := bool(entry["active"])
|
||||
var color := Brand.ROYAL_BLUE if active else Brand.muted_color(dark)
|
||||
(entry["icon"] as TextureRect).modulate = color
|
||||
(entry["label"] as Label).add_theme_color_override("font_color", color)
|
||||
(entry["underline"] as ColorRect).color = Brand.ROYAL_BLUE if active else Color(0, 0, 0, 0)
|
||||
|
||||
|
||||
func _build_footer() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
footer_card = PanelContainer.new()
|
||||
footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
|
||||
add_child(footer_card)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
footer_card.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(row)
|
||||
|
||||
footer_icon_panel = PanelContainer.new()
|
||||
footer_icon_panel.custom_minimum_size = Vector2(36, 36)
|
||||
footer_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))
|
||||
footer_icon = TextureRect.new()
|
||||
footer_icon.texture = load("res://assets/icons/lucide/activity.svg")
|
||||
footer_icon.custom_minimum_size = Vector2(18, 18)
|
||||
footer_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
footer_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
footer_icon.modulate = Brand.ROYAL_BLUE
|
||||
footer_icon_panel.add_child(footer_icon)
|
||||
row.add_child(footer_icon_panel)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
copy.add_theme_constant_override("separation", 2)
|
||||
footer_heading = Label.new()
|
||||
footer_heading.text = "How to earn RZ Coins"
|
||||
footer_heading.add_theme_font_override("font", Brand.font)
|
||||
footer_heading.add_theme_font_size_override("font_size", 15)
|
||||
footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
copy.add_child(footer_heading)
|
||||
footer_description = Label.new()
|
||||
footer_description.text = "Answer questions correctly on the first try, within the time limit."
|
||||
footer_description.add_theme_font_override("font", Brand.font)
|
||||
footer_description.add_theme_font_size_override("font_size", 12)
|
||||
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
footer_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.add_child(footer_description)
|
||||
row.add_child(copy)
|
||||
|
||||
|
||||
func _refresh_theme_controls() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
subtitle_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
summary_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
|
||||
summary_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 20))
|
||||
balance_value_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
_apply_tab_theme(dark)
|
||||
section_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
|
||||
footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))
|
||||
footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
balance_value_label.text = _format_thousands(CreditsService.credits)
|
||||
inventory_label.text = "%d exam token(s) · %d QBank reset token(s)" % [
|
||||
CreditsService.exam_tokens, CreditsService.qbank_reset_tokens]
|
||||
for child in products_grid.get_children():
|
||||
child.queue_free()
|
||||
for product_variant in CreditsService.products:
|
||||
if typeof(product_variant) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var product: Dictionary = product_variant
|
||||
var sku := str(product.get("sku", ""))
|
||||
var price := int(product.get("coinPrice", 0))
|
||||
products_grid.add_child(_build_item_card(
|
||||
dark,
|
||||
ICON_BY_SKU.get(sku, "star"),
|
||||
Brand.ROYAL_BLUE,
|
||||
str(product.get("name", "Store item")),
|
||||
str(product.get("description", "")),
|
||||
FEATURES_BY_SKU.get(sku, []),
|
||||
POPULAR_SKUS.has(sku),
|
||||
price,
|
||||
"Buy",
|
||||
CreditsService.credits < price,
|
||||
_ask_purchase.bind(sku, str(product.get("name", "item")), price)))
|
||||
if CreditsService.qbank_reset_tokens > 0:
|
||||
products_grid.add_child(_build_item_card(
|
||||
dark,
|
||||
"rotate-ccw",
|
||||
Brand.SUCCESS,
|
||||
"Use QBank Reset Token",
|
||||
"Question eligibility resets; ELO, history, bookmarks, notes, reviews, and earning limits are preserved.",
|
||||
FEATURES_BY_SKU.get("qbank_reset_1", []),
|
||||
false,
|
||||
-1,
|
||||
"Use token",
|
||||
false,
|
||||
_ask_reset))
|
||||
|
||||
|
||||
func _build_item_card(
|
||||
dark: bool,
|
||||
icon_name: String,
|
||||
accent: Color,
|
||||
title: String,
|
||||
description_text: String,
|
||||
features: Array,
|
||||
popular: bool,
|
||||
price: int,
|
||||
button_text: String,
|
||||
button_disabled: bool,
|
||||
on_press: Callable
|
||||
) -> PanelContainer:
|
||||
var card := _new_card(dark)
|
||||
var content: VBoxContainer = card.get_child(0).get_child(0)
|
||||
|
||||
var top_row := HBoxContainer.new()
|
||||
content.add_child(top_row)
|
||||
top_row.add_child(_card_icon(dark, icon_name, accent))
|
||||
var badge_spacer := Control.new()
|
||||
badge_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
top_row.add_child(badge_spacer)
|
||||
if popular:
|
||||
top_row.add_child(_popular_badge())
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = title
|
||||
name_label.add_theme_font_override("font", Brand.font)
|
||||
name_label.add_theme_font_size_override("font_size", 16)
|
||||
name_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
content.add_child(name_label)
|
||||
|
||||
var description := Label.new()
|
||||
description.text = description_text
|
||||
description.add_theme_font_override("font", Brand.font)
|
||||
description.add_theme_font_size_override("font_size", 12)
|
||||
description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
description.custom_minimum_size = Vector2(190, 0)
|
||||
content.add_child(description)
|
||||
|
||||
if not features.is_empty():
|
||||
var bullets := VBoxContainer.new()
|
||||
bullets.add_theme_constant_override("separation", 4)
|
||||
content.add_child(bullets)
|
||||
for feature_text in features:
|
||||
bullets.add_child(_feature_row(dark, accent, str(feature_text)))
|
||||
|
||||
var divider := ColorRect.new()
|
||||
divider.custom_minimum_size = Vector2(0, 1)
|
||||
divider.color = Color("2a365a") if dark else Brand.LIGHT_BORDER
|
||||
content.add_child(divider)
|
||||
|
||||
var footer_row := HBoxContainer.new()
|
||||
footer_row.add_theme_constant_override("separation", 8)
|
||||
content.add_child(footer_row)
|
||||
if price >= 0:
|
||||
footer_row.add_child(_price_tag(dark, price))
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
footer_row.add_child(spacer)
|
||||
var button := Button.new()
|
||||
button.text = button_text
|
||||
button.custom_minimum_size = Vector2(64, 34)
|
||||
button.add_theme_font_override("font", Brand.font)
|
||||
_style_buy_button(button)
|
||||
button.disabled = button_disabled
|
||||
button.pressed.connect(on_press)
|
||||
footer_row.add_child(button)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
func _new_card(dark: bool) -> PanelContainer:
|
||||
var card := PanelContainer.new()
|
||||
card.custom_minimum_size = Vector2(230, 0)
|
||||
card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_top", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
card.add_child(margin)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(content)
|
||||
return card
|
||||
|
||||
|
||||
func _card_icon(dark: bool, icon_name: String, accent: Color) -> PanelContainer:
|
||||
var icon_panel := PanelContainer.new()
|
||||
icon_panel.custom_minimum_size = Vector2(48, 48)
|
||||
icon_panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 24))
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
||||
icon.custom_minimum_size = Vector2(24, 24)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = accent
|
||||
icon_panel.add_child(icon)
|
||||
return icon_panel
|
||||
|
||||
|
||||
func _popular_badge() -> PanelContainer:
|
||||
var badge := PanelContainer.new()
|
||||
badge.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = Brand.ROYAL_BLUE
|
||||
box.set_corner_radius_all(6)
|
||||
box.content_margin_left = 8
|
||||
box.content_margin_right = 8
|
||||
box.content_margin_top = 3
|
||||
box.content_margin_bottom = 3
|
||||
badge.add_theme_stylebox_override("panel", box)
|
||||
var label := Label.new()
|
||||
label.text = "Popular"
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 11)
|
||||
label.add_theme_color_override("font_color", Brand.DARK_TEXT)
|
||||
badge.add_child(label)
|
||||
return badge
|
||||
|
||||
|
||||
func _feature_row(dark: bool, accent: Color, text: String) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 6)
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/circle-check.svg")
|
||||
icon.custom_minimum_size = Vector2(14, 14)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = accent
|
||||
row.add_child(icon)
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 12)
|
||||
label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
row.add_child(label)
|
||||
return row
|
||||
|
||||
|
||||
func _price_tag(dark: bool, price: int) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 4)
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/star.svg")
|
||||
icon.custom_minimum_size = Vector2(14, 14)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = Brand.AMBER
|
||||
row.add_child(icon)
|
||||
var label := Label.new()
|
||||
label.text = _format_thousands(price)
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
row.add_child(label)
|
||||
return row
|
||||
|
||||
|
||||
func _style_buy_button(button: Button) -> void:
|
||||
var normal := StyleBoxFlat.new()
|
||||
normal.bg_color = Brand.ROYAL_BLUE
|
||||
normal.set_corner_radius_all(8)
|
||||
var hover := StyleBoxFlat.new()
|
||||
hover.bg_color = Brand.SKY_BLUE
|
||||
hover.set_corner_radius_all(8)
|
||||
var disabled := StyleBoxFlat.new()
|
||||
disabled.bg_color = Color(Brand.ROYAL_BLUE, 0.35)
|
||||
disabled.set_corner_radius_all(8)
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", normal)
|
||||
button.add_theme_stylebox_override("disabled", disabled)
|
||||
button.add_theme_color_override("font_color", Brand.DARK_TEXT)
|
||||
button.add_theme_color_override("font_hover_color", Brand.DARK_TEXT)
|
||||
button.add_theme_color_override("font_disabled_color", Color(Brand.DARK_TEXT, 0.7))
|
||||
|
||||
|
||||
func _ask_purchase(sku: String, product_name: String, price: int) -> void:
|
||||
pending_sku = sku
|
||||
confirm_dialog.title = "Confirm purchase"
|
||||
confirm_dialog.dialog_text = "Buy %s for %d Rz Coins?" % [product_name, price]
|
||||
confirm_dialog.popup_centered()
|
||||
|
||||
|
||||
func _confirm_purchase() -> void:
|
||||
if pending_sku == "__reset__":
|
||||
CreditsService.reset_qbank()
|
||||
elif not pending_sku.is_empty():
|
||||
CreditsService.purchase(pending_sku)
|
||||
pending_sku = ""
|
||||
|
||||
|
||||
func _ask_reset() -> void:
|
||||
pending_sku = "__reset__"
|
||||
confirm_dialog.title = "Reset QBank eligibility"
|
||||
confirm_dialog.dialog_text = "Make all QBank questions eligible again? ELO, answer history, bookmarks, notes, reviews, and the rolling coin cap will be preserved."
|
||||
confirm_dialog.popup_centered()
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
|
||||
|
||||
|
||||
func _show_message(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
_refresh()
|
||||
|
||||
|
||||
func _format_thousands(value: int) -> String:
|
||||
var digits := str(absi(value))
|
||||
var grouped := ""
|
||||
var count := 0
|
||||
for index in range(digits.length() - 1, -1, -1):
|
||||
grouped = digits[index] + grouped
|
||||
count += 1
|
||||
if count % 3 == 0 and index != 0:
|
||||
grouped = "," + grouped
|
||||
return ("-" if value < 0 else "") + grouped
|
||||
@@ -0,0 +1 @@
|
||||
uid://cldpnvs2k6r3t
|
||||
@@ -137,6 +137,8 @@ func _connect_services() -> void:
|
||||
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
|
||||
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
|
||||
ExamSimulationService.request_failed.connect(_on_request_failed)
|
||||
if not ExamSimulationService.exam_token_required.is_connected(_on_exam_token_required):
|
||||
ExamSimulationService.exam_token_required.connect(_on_exam_token_required)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
@@ -538,6 +540,11 @@ func _on_request_failed(message: String) -> void:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_exam_token_required() -> void:
|
||||
pending_launch = false
|
||||
main.show_screen("rz_store")
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 940
|
||||
if compact_layout == use_compact:
|
||||
|
||||
@@ -1,91 +1,283 @@
|
||||
extends VBoxContainer
|
||||
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const CORRECT_ICON := preload("res://assets/icons/lucide/circle-check.svg")
|
||||
const PROBABILITY_ICON := preload("res://assets/icons/lucide/activity.svg")
|
||||
const SEEN_ICON := preload("res://assets/icons/lucide/eye.svg")
|
||||
const CATEGORY_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-increasing.svg")
|
||||
const REVIEW_ICON := preload("res://assets/icons/lucide/clipboard-list.svg")
|
||||
const CATEGORY_PALETTE := [Brand.ROYAL_BLUE, Brand.SUCCESS, Brand.AMBER, Brand.MAGENTA, Brand.SKY_BLUE]
|
||||
|
||||
var main: Control
|
||||
var score_label: Label
|
||||
var summary_label: Label
|
||||
var category_list: VBoxContainer
|
||||
var content: VBoxContainer
|
||||
var current_simulation := {}
|
||||
var return_screen := "selection"
|
||||
var done_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)
|
||||
|
||||
add_theme_constant_override("separation", 0)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
var content := VBoxContainer.new()
|
||||
content = VBoxContainer.new()
|
||||
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
content.add_theme_constant_override("separation", 20)
|
||||
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_button := Button.new()
|
||||
review_button.text = "Review Questions"
|
||||
review_button.custom_minimum_size = Vector2(240, 50)
|
||||
review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
Style.style_primary_button(review_button)
|
||||
review_button.pressed.connect(func(): main.show_screen("simulation_review", {"simulation": current_simulation, "return_screen": return_screen}))
|
||||
content.add_child(review_button)
|
||||
|
||||
done_button = Button.new()
|
||||
done_button.text = "Return to study modes"
|
||||
done_button.custom_minimum_size = Vector2(240, 50)
|
||||
done_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
Style.style_primary_button(done_button)
|
||||
done_button.pressed.connect(_return_from_results)
|
||||
add_child(done_button)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): _render())
|
||||
|
||||
|
||||
func on_show(payload: Variant = null) -> void:
|
||||
var wrapper: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
|
||||
var simulation: Dictionary = wrapper.get("simulation", wrapper) if not wrapper.is_empty() else ExamSimulationService.simulation
|
||||
return_screen = str(wrapper.get("return_screen", "selection"))
|
||||
done_button.text = "Return to simulation history" if return_screen == "simulation_history" else "Return to study modes"
|
||||
current_simulation = simulation
|
||||
var score: Dictionary = simulation.get("score", {})
|
||||
_render()
|
||||
|
||||
|
||||
func _render() -> void:
|
||||
_clear(content)
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var score: Dictionary = current_simulation.get("score", {})
|
||||
content.add_child(_build_hero_card(dark, score))
|
||||
content.add_child(_build_stats_row(dark, score))
|
||||
content.add_child(_build_disclaimer(dark, score))
|
||||
content.add_child(_build_category_card(dark, score))
|
||||
content.add_child(_build_actions())
|
||||
|
||||
|
||||
func _build_hero_card(dark: bool, score: Dictionary) -> PanelContainer:
|
||||
var predicted := int(score.get("predictedScore", 0))
|
||||
var low := int(score.get("rangeLow", 0))
|
||||
var high := int(score.get("rangeHigh", 0))
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 24)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
copy.add_theme_constant_override("separation", 10)
|
||||
row.add_child(copy)
|
||||
|
||||
copy.add_child(_label("STEP 3 DAY 1 SIMULATION COMPLETE", 13, Brand.SKY_BLUE, true))
|
||||
var headline := _label("Provisional Step 3 estimate: %d" % predicted, 26, Brand.text_color(dark), true)
|
||||
headline.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.add_child(headline)
|
||||
|
||||
var badge := _label("%d-%d Predicted score range" % [low, high], 13, Brand.text_color(dark))
|
||||
badge.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
badge.add_theme_stylebox_override("normal", Brand.badge_box(dark, Brand.ROYAL_BLUE))
|
||||
copy.add_child(badge)
|
||||
|
||||
row.add_child(_score_medallion(dark, predicted, Brand.ROYAL_BLUE))
|
||||
return _card(dark, row, Brand.ROYAL_BLUE, true)
|
||||
|
||||
|
||||
func _score_medallion(dark: bool, score: int, accent: Color) -> Control:
|
||||
var wrap := Control.new()
|
||||
wrap.custom_minimum_size = Vector2(120, 120)
|
||||
wrap.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
wrap.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 60, true))
|
||||
wrap.add_child(panel)
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
panel.add_child(center)
|
||||
center.add_child(_label(str(score), 34, Brand.DARK_TEXT, true))
|
||||
return wrap
|
||||
|
||||
|
||||
func _build_stats_row(dark: bool, score: Dictionary) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 16)
|
||||
var correct := int(score.get("correct", 0))
|
||||
var total := int(score.get("total", 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)
|
||||
var seen := int(score.get("seenCount", 0))
|
||||
row.add_child(_stat_card(dark, CORRECT_ICON, Brand.ROYAL_BLUE, "%d / %d" % [correct, total], "Correct"))
|
||||
row.add_child(_stat_card(dark, PROBABILITY_ICON, Brand.SUCCESS, "%d%%" % probability, "Heuristic probability of reaching 200+"))
|
||||
row.add_child(_stat_card(dark, SEEN_ICON, Brand.SKY_BLUE, str(seen), "Previously seen questions"))
|
||||
return row
|
||||
|
||||
|
||||
func _return_from_results() -> void:
|
||||
if return_screen == "simulation_history":
|
||||
main.show_screen("simulation_history", "preserve")
|
||||
func _stat_card(dark: bool, icon: Texture2D, accent: Color, value: String, caption: String) -> PanelContainer:
|
||||
var inner := HBoxContainer.new()
|
||||
inner.add_theme_constant_override("separation", 12)
|
||||
|
||||
var icon_panel := PanelContainer.new()
|
||||
icon_panel.custom_minimum_size = Vector2(44, 44)
|
||||
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 22, true))
|
||||
var icon_rect := TextureRect.new()
|
||||
icon_rect.texture = icon
|
||||
icon_rect.custom_minimum_size = Vector2(20, 20)
|
||||
icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon_rect.modulate = Brand.DARK_TEXT
|
||||
icon_panel.add_child(icon_rect)
|
||||
inner.add_child(icon_panel)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.add_theme_constant_override("separation", 2)
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
copy.add_child(_label(value, 20, Brand.text_color(dark), true))
|
||||
var caption_label := _label(caption, 12, Brand.muted_color(dark))
|
||||
caption_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.add_child(caption_label)
|
||||
inner.add_child(copy)
|
||||
|
||||
return _card(dark, inner)
|
||||
|
||||
|
||||
func _build_disclaimer(dark: bool, score: Dictionary) -> Label:
|
||||
var raw_accuracy := float(score.get("rawAccuracy", 0.0)) * 100.0
|
||||
var adjusted_accuracy := float(score.get("difficultyAdjustedAccuracy", 0.0)) * 100.0
|
||||
var label := _label(
|
||||
"%.1f%% raw accuracy · %.1f%% difficulty-adjusted accuracy\nThis transparent, unvalidated educational heuristic is not an official USMLE or NBME score and excludes CCS performance." % [raw_accuracy, adjusted_accuracy],
|
||||
12, Brand.muted_color(dark))
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
return label
|
||||
|
||||
|
||||
func _build_category_card(dark: bool, score: Dictionary) -> PanelContainer:
|
||||
var column := VBoxContainer.new()
|
||||
column.add_theme_constant_override("separation", 14)
|
||||
|
||||
var heading := HBoxContainer.new()
|
||||
heading.add_theme_constant_override("separation", 8)
|
||||
var heading_icon := TextureRect.new()
|
||||
heading_icon.texture = CATEGORY_ICON
|
||||
heading_icon.custom_minimum_size = Vector2(20, 20)
|
||||
heading_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
heading_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
heading_icon.modulate = Brand.ROYAL_BLUE
|
||||
heading.add_child(heading_icon)
|
||||
heading.add_child(_label("Performance by content area", 18, Brand.text_color(dark), true))
|
||||
column.add_child(heading)
|
||||
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 2
|
||||
grid.add_theme_constant_override("h_separation", 32)
|
||||
grid.add_theme_constant_override("v_separation", 14)
|
||||
var categories: Dictionary = score.get("categories", {})
|
||||
var index := 0
|
||||
for category_name in categories:
|
||||
var value: Dictionary = categories[category_name]
|
||||
grid.add_child(_category_row(dark, str(category_name), value, index))
|
||||
index += 1
|
||||
if categories.is_empty():
|
||||
column.add_child(_label("No content-area breakdown is available for this simulation.", 13, Brand.muted_color(dark)))
|
||||
else:
|
||||
main.show_screen("selection", "step3")
|
||||
column.add_child(grid)
|
||||
|
||||
return _card(dark, column)
|
||||
|
||||
|
||||
func _category_row(dark: bool, category_name: String, value: Dictionary, index: int) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var accent: Color = CATEGORY_PALETTE[index % CATEGORY_PALETTE.size()]
|
||||
|
||||
var dot := PanelContainer.new()
|
||||
dot.custom_minimum_size = Vector2(10, 10)
|
||||
dot.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
dot.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 5, true))
|
||||
row.add_child(dot)
|
||||
|
||||
var correct := int(value.get("correct", 0))
|
||||
var total := int(value.get("total", 0))
|
||||
var percent := int(round(100.0 * float(correct) / float(total))) if total > 0 else 0
|
||||
|
||||
var title := _label(category_name, 13, Brand.text_color(dark))
|
||||
title.custom_minimum_size.x = 200
|
||||
title.clip_text = true
|
||||
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
row.add_child(title)
|
||||
|
||||
var bar := ProgressBar.new()
|
||||
bar.show_percentage = false
|
||||
bar.min_value = 0.0
|
||||
bar.max_value = 100.0
|
||||
bar.value = percent
|
||||
bar.custom_minimum_size = Vector2(90, 7)
|
||||
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
_style_progress(bar, accent, dark)
|
||||
row.add_child(bar)
|
||||
|
||||
var detail := _label("%d%% %d/%d" % [percent, correct, total], 12, accent if dark else accent.darkened(0.15))
|
||||
detail.custom_minimum_size.x = 76
|
||||
detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
row.add_child(detail)
|
||||
return row
|
||||
|
||||
|
||||
func _build_actions() -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
|
||||
var review_button := Button.new()
|
||||
review_button.text = "Review Questions"
|
||||
review_button.icon = REVIEW_ICON
|
||||
review_button.custom_minimum_size = Vector2(220, 52)
|
||||
review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
_style_gradient_button(review_button)
|
||||
review_button.pressed.connect(func(): main.show_screen("simulation_review", {"simulation": current_simulation, "return_screen": return_screen}))
|
||||
row.add_child(review_button)
|
||||
return row
|
||||
|
||||
|
||||
func _card(dark: bool, child: Control, accent: Color = Brand.ROYAL_BLUE, active: bool = false) -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var box := Brand.content_card_box(dark, active, accent)
|
||||
box.content_margin_left = 20
|
||||
box.content_margin_right = 20
|
||||
box.content_margin_top = 18
|
||||
box.content_margin_bottom = 18
|
||||
panel.add_theme_stylebox_override("panel", box)
|
||||
panel.add_child(child)
|
||||
return panel
|
||||
|
||||
|
||||
func _label(text: String, font_size: int, color: Color, _bold: bool = false) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", font_size)
|
||||
label.add_theme_color_override("font_color", color)
|
||||
return label
|
||||
|
||||
|
||||
func _style_progress(bar: ProgressBar, accent: Color, dark: bool) -> void:
|
||||
var background := StyleBoxFlat.new()
|
||||
background.bg_color = Color("26324a") if dark else Color("e2e8f0")
|
||||
background.set_corner_radius_all(4)
|
||||
var fill := StyleBoxFlat.new()
|
||||
fill.bg_color = accent
|
||||
fill.set_corner_radius_all(4)
|
||||
bar.add_theme_stylebox_override("background", background)
|
||||
bar.add_theme_stylebox_override("fill", fill)
|
||||
|
||||
|
||||
func _style_gradient_button(button: Button) -> void:
|
||||
Brand.apply_font(button)
|
||||
button.add_theme_font_size_override("font_size", 15)
|
||||
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
|
||||
button.add_theme_stylebox_override("hover", Brand.gradient_button_box())
|
||||
button.add_theme_stylebox_override("pressed", Brand.gradient_button_box())
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
|
||||
|
||||
func _clear(container: Control) -> void:
|
||||
for child in container.get_children():
|
||||
child.queue_free()
|
||||
container.remove_child(child)
|
||||
child.free()
|
||||
|
||||
@@ -13,7 +13,7 @@ func _initialize() -> void:
|
||||
_test_eliminated_choice_uses_rich_text_strikeout()
|
||||
_test_eliminated_choice_that_is_also_locked_wrong_still_strikes_through()
|
||||
_test_explanation_highlighting_has_no_status_heading()
|
||||
_test_resolved_followup_has_no_header_or_verdict_prefix()
|
||||
_test_resolved_followup_stays_expanded_and_toggles()
|
||||
_test_review_content_normalization()
|
||||
_test_followup_attempt_order_is_stable()
|
||||
_test_review_followup_is_optional_and_single_attempt()
|
||||
@@ -22,6 +22,7 @@ func _initialize() -> void:
|
||||
_test_feedback_context_excludes_study_content()
|
||||
_test_feedback_form_validation()
|
||||
_test_revisitable_navigation_prefers_unanswered_questions()
|
||||
_test_timer_text_avoids_redundant_frame_updates()
|
||||
_test_submit_styles_keep_stable_geometry()
|
||||
_test_core_timer_counts_up_and_remains_visible()
|
||||
await _test_player_runtime_construction()
|
||||
@@ -177,7 +178,7 @@ func _test_explanation_highlighting_has_no_status_heading() -> void:
|
||||
player.free()
|
||||
|
||||
|
||||
func _test_resolved_followup_has_no_header_or_verdict_prefix() -> void:
|
||||
func _test_resolved_followup_stays_expanded_and_toggles() -> void:
|
||||
var player = load("res://scripts/ui/question_player.gd").new()
|
||||
var panel := PanelContainer.new()
|
||||
var content := VBoxContainer.new()
|
||||
@@ -191,17 +192,38 @@ func _test_resolved_followup_has_no_header_or_verdict_prefix() -> void:
|
||||
player.presented_choices = [{"id": "B", "text": "Wrong answer"}]
|
||||
player.followup_states["B"] = {
|
||||
"choice_id": "B", "choice_text": "Wrong answer", "prompt": "Why?",
|
||||
"selected_option_text": "Finding", "was_correct": false,
|
||||
"teaching_point": "Incorrect. Review the finding.", "resolved": true
|
||||
"choices": [{"id": "A", "text": "Selected finding"}, {"id": "B", "text": "Correct finding"}],
|
||||
"correct_choice_id": "B", "selected_option_id": "A",
|
||||
"selected_option_text": "A. Selected finding", "was_correct": false,
|
||||
"teaching_point": "Incorrect. Review the finding.", "resolved": true, "collapsed": false,
|
||||
"has_authored_choices": true
|
||||
}
|
||||
player._render_followup_state("B")
|
||||
_assert(content.find_child("FollowupToggle", true, false) == null, "a resolved follow-up should not render a collapse toggle")
|
||||
var explanation_text := ""
|
||||
for child in content.get_children():
|
||||
if child is Label:
|
||||
explanation_text += child.text
|
||||
_assert(not explanation_text.contains("Why this is"), "a resolved follow-up should not repeat a 'Why this is...' heading")
|
||||
_assert(not explanation_text.begins_with("Incorrect."), "a resolved follow-up explanation should not repeat the verdict prefix")
|
||||
var toggle := content.find_child("FollowupToggle", true, false) as Button
|
||||
_assert(toggle != null, "a resolved follow-up should render a minimize toggle")
|
||||
_assert(toggle != null and toggle.text == "Minimize", "a newly resolved follow-up should remain expanded")
|
||||
_assert(content.find_child("FollowupPrompt", true, false) != null, "the resolved follow-up should retain its prompt")
|
||||
_assert(content.find_child("FollowupOption_A", true, false) != null, "the resolved follow-up should retain its selected option")
|
||||
_assert(content.find_child("FollowupOption_B", true, false) != null, "the resolved follow-up should retain its correct option")
|
||||
var verdict := content.find_child("FollowupVerdict", true, false) as Label
|
||||
_assert(verdict != null and verdict.text == "Incorrect", "the resolved follow-up should show whether the answer was correct")
|
||||
if toggle != null:
|
||||
toggle.pressed.emit()
|
||||
_assert(bool(player.followup_states["B"].get("collapsed", false)), "the minimize toggle should collapse the follow-up")
|
||||
_assert(content.find_child("FollowupPrompt", true, false) == null, "a collapsed follow-up should hide its prompt")
|
||||
toggle = content.find_child("FollowupToggle", true, false) as Button
|
||||
_assert(toggle != null and toggle.text == "Expand", "a collapsed follow-up should render an expand toggle")
|
||||
if toggle != null:
|
||||
toggle.pressed.emit()
|
||||
_assert(not bool(player.followup_states["B"].get("collapsed", true)), "the expand toggle should restore the follow-up")
|
||||
_assert(content.find_child("FollowupPrompt", true, false) != null, "expanding should restore the follow-up prompt")
|
||||
var correct_state: Dictionary = player.followup_states["B"]
|
||||
correct_state["selected_option_id"] = "B"
|
||||
correct_state["was_correct"] = true
|
||||
player.followup_states["B"] = correct_state
|
||||
player._render_followup_state("B")
|
||||
verdict = content.find_child("FollowupVerdict", true, false) as Label
|
||||
_assert(verdict != null and verdict.text == "Correct", "a correct follow-up response should show a correct verdict")
|
||||
player.choice_buttons[0].free()
|
||||
player.choice_percent_labels[0].free()
|
||||
player.choice_top_labels[0].free()
|
||||
@@ -355,8 +377,23 @@ func _test_revisitable_navigation_prefers_unanswered_questions() -> void:
|
||||
session.current_index = prior_index
|
||||
|
||||
|
||||
func _test_timer_text_avoids_redundant_frame_updates() -> void:
|
||||
var player = load("res://scripts/ui/question_player.gd").new()
|
||||
player.timer_label = Label.new()
|
||||
player._set_timer_display(65)
|
||||
_assert(player.timer_label.text == "01:05", "timer should render a changed second")
|
||||
player.timer_label.text = "unchanged"
|
||||
player._set_timer_display(65)
|
||||
_assert(player.timer_label.text == "unchanged", "timer should not rewrite its label every frame")
|
||||
player._set_timer_display(66)
|
||||
_assert(player.timer_label.text == "01:06", "timer should render the next changed second")
|
||||
player.timer_label.free()
|
||||
player.free()
|
||||
|
||||
|
||||
func _test_player_runtime_construction() -> void:
|
||||
var host := Control.new()
|
||||
host.size = Vector2(1280, 720)
|
||||
root.add_child(host)
|
||||
var player = load("res://scripts/ui/question_player.gd").new()
|
||||
player.main = host
|
||||
@@ -388,7 +425,29 @@ func _test_player_runtime_construction() -> void:
|
||||
_assert(player.theme_button.icon in [Brand.SUN_ICON, Brand.MOON_ICON], "question theme toggle should use the shared sun or moon icon")
|
||||
_assert(player.choice_badges.size() == 5, "question player should construct an answer badge for every visible choice slot")
|
||||
_assert(player.choice_followup_panels[2].get_parent() == player.choice_rows[2], "distractor remediation should remain nested inside its answer choice")
|
||||
var long_choice := "Transfer to a neurocritical-care center, use a titratable agent to avoid severe hypertension and blood-pressure variability without causing hypotension, start enteral nimodipine, and secure the aneurysm as early as feasible."
|
||||
player.choice_buttons[0].text = long_choice
|
||||
await process_frame
|
||||
await process_frame
|
||||
_assert(player.choice_buttons[0].autowrap_mode == TextServer.AUTOWRAP_WORD_SMART, "long answer choices should use word-smart wrapping")
|
||||
_assert(player.choice_buttons[0].size.y > 68.0, "a wrapped answer choice should grow taller than the standard answer row")
|
||||
_assert(player.choice_buttons[0].size.x <= player.question_panel.size.x, "long answer choices should not widen the question player")
|
||||
_assert(player.next_button.get_global_rect().end.x <= player.get_global_rect().end.x + 0.5, "long answer choices should never push Submit beyond the viewport")
|
||||
var prior_session_kind: String = root.get_node("BlockSessionService").session_kind
|
||||
_assert(root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "A", "isSubmitted": null}), "legacy answered simulation responses should restore as submitted")
|
||||
_assert(not root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "A", "isSubmitted": false}), "new simulation drafts should remain editable after an in-session autosave")
|
||||
_assert(not root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "", "isSubmitted": null}), "legacy skipped simulation responses should remain revisitable")
|
||||
root.get_node("BlockSessionService").session_kind = "simulation"
|
||||
player.presented_choices = [{"id": "A", "text": "First"}, {"id": "B", "text": "Second"}]
|
||||
player.current_answer = {"selected_choice_id": "A", "is_submitted": true}
|
||||
player.selected_choice = "A"
|
||||
player._apply_saved_choice_state()
|
||||
_assert(player.choice_buttons[0].disabled and player.choice_buttons[1].disabled, "resumed submitted simulation answers should lock every choice")
|
||||
_assert(not player.next_button.disabled, "a resumed submitted answer should still allow navigation to the next question")
|
||||
player._select_choice(1)
|
||||
_assert(player.selected_choice == "A", "a resumed submitted simulation answer should not be changeable")
|
||||
player.current_answer = {}
|
||||
player.selected_choice = ""
|
||||
for session_kind in ["block", "core", "bookmark", "review", "simulation"]:
|
||||
root.get_node("BlockSessionService").session_kind = session_kind
|
||||
player._refresh_toolbar_state()
|
||||
|
||||
@@ -15,6 +15,18 @@ func _run() -> void:
|
||||
service.simulation = {"id": "active", "state": "between_blocks", "currentBlock": 2, "totalBlocks": 12, "createdUtc": "2026-07-16T22:04:00Z"}
|
||||
service.history = [{"id": "completed", "state": "completed", "currentBlock": 11, "totalBlocks": 12, "createdUtc": "2026-07-15T20:35:00Z", "predictedScore": 236, "rangeLow": 221, "rangeHigh": 248, "passProbability": 0.82}]
|
||||
service.history_next_cursor = "next-page"
|
||||
_assert(service._is_stale_submit_failure("submit"), "a submit failure after reaching between-block state should be ignored")
|
||||
service.simulation["state"] = "completed"
|
||||
_assert(service._is_stale_submit_failure("submit"), "a submit failure after completion should be ignored")
|
||||
_assert(not service._is_stale_submit_failure("save"), "non-submit failures should still be surfaced")
|
||||
service.simulation["state"] = "block_active"
|
||||
service.submit_recovery_attempts = 0
|
||||
_assert(service._should_reconcile_submit_failure("submit", 409), "an active-block submit conflict should reconcile automatically")
|
||||
service.submit_recovery_attempts = 1
|
||||
_assert(not service._should_reconcile_submit_failure("submit", 409), "submit conflict recovery should be bounded")
|
||||
_assert(not service._should_reconcile_submit_failure("submit", 500), "non-conflict submit failures should still be surfaced")
|
||||
service.submit_recovery_attempts = 0
|
||||
service.simulation["state"] = "between_blocks"
|
||||
var host := Control.new()
|
||||
host.size = Vector2(1400, 800)
|
||||
root.add_child(host)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\api\QuestionsApi\QuestionsApi.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
|
||||
const string accessedOn = "2026-07-30";
|
||||
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
|
||||
var connectionString = Environment.GetEnvironmentVariable("STEP3_MONGODB_CONNECTION_STRING")
|
||||
?? "mongodb://192.168.86.50:27017";
|
||||
var databaseName = Environment.GetEnvironmentVariable("STEP3_MONGODB_DATABASE")
|
||||
?? "promiscuity";
|
||||
|
||||
var questions = SeedCatalog.All.Select(Build).ToList();
|
||||
var errors = questions
|
||||
.Select(question => (question.ExternalId, Error: ValidateAuthoredQuestion(question)))
|
||||
.Where(result => result.Error is not null)
|
||||
.ToList();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors) Console.Error.WriteLine($"{error.ExternalId}: {error.Error}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (questions.Count != 50 || questions.Select(q => q.ExternalId).Distinct().Count() != 50)
|
||||
{
|
||||
Console.Error.WriteLine("Expected exactly 50 unique questions.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var grouped = questions
|
||||
.GroupBy(question => question.System)
|
||||
.OrderBy(group => group.Key)
|
||||
.Select(group => $"{group.Key}: {group.Count()}");
|
||||
Console.WriteLine($"Validated {questions.Count} difficulty-4 gap questions.");
|
||||
Console.WriteLine(string.Join(Environment.NewLine, grouped));
|
||||
if (!apply)
|
||||
{
|
||||
Console.WriteLine("Dry run only. Pass --apply to upsert into the configured development database.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MongoDB:ConnectionString"] = connectionString,
|
||||
["MongoDB:DatabaseName"] = databaseName
|
||||
}).Build();
|
||||
var store = new QuestionStore(new MongoDatabaseProvider(configuration));
|
||||
var upserted = await store.UpsertManyByExternalIdAsync(questions);
|
||||
var saved = await store.GetByExternalIdsAsync(questions.Select(q => q.ExternalId));
|
||||
var invalidSaved = saved.Values.Where(q =>
|
||||
q.Difficulty != 4
|
||||
|| !q.IsPublished
|
||||
|| q.Media.Count != 0
|
||||
|| q.References.Count == 0
|
||||
|| q.FlaggedForReviewUtc is not null).ToList();
|
||||
if (upserted != 50 || saved.Count != 50 || invalidSaved.Count != 0)
|
||||
{
|
||||
Console.Error.WriteLine($"Verification failed: upserted={upserted}, found={saved.Count}, invalid={invalidSaved.Count}.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Upserted and verified 50 questions in database '{databaseName}'.");
|
||||
return 0;
|
||||
|
||||
string? ValidateAuthoredQuestion(UpsertQuestionRequest question)
|
||||
{
|
||||
var schemaError = QuestionValidator.Validate(question);
|
||||
if (schemaError is not null) return schemaError;
|
||||
if (!question.ExternalId.StartsWith("gap-d4-20260730-", StringComparison.Ordinal))
|
||||
return "externalId must use the gap-d4-20260730 prefix";
|
||||
if (question.Difficulty != 4 || !question.IsPublished)
|
||||
return "question must be published at difficulty 4";
|
||||
if (question.Media.Count != 0)
|
||||
return "gap questions must not contain unreviewed media";
|
||||
if (question.Tags.Count is < 3 or > 6)
|
||||
return "questions require 3-6 tags";
|
||||
if (question.Choices.Count < 5)
|
||||
return "questions require one correct answer and at least four distractors";
|
||||
if (question.ChoiceSelectionPercentages.Count != question.Choices.Count
|
||||
|| question.ChoiceSelectionPercentages.Values.Any(value => value != 0))
|
||||
return "every choice selection percentage must initialize to zero";
|
||||
if (!question.ChoiceExplanations[question.CorrectChoiceId].StartsWith("Correct.", StringComparison.Ordinal))
|
||||
return "correct choice explanation must begin with Correct.";
|
||||
if (question.ChoiceExplanations
|
||||
.Where(entry => entry.Key != question.CorrectChoiceId)
|
||||
.Any(entry => !entry.Value.StartsWith("Incorrect.", StringComparison.Ordinal)))
|
||||
return "incorrect choice explanations must begin with Incorrect.";
|
||||
foreach (var followup in question.WrongAnswerFollowups.Values)
|
||||
{
|
||||
if (followup.Choices.Count != 3 || followup.Choices.Select(choice => choice.Id).SequenceEqual(["A", "B", "C"]) is false)
|
||||
return "wrong-answer follow-ups must use ordered A-C choices";
|
||||
if (followup.Choices.Any(choice =>
|
||||
!question.Stem.Contains(choice.Text, StringComparison.OrdinalIgnoreCase)
|
||||
&& !question.Labs.Any(lab => $"{lab.Name} {lab.Value}".Contains(choice.Text, StringComparison.OrdinalIgnoreCase))))
|
||||
return "wrong-answer follow-up choices must be traceable to the stem or labs";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
UpsertQuestionRequest Build(Seed seed)
|
||||
{
|
||||
var ids = Enumerable.Range(0, seed.Answers.Length)
|
||||
.Select(index => ((char)('A' + index)).ToString())
|
||||
.ToArray();
|
||||
var choices = ids.Zip(seed.Answers, (id, text) => new QuestionChoice { Id = id, Text = text }).ToList();
|
||||
var choiceExplanations = ids.ToDictionary(
|
||||
id => id,
|
||||
id => id == "A"
|
||||
? $"Correct. {seed.Explanation}"
|
||||
: $"Incorrect. {choices.Single(choice => choice.Id == id).Text} does not adequately address the decisive finding: {seed.KeyFact}. {seed.Takeaway}");
|
||||
var wrongFollowups = ids.Skip(1).ToDictionary(
|
||||
id => id,
|
||||
id => new WrongAnswerFollowup
|
||||
{
|
||||
Prompt = $"Which finding in this vignette most strongly supports {seed.Answers[0]}?",
|
||||
Choices =
|
||||
[
|
||||
new() { Id = "A", Text = seed.KeyFact },
|
||||
new() { Id = "B", Text = seed.ContextFact },
|
||||
new() { Id = "C", Text = seed.NeutralFact }
|
||||
],
|
||||
CorrectChoiceId = "A",
|
||||
TeachingPoint = seed.Takeaway
|
||||
});
|
||||
|
||||
return new UpsertQuestionRequest
|
||||
{
|
||||
ExternalId = $"gap-d4-20260730-{seed.Slug}",
|
||||
Stem = seed.Stem,
|
||||
LeadIn = seed.LeadIn,
|
||||
Choices = choices,
|
||||
CorrectChoiceId = "A",
|
||||
Explanation = $"Correct. {seed.Explanation}",
|
||||
KeyTakeaway = seed.Takeaway,
|
||||
UsmlePearl = seed.Pearl,
|
||||
ReviewFollowups =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Prompt = "Which principle best generalizes from this case?",
|
||||
Choices =
|
||||
[
|
||||
new() { Id = "A", Text = seed.Takeaway },
|
||||
new() { Id = "B", Text = seed.ReviewDistractor1 },
|
||||
new() { Id = "C", Text = seed.ReviewDistractor2 }
|
||||
],
|
||||
CorrectChoiceId = "A",
|
||||
TeachingPoint = seed.Takeaway
|
||||
}
|
||||
],
|
||||
ChoiceExplanations = choiceExplanations,
|
||||
WrongAnswerFollowups = wrongFollowups,
|
||||
ChoiceSelectionPercentages = ids.ToDictionary(id => id, _ => 0),
|
||||
Discipline = seed.Discipline,
|
||||
System = seed.System,
|
||||
PerformanceCategory = seed.PerformanceCategory,
|
||||
Difficulty = 4,
|
||||
Labs = [],
|
||||
Media = [],
|
||||
Tags = [seed.TopicTag, "difficulty 4", "gap coverage", seed.Discipline.ToLowerInvariant()],
|
||||
References =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Title = seed.ReferenceTitle,
|
||||
Publisher = seed.ReferencePublisher,
|
||||
Url = seed.ReferenceUrl,
|
||||
Supports = seed.ReferenceSupports,
|
||||
AccessedOn = accessedOn,
|
||||
Reviewer = "OpenAI Codex medical content review"
|
||||
}
|
||||
],
|
||||
IsPublished = true
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record Seed(
|
||||
string Slug,
|
||||
string System,
|
||||
string Discipline,
|
||||
string PerformanceCategory,
|
||||
string TopicTag,
|
||||
string Stem,
|
||||
string LeadIn,
|
||||
string[] Answers,
|
||||
string Explanation,
|
||||
string Takeaway,
|
||||
string Pearl,
|
||||
string KeyFact,
|
||||
string ContextFact,
|
||||
string NeutralFact,
|
||||
string ReviewDistractor1,
|
||||
string ReviewDistractor2,
|
||||
string ReferenceTitle,
|
||||
string ReferencePublisher,
|
||||
string ReferenceUrl,
|
||||
string ReferenceSupports);
|
||||
@@ -0,0 +1,555 @@
|
||||
internal static class SeedCatalog
|
||||
{
|
||||
internal static readonly Seed[] All =
|
||||
[
|
||||
new("acute-compartment-syndrome-fasciotomy", "Musculoskeletal System", "Surgery", "Musculoskeletal/Skin & Subcutaneous Tissue", "compartment syndrome",
|
||||
"A 34-year-old man has escalating lower-leg pain after tibial fracture fixation. The pain is severe with passive toe extension. The anterior compartment is tense, pedal pulses remain palpable, and analgesics no longer provide relief.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Immediate four-compartment fasciotomy", "CT angiography of the leg", "Elevation above heart level and observation", "Increase opioid analgesia", "Wait for loss of pedal pulses", "Serial creatine kinase measurement without decompression"],
|
||||
"Progressive pain with passive stretch and a tense compartment after fracture indicates acute compartment syndrome. Definitive treatment is immediate complete fasciotomy; palpable pulses do not exclude dangerous compartment pressure.",
|
||||
"Treat clinically apparent acute compartment syndrome with urgent complete decompression before irreversible muscle and nerve injury occurs.",
|
||||
"Waiting for pulselessness diagnoses a late, limb-threatening stage.",
|
||||
"pain is severe with passive toe extension", "pedal pulses remain palpable", "the anterior compartment is tense",
|
||||
"Palpable pulses exclude compartment syndrome", "Imaging should delay decompression until vascular injury is proven",
|
||||
"Management of Acute Compartment Syndrome", "American Academy of Orthopaedic Surgeons", "https://www.aaos.org/acscpg2025", "Diagnosis and urgent complete fasciotomy for acute compartment syndrome."),
|
||||
|
||||
new("diabetic-foot-osteomyelitis-bone-culture", "Musculoskeletal System", "Medicine", "Musculoskeletal/Skin & Subcutaneous Tissue", "diabetic foot osteomyelitis",
|
||||
"A 62-year-old man with diabetes has an ulcer beneath the first metatarsal head that probes to bone. Plain radiographs show cortical erosion. A superficial swab grows mixed skin flora, he is hemodynamically stable, and no drainable abscess is present.",
|
||||
"Which test best identifies the pathogen responsible for the suspected osteomyelitis?",
|
||||
["Percutaneous or intraoperative bone culture", "Repeat superficial swab culture", "Serum procalcitonin alone", "Blood culture after antibiotics", "Tagged leukocyte scan without tissue sampling", "Empiric lifelong antibiotics without microbiologic sampling"],
|
||||
"When diabetic foot osteomyelitis is suspected, a bone specimen rather than a superficial soft-tissue swab provides the most reliable microbiologic diagnosis and guides targeted therapy.",
|
||||
"Obtain bone for culture when defining the causative organism in diabetic foot osteomyelitis.",
|
||||
"Superficial wound flora may not represent organisms infecting bone.",
|
||||
"probes to bone", "a superficial swab grows mixed skin flora", "no drainable abscess is present",
|
||||
"A superficial swab reliably identifies bone pathogens", "Imaging alone supplies antimicrobial susceptibility data",
|
||||
"IWGDF/IDSA Guidelines on Diabetes-Related Foot Infections", "Infectious Diseases Society of America", "https://www.idsociety.org/practice-guideline/diabetic-foot-infections/", "Bone rather than soft-tissue sampling for suspected diabetic foot osteomyelitis."),
|
||||
|
||||
new("native-septic-arthritis-drainage", "Musculoskeletal System", "Medicine", "Musculoskeletal/Skin & Subcutaneous Tissue", "septic arthritis",
|
||||
"A 71-year-old woman receiving adalimumab develops abrupt knee pain and inability to bear weight. The knee is warm with a large effusion. Arthrocentesis yields opaque fluid with gram-positive cocci, blood pressure is stable, and radiographs show no fracture.",
|
||||
"Which management is most appropriate?",
|
||||
["Prompt joint drainage plus intravenous antibiotics", "Intra-articular glucocorticoid injection", "Oral colchicine alone", "Delay treatment for final culture susceptibilities", "Physical therapy without antimicrobial treatment", "Arthroscopic inspection without drainage or antibiotics"],
|
||||
"Purulent synovial fluid with organisms establishes septic arthritis. Rapid antimicrobial therapy and evacuation of infected joint fluid are needed to limit cartilage destruction and systemic spread.",
|
||||
"Septic arthritis requires prompt antimicrobial treatment and drainage of the infected joint.",
|
||||
"Immunosuppression can blunt systemic findings despite destructive joint infection.",
|
||||
"arthrocentesis yields opaque fluid with gram-positive cocci", "blood pressure is stable", "radiographs show no fracture",
|
||||
"Stable blood pressure permits delayed treatment", "Steroid injection is safe before infection is controlled",
|
||||
"Guideline for Management of Septic Arthritis in Native Joints", "European Bone and Joint Infection Society", "https://jbji.copernicus.org/articles/8/29/2023/", "Prompt drainage and antimicrobial therapy for native-joint septic arthritis."),
|
||||
|
||||
new("open-fracture-early-antibiotics", "Musculoskeletal System", "Emergency Medicine", "Musculoskeletal/Skin & Subcutaneous Tissue", "open fracture",
|
||||
"A 27-year-old motorcyclist has a tibial fracture with bone visible through a contaminated wound. Distal perfusion is intact. Sterile saline-moistened dressing and splinting are applied, tetanus status is unknown, and operative irrigation is being arranged.",
|
||||
"Which intervention should occur immediately?",
|
||||
["Administer systemic antibiotic prophylaxis", "Close the wound primarily in the emergency department", "Delay antibiotics until operative cultures return", "Probe the wound repeatedly to assess depth", "Apply a circumferential cast over the wound", "Provide tetanus prophylaxis as the only infection-prevention measure"],
|
||||
"An open fracture is contaminated at presentation. Systemic antibiotics should be administered as early as possible, alongside tetanus prophylaxis and urgent operative debridement planning.",
|
||||
"Give early systemic antibiotics for an open fracture; do not wait for operative cultures or debridement.",
|
||||
"A sterile dressing protects the wound but does not replace antimicrobial prophylaxis.",
|
||||
"bone visible", "distal perfusion is intact", "operative irrigation is being arranged",
|
||||
"Intact perfusion eliminates infection risk", "Cultures must precede the first antibiotic dose",
|
||||
"Prevention of Surgical Site Infections After Major Extremity Trauma", "American Academy of Orthopaedic Surgeons", "https://www.aaos.org/surgical-site-infections-after-major-extremity-trauma/", "Early antibiotic prophylaxis and operative management of open extremity fractures."),
|
||||
|
||||
new("dermatomyositis-malignancy-screening", "Musculoskeletal System", "Rheumatology", "Musculoskeletal/Skin & Subcutaneous Tissue", "dermatomyositis",
|
||||
"A 58-year-old woman has progressive proximal weakness, a violaceous eyelid eruption, and scaly papules over the knuckles. Electromyography supports inflammatory myopathy. She has unintentional weight loss, routine age-appropriate screening is incomplete, and there is no medication trigger.",
|
||||
"Which additional evaluation is most important?",
|
||||
["Risk-stratified screening for occult malignancy", "Testing only for Lyme disease", "Immediate muscle biopsy before any cancer evaluation", "Reassurance that the condition is never paraneoplastic", "Long-term antibiotics for presumed cellulitis", "Repeat electromyography yearly without age-appropriate screening"],
|
||||
"Adult-onset dermatomyositis is associated with malignancy, particularly near disease onset. Clinical risk assessment and appropriate enhanced plus age- and sex-based screening are indicated.",
|
||||
"Evaluate adults with newly diagnosed dermatomyositis for occult malignancy using risk-stratified screening.",
|
||||
"Cancer evaluation complements rather than replaces confirmation and treatment of inflammatory myopathy.",
|
||||
"progressive proximal weakness", "she has unintentional weight loss", "scaly papules over the knuckles",
|
||||
"Dermatomyositis has no association with malignancy", "Cancer screening should wait until weakness resolves",
|
||||
"International Guideline for Idiopathic Inflammatory Myopathy-Associated Cancer Screening", "International Myositis Assessment and Clinical Studies Group", "https://www.nature.com/articles/s41584-023-01045-w", "Risk-stratified malignancy screening in adults with idiopathic inflammatory myopathy."),
|
||||
|
||||
new("prosthetic-joint-acute-da-ir", "Musculoskeletal System", "Surgery", "Musculoskeletal/Skin & Subcutaneous Tissue", "prosthetic joint infection",
|
||||
"A 66-year-old man develops acute pain and drainage from a knee arthroplasty placed 3 weeks ago. Symptoms began 2 days ago. The implant is stable, the soft-tissue envelope is intact, aspiration grows methicillin-susceptible Staphylococcus aureus, and there is no sinus tract.",
|
||||
"Which surgical strategy is most appropriate?",
|
||||
["Debridement with implant retention and modular-component exchange", "No surgery because the implant is stable", "Immediate amputation", "Chronic suppressive antibiotics without source control", "Delay intervention until the prosthesis loosens"],
|
||||
"A stable, recently implanted prosthesis with very short symptom duration and no sinus tract is a setting in which debridement, antibiotics, implant retention, and exchange of modular components can be appropriate.",
|
||||
"Early acute prosthetic-joint infection with a stable implant requires prompt source control; selected patients can undergo debridement and implant retention.",
|
||||
"A sinus tract or prolonged symptoms would make retention less likely to succeed.",
|
||||
"symptoms began 2 days ago", "the implant is stable", "there is no sinus tract",
|
||||
"Stable hardware means surgery is unnecessary", "Waiting for loosening improves implant-retention success",
|
||||
"Diagnosis and Management of Prosthetic Joint Infection", "Infectious Diseases Society of America", "https://www.idsociety.org/practice-guideline/prosthetic-joint-infection/", "Selection of debridement and implant retention for early acute prosthetic-joint infection."),
|
||||
|
||||
new("cauda-equina-urgent-decompression", "Musculoskeletal System", "Surgery", "Nervous System & Special Senses", "cauda equina syndrome",
|
||||
"A 49-year-old warehouse worker with a known lumbar disk herniation develops bilateral leg weakness and numbness around the perineum. He cannot initiate urination, rectal tone is reduced, and symptoms progressed over 6 hours.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Emergency lumbar MRI and surgical decompression", "Routine outpatient physical therapy", "Lumbar epidural steroid injection before imaging", "Oral analgesics with follow-up in one month", "Electromyography before spinal imaging", "Bladder training before evaluating the spinal canal"],
|
||||
"Acute urinary retention, saddle anesthesia, reduced rectal tone, and bilateral deficits indicate cauda equina syndrome. Emergency MRI and prompt decompression are required.",
|
||||
"Treat suspected cauda equina syndrome as a surgical emergency requiring immediate imaging and decompression.",
|
||||
"Do not attribute new bladder dysfunction to pain medication without excluding compressive neurologic injury.",
|
||||
"he cannot initiate urination", "rectal tone is reduced", "bilateral leg weakness",
|
||||
"Outpatient therapy is appropriate despite bladder dysfunction", "Electrodiagnostic testing should precede emergency imaging",
|
||||
"Suspected Neurological Conditions: Cauda Equina Syndrome", "National Institute for Health and Care Excellence", "https://www.nice.org.uk/guidance/ng127", "Emergency recognition and referral for cauda equina syndrome."),
|
||||
|
||||
new("rhabdomyolysis-volume-resuscitation", "Musculoskeletal System", "Emergency Medicine", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "rhabdomyolysis",
|
||||
"A 41-year-old man is rescued after his thigh was compressed beneath machinery for 7 hours. Urine is dark, the thigh is swollen but compartments remain soft, electrocardiography shows no hyperkalemic changes, and he is making urine.",
|
||||
"Which intervention best reduces the risk of acute kidney injury?",
|
||||
["Early intravenous isotonic crystalloid resuscitation", "Routine bicarbonate and mannitol for every patient", "Fluid restriction", "Immediate dialysis despite preserved urine output", "High-dose loop diuretics as sole therapy", "Delay fluids until urine myoglobin is quantified"],
|
||||
"Crush-related rhabdomyolysis releases myoglobin and causes intravascular depletion. Early goal-directed isotonic crystalloid is the primary kidney-protective treatment.",
|
||||
"Early isotonic crystalloid resuscitation is central to preventing rhabdomyolysis-associated acute kidney injury.",
|
||||
"Bicarbonate, mannitol, and diuretics are not routine substitutes for adequate volume resuscitation.",
|
||||
"urine is dark", "he is making urine", "compartments remain soft",
|
||||
"Preserved urine output eliminates kidney-injury risk", "Routine mannitol is superior to crystalloid",
|
||||
"Rhabdomyolysis Clinical Consensus Document", "American Association for the Surgery of Trauma", "https://pmc.ncbi.nlm.nih.gov/articles/PMC8804685/", "Fluid resuscitation and renal-protection strategy in rhabdomyolysis."),
|
||||
|
||||
new("neonatal-hsv-empiric-acyclovir", "Nervous System", "Pediatrics", "Nervous System & Special Senses", "neonatal herpes",
|
||||
"A 12-day-old infant has poor feeding, focal seizures, and grouped vesicles on the scalp. The mother had no recognized genital lesions. Cerebrospinal fluid testing is pending, the infant remains perfused, and bacterial cultures have been collected.",
|
||||
"Which treatment should be started now?",
|
||||
["Intravenous acyclovir", "Wait for polymerase-chain-reaction confirmation", "Topical acyclovir only", "Oral valacyclovir", "Glucocorticoids without antiviral therapy", "Phototherapy for the vesicular eruption"],
|
||||
"Neonatal herpes can rapidly cause encephalitis or disseminated disease, and maternal history is often absent. Empiric intravenous acyclovir should begin immediately when clinical findings suggest infection.",
|
||||
"Start empiric intravenous acyclovir promptly when neonatal herpes is suspected; do not await confirmatory testing.",
|
||||
"Absence of known maternal lesions does not meaningfully exclude neonatal HSV.",
|
||||
"focal seizures", "grouped vesicles on the scalp", "the mother had no recognized genital lesions",
|
||||
"Maternal history reliably excludes neonatal HSV", "Topical therapy treats suspected neonatal encephalitis",
|
||||
"Sexually Transmitted Infections Treatment Guidelines: Genital Herpes", "Centers for Disease Control and Prevention", "https://www.cdc.gov/std/treatment-guidelines/herpes.htm", "Recognition and intravenous acyclovir treatment of neonatal herpes."),
|
||||
|
||||
new("pediatric-intussusception-air-enema", "Gastrointestinal System", "Pediatrics", "Gastrointestinal System", "intussusception",
|
||||
"A 10-month-old boy has episodic screaming with leg flexion and vomiting. Between episodes he appears tired. Ultrasonography shows a target-shaped ileocolic mass. The abdomen has no guarding, there is no free air, and he is hemodynamically stable.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Pneumatic enema reduction", "Immediate laparotomy without attempted reduction", "Upper endoscopy", "Oral laxatives and discharge", "Observation for spontaneous resolution"],
|
||||
"Stable ileocolic intussusception without perforation or peritonitis is treated with image-guided pneumatic or hydrostatic enema reduction.",
|
||||
"Use enema reduction first for uncomplicated, stable ileocolic intussusception.",
|
||||
"Perforation, peritonitis, shock, or failed enema reduction requires operative management.",
|
||||
"ultrasonography shows a target-shaped ileocolic mass", "there is no free air", "he is hemodynamically stable",
|
||||
"Every intussusception requires immediate laparotomy", "A stable child should be observed without reduction",
|
||||
"Management of Intussusception in Children", "American College of Radiology", "https://acsearch.acr.org/docs/69451/Narrative/", "Imaging diagnosis and enema reduction of uncomplicated pediatric intussusception."),
|
||||
|
||||
new("pyloric-stenosis-correct-electrolytes", "Gastrointestinal System", "Pediatrics", "Gastrointestinal System", "pyloric stenosis",
|
||||
"A 5-week-old boy has progressive projectile nonbilious vomiting and visible gastric peristalsis. Ultrasonography confirms hypertrophic pyloric stenosis. He is dehydrated, capillary refill is delayed, and a chloride-responsive metabolic alkalosis is present.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Intravenous fluid and electrolyte correction before pyloromyotomy", "Immediate pyloromyotomy before resuscitation", "Emergency bowel resection", "Oral feeding challenge", "Acid suppression alone"],
|
||||
"Pyloric stenosis requires pyloromyotomy, but the metabolic and volume abnormalities must first be corrected because the operation is urgent rather than immediately emergent.",
|
||||
"Resuscitate and correct electrolyte abnormalities before pyloromyotomy for hypertrophic pyloric stenosis.",
|
||||
"The nonbilious character localizes obstruction proximal to the duodenum but does not eliminate anesthetic risk from alkalosis.",
|
||||
"projectile nonbilious vomiting", "he is dehydrated", "a chloride-responsive metabolic alkalosis is present",
|
||||
"Surgery must precede correction of dehydration", "Acid suppression reverses muscular hypertrophy",
|
||||
"Appropriateness Criteria: Vomiting in Infants", "American College of Radiology", "https://acsearch.acr.org/docs/69445/Narrative/", "Diagnosis and stabilization priorities in infantile hypertrophic pyloric stenosis."),
|
||||
|
||||
new("pediatric-dka-cerebral-injury-hyperosmolar", "Endocrine System", "Pediatrics", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "pediatric DKA",
|
||||
"A 13-year-old girl is receiving protocolized treatment for diabetic ketoacidosis. Six hours later she develops severe headache, bradycardia, declining consciousness, and new urinary incontinence. Her glucose is falling gradually, blood pressure is preserved, and no sedating medication was given.",
|
||||
"Which treatment should be administered immediately?",
|
||||
["Hypertonic saline or mannitol for suspected cerebral injury", "Increase the insulin infusion sharply", "Administer intravenous bicarbonate routinely", "Obtain lumbar puncture before treatment", "Stop all intravenous fluids permanently", "Wait for head CT confirmation before osmotherapy"],
|
||||
"Neurologic deterioration with headache and bradycardia during pediatric DKA treatment indicates cerebral injury. Hyperosmolar therapy should be given immediately without waiting for imaging.",
|
||||
"Treat suspected cerebral injury during pediatric DKA immediately with hypertonic saline or mannitol.",
|
||||
"A gradual glucose decline does not exclude DKA-related cerebral injury.",
|
||||
"she develops severe headache", "bradycardia", "declining consciousness",
|
||||
"Imaging confirmation must precede hyperosmolar therapy", "More insulin reverses cerebral edema",
|
||||
"Diabetes in Children and Young People: Recommendations", "National Institute for Health and Care Excellence", "https://www.nice.org.uk/guidance/ng18/chapter/Recommendations", "Recognition and emergency hyperosmolar treatment of cerebral edema during pediatric DKA."),
|
||||
|
||||
new("sickle-acute-chest-exchange-transfusion", "Respiratory System", "Pediatrics", "Respiratory System", "acute chest syndrome",
|
||||
"A 16-year-old boy with sickle cell disease is admitted for acute chest syndrome. Despite oxygen, antibiotics, incentive spirometry, and simple transfusion, hypoxemia worsens and pulmonary infiltrates progress to multiple lobes. He is tachypneic, hemoglobin has risen after transfusion, and respiratory effort is increasing.",
|
||||
"Which treatment is most appropriate now?",
|
||||
["Urgent red-cell exchange transfusion", "Repeat simple transfusions without limit", "Discontinue antibiotics", "Therapeutic phlebotomy alone", "Delay escalation until mechanical ventilation is required"],
|
||||
"Rapidly progressive, severe acute chest syndrome despite initial therapy is an indication for exchange transfusion, which lowers the sickle hemoglobin fraction without excessive viscosity.",
|
||||
"Use exchange transfusion for severe or rapidly progressive acute chest syndrome.",
|
||||
"Repeated simple transfusion after hemoglobin has risen can increase viscosity.",
|
||||
"hypoxemia worsens", "pulmonary infiltrates progress to multiple lobes", "hemoglobin has risen after transfusion",
|
||||
"Exchange transfusion should wait for intubation", "Higher viscosity improves pulmonary perfusion",
|
||||
"ASH Guidelines for Sickle Cell Disease: Transfusion Support", "American Society of Hematology", "https://ashpublications.org/bloodadvances/article/4/2/327/440607/American-Society-of-Hematology-2020-guidelines-for", "Exchange transfusion for severe acute chest syndrome."),
|
||||
|
||||
new("child-abuse-sentinel-injury-evaluation", "Multisystem", "Pediatrics", "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety", "child abuse",
|
||||
"A 4-month-old nonmobile infant is brought for a small bruise on the cheek. The caregiver reports that the infant rolled into a table, but developmental history confirms he cannot roll. A healing posterior rib fracture is seen incidentally, the infant is calm, and the caregiver asks to leave.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Initiate a complete abuse evaluation and ensure immediate safety", "Discharge because the bruise is small", "Wait for another injury before reporting concern", "Confront the caregiver and permit departure", "Document only the current bruise", "Ask the caregiver to arrange outpatient skeletal imaging", "Report only if the caregiver confesses"],
|
||||
"Bruising in a nonmobile infant and a posterior rib fracture are sentinel injuries highly concerning for inflicted trauma. The clinician must evaluate for occult injury, report suspicion, and establish safety.",
|
||||
"Sentinel injuries in a nonmobile infant require prompt evaluation for abuse and immediate protection.",
|
||||
"A benign appearance does not exclude occult intracranial, skeletal, or abdominal injury.",
|
||||
"a small bruise on the cheek", "he cannot roll", "a healing posterior rib fracture",
|
||||
"Minor bruising in a nonmobile infant is expected", "Reporting requires proof rather than reasonable suspicion",
|
||||
"Child Maltreatment: A Guide for Prevention", "Centers for Disease Control and Prevention", "https://www.cdc.gov/child-abuse-neglect/about/index.html", "Recognition, reporting, and safety response to suspected child maltreatment."),
|
||||
|
||||
new("pediatric-septic-hip-aspiration", "Musculoskeletal System", "Pediatrics", "Musculoskeletal/Skin & Subcutaneous Tissue", "pediatric septic arthritis",
|
||||
"A 6-year-old boy refuses to bear weight and holds the hip flexed and externally rotated. He has acute pain with passive motion and an effusion on ultrasonography. Blood cultures are obtained, there was no trauma, and inflammatory markers are markedly elevated.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Urgent joint aspiration followed by drainage and antibiotics", "Discharge with ibuprofen for transient synovitis", "Intra-articular glucocorticoid injection", "Wait one week for repeat ultrasonography", "Begin physical therapy"],
|
||||
"The inability to bear weight, painful restricted motion, effusion, and inflammation make septic arthritis likely. Prompt aspiration for microbiology and urgent drainage plus antibiotics protect the joint.",
|
||||
"Suspected bacterial arthritis of the hip requires urgent aspiration and source control.",
|
||||
"Transient synovitis should not be assumed when high-risk inflammatory features are present.",
|
||||
"refuses to bear weight", "an effusion on ultrasonography", "inflammatory markers are markedly elevated",
|
||||
"Ultrasonographic effusion proves transient synovitis", "Antibiotics should wait a week in a stable child",
|
||||
"PIDS/IDSA Guideline for Acute Bacterial Arthritis in Pediatrics", "Infectious Diseases Society of America", "https://www.idsociety.org/practice-guideline/acute-bacterial-arthritis-in-pediatrics2/", "Diagnostic aspiration, drainage, and antimicrobial treatment of pediatric bacterial arthritis."),
|
||||
|
||||
new("neonatal-bilious-emesis-volvulus", "Gastrointestinal System", "Pediatrics", "Gastrointestinal System", "malrotation volvulus",
|
||||
"A 3-day-old newborn develops sudden bilious emesis and abdominal distention. He becomes lethargic with poor perfusion. The abdomen is tender, the anus is patent, and prenatal ultrasonography was normal.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Immediate surgical consultation and operative management for volvulus", "Discharge after changing formula", "Pyloromyotomy", "Barium enema as definitive treatment", "Observe until stool passage"],
|
||||
"Bilious vomiting with shock and abdominal tenderness in a neonate suggests midgut volvulus from malrotation. Resuscitation and emergency operative evaluation must not be delayed.",
|
||||
"Treat a neonate with suspected midgut volvulus as a surgical emergency.",
|
||||
"Normal prenatal imaging does not exclude malrotation.",
|
||||
"sudden bilious emesis", "poor perfusion", "the abdomen is tender",
|
||||
"Bilious emesis is typical of pyloric stenosis", "Normal prenatal imaging excludes volvulus",
|
||||
"Appropriateness Criteria: Vomiting in Infants", "American College of Radiology", "https://acsearch.acr.org/docs/69445/Narrative/", "Emergency evaluation of neonatal bilious vomiting and suspected malrotation with volvulus."),
|
||||
|
||||
new("acute-angle-closure-immediate-pressure-lowering", "Eye & Adnexa", "Ophthalmology", "Nervous System & Special Senses", "angle closure glaucoma",
|
||||
"A 64-year-old woman develops severe unilateral eye pain, headache, nausea, and halos after entering a dark theater. The affected eye is red with a cloudy cornea and a fixed mid-dilated pupil. Visual acuity is reduced, there was no trauma, and the other eye is comfortable.",
|
||||
"Which is the most appropriate immediate management?",
|
||||
["Rapid medical lowering of intraocular pressure followed by laser iridotomy", "Topical antibiotic drops only", "Eye patching and outpatient observation", "Immediate cataract refraction", "Systemic glucocorticoids alone"],
|
||||
"This is acute angle-closure glaucoma, an ophthalmic emergency. Pressure-lowering medications are started immediately, followed by definitive laser peripheral iridotomy when the cornea and pressure permit.",
|
||||
"Acute angle closure requires immediate intraocular-pressure reduction and definitive iridotomy.",
|
||||
"A fixed mid-dilated pupil with a cloudy painful eye is not uncomplicated conjunctivitis.",
|
||||
"a fixed mid-dilated pupil", "the affected eye is red", "visual acuity is reduced",
|
||||
"Antibiotic drops open the anterior chamber angle", "Observation prevents optic-nerve ischemia",
|
||||
"Primary Angle Closure Disease Preferred Practice Pattern", "American Academy of Ophthalmology", "https://www.aao.org/education/preferred-practice-pattern/primary-angle-closure-disease-ppp", "Emergency pressure reduction and definitive iridotomy for acute angle closure."),
|
||||
|
||||
new("retinal-detachment-urgent-repair", "Eye & Adnexa", "Ophthalmology", "Nervous System & Special Senses", "retinal detachment",
|
||||
"A 59-year-old highly myopic man reports flashes, many new floaters, and a curtain descending across the right visual field. The eye is painless. Dilated examination shows a superior retinal tear with detachment approaching the macula, central acuity remains intact, and there is no redness.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Same-day retinal-surgery evaluation for repair", "Routine optometry appointment in six months", "Topical antibiotics", "Carotid endarterectomy", "Reassurance for ocular migraine"],
|
||||
"A symptomatic retinal tear with a macula-threatening detachment requires urgent retinal specialist repair to preserve central vision.",
|
||||
"Urgently repair a macula-on or macula-threatening retinal detachment.",
|
||||
"Painless symptoms do not make a retinal detachment benign.",
|
||||
"a curtain descending across the right visual field", "the eye is painless", "central acuity remains intact",
|
||||
"Intact central acuity permits routine follow-up", "Topical therapy reattaches the retina",
|
||||
"Posterior Vitreous Detachment, Retinal Breaks, and Lattice Degeneration PPP", "American Academy of Ophthalmology", "https://www.aao.org/education/preferred-practice-pattern/posterior-vitreous-detachment-retinal-breaks-latti", "Urgent evaluation and repair of symptomatic retinal tears and detachment."),
|
||||
|
||||
new("orbital-cellulitis-iv-antibiotics-imaging", "Eye & Adnexa", "Pediatrics", "Nervous System & Special Senses", "orbital cellulitis",
|
||||
"A 9-year-old child with recent sinusitis has eyelid swelling, pain with eye movement, proptosis, and reduced color vision. The child is ill appearing, visual acuity has declined, there is no penetrating trauma, and the opposite eye is normal.",
|
||||
"Which is the most appropriate management?",
|
||||
["Hospitalize for intravenous antibiotics and urgent orbital imaging", "Treat with topical antibiotic drops at home", "Prescribe an oral antihistamine", "Patch the eye and reassess in one week", "Perform tonometry only"],
|
||||
"Painful extraocular movement, proptosis, and visual dysfunction indicate postseptal orbital cellulitis. Hospitalization, broad intravenous antibiotics, imaging, and ophthalmologic and ENT assessment are required.",
|
||||
"Orbital signs or visual compromise require inpatient intravenous therapy and urgent imaging.",
|
||||
"Preseptal cellulitis does not cause proptosis, ophthalmoplegia, or impaired vision.",
|
||||
"pain with eye movement", "proptosis", "reduced color vision",
|
||||
"Topical drops treat postseptal infection", "Proptosis is typical of allergic swelling",
|
||||
"Orbital Cellulitis", "American Academy of Ophthalmology", "https://eyewiki.aao.org/Orbital_Cellulitis", "Recognition, imaging, intravenous antibiotics, and surgical indications for orbital cellulitis."),
|
||||
|
||||
new("chemical-eye-injury-immediate-irrigation", "Eye & Adnexa", "Emergency Medicine", "Nervous System & Special Senses", "chemical eye injury",
|
||||
"A laboratory worker splashes an alkaline cleaning solution into both eyes. He has intense pain, blepharospasm, and blurred vision. Coworkers bring the product label, contact lenses remain in place, and the exposure occurred 5 minutes ago.",
|
||||
"Which action has the highest priority?",
|
||||
["Begin immediate copious ocular irrigation", "Measure visual acuity before any treatment", "Neutralize the alkali with an acid", "Wait for an ophthalmologist before irrigation", "Apply topical anesthetic and discharge", "Cover both eyes until the product safety sheet is reviewed"],
|
||||
"Chemical ocular injury demands immediate copious irrigation. Treatment begins before a complete examination; contact lenses are removed during irrigation and pH is checked serially.",
|
||||
"Do not delay irrigation of a chemical eye exposure for examination, consultation, or agent identification.",
|
||||
"Attempting chemical neutralization can generate heat and worsen injury.",
|
||||
"an alkaline cleaning solution", "the exposure occurred 5 minutes ago", "contact lenses remain in place",
|
||||
"Visual acuity must precede irrigation", "An acid should be applied to neutralize alkali",
|
||||
"Chemical Injury of the Conjunctiva and Cornea", "American Academy of Ophthalmology", "https://eyewiki.aao.org/Chemical_%28Alkali_and_Acid%29_Injury_of_the_Conjunctiva_and_Cornea", "Immediate irrigation and initial management of ocular chemical burns."),
|
||||
|
||||
new("posterior-epistaxis-airway-packing", "Ear, Nose & Throat", "Emergency Medicine", "Nervous System & Special Senses", "posterior epistaxis",
|
||||
"A 76-year-old man taking apixaban has brisk bleeding into the posterior pharynx from both nostrils. Direct pressure and topical vasoconstrictor have failed. He is coughing blood, blood pressure is falling, and no anterior bleeding point is visible.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Protect the airway and place posterior nasal packing with specialist consultation", "Discharge with humidification", "Continue pinching the nasal bridge indefinitely", "Perform lumbar puncture", "Start intranasal glucocorticoids"],
|
||||
"Ongoing bilateral posterior bleeding with hemodynamic compromise threatens the airway. Resuscitation, airway protection as needed, posterior packing or balloon control, reversal assessment, and urgent specialist management are required.",
|
||||
"Severe posterior epistaxis requires airway and hemodynamic stabilization plus definitive posterior control.",
|
||||
"Posterior packing requires monitored admission because of rebleeding and airway risk.",
|
||||
"brisk bleeding into the posterior pharynx", "blood pressure is falling", "no anterior bleeding point is visible",
|
||||
"Failure to see an anterior point makes discharge safe", "Humidification controls hemorrhagic shock",
|
||||
"Clinical Practice Guideline: Nosebleed", "American Academy of Otolaryngology–Head and Neck Surgery", "https://www.entnet.org/quality-practice/quality-products/clinical-practice-guidelines/cpg-nosebleed/", "Escalation, packing, and specialist management for severe epistaxis."),
|
||||
|
||||
new("peritonsillar-abscess-drainage", "Ear, Nose & Throat", "Medicine", "Nervous System & Special Senses", "peritonsillar abscess",
|
||||
"A 24-year-old woman has worsening unilateral throat pain, trismus, drooling, and a muffled voice. The uvula is displaced away from a bulging tonsillar pillar. She can maintain the airway, oxygenation is normal, and there is no neck trauma.",
|
||||
"Which management is most appropriate?",
|
||||
["Drain the abscess and administer antibiotics", "Treat with throat lozenges alone", "Begin anticoagulation", "Perform immediate thyroidectomy", "Wait for spontaneous rupture without monitoring"],
|
||||
"Trismus, muffled voice, uvular deviation, and a unilateral peritonsillar bulge indicate peritonsillar abscess. Drainage plus antimicrobial therapy is standard when the airway is currently stable.",
|
||||
"Peritonsillar abscess generally requires drainage and antibiotics while airway risk is assessed.",
|
||||
"Drooling or worsening respiratory distress would require immediate airway escalation.",
|
||||
"the uvula is displaced", "she can maintain the airway", "a bulging tonsillar pillar",
|
||||
"Antibiotics are never needed after drainage", "Stable oxygenation makes source control unnecessary",
|
||||
"Pharyngitis and Tonsillitis Including Peritonsillar Abscess", "Oxford University Hospitals NHS Foundation Trust", "https://ouh.quris.com/hospital-guidelines/adult-antimicrobial-guide/empiric-treatment/ent-and-orofacial/pharyngitistonsillitis-including-peritonsillar-abscess-and-group-a-strep-throat-infection/", "Urgent ENT evaluation, source control, and antibiotics for peritonsillar abscess."),
|
||||
|
||||
new("cryptococcal-meningitis-raised-pressure", "Immune System", "Infectious Disease", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "cryptococcal meningitis",
|
||||
"A 38-year-old man with advanced HIV has cryptococcal meningitis and is receiving liposomal amphotericin plus flucytosine. He develops worsening headache and vomiting. Repeat lumbar puncture shows markedly elevated opening pressure, neurologic examination is otherwise unchanged, and brain imaging shows no mass lesion.",
|
||||
"Which intervention is most appropriate?",
|
||||
["Therapeutic lumbar puncture to reduce intracranial pressure", "Acetazolamide as sole therapy", "Routine glucocorticoids", "Stop antifungal therapy", "Place a permanent shunt before attempting serial drainage", "Mannitol without cerebrospinal-fluid drainage"],
|
||||
"Raised intracranial pressure is a major cause of morbidity in cryptococcal meningitis. Symptomatic elevation requires prompt therapeutic lumbar puncture, often repeated daily.",
|
||||
"Measure and aggressively manage raised opening pressure with therapeutic lumbar puncture in cryptococcal meningitis.",
|
||||
"Acetazolamide, mannitol, and routine corticosteroids do not replace cerebrospinal-fluid drainage.",
|
||||
"markedly elevated opening pressure", "worsening headache", "brain imaging shows no mass lesion",
|
||||
"Antifungal therapy alone immediately normalizes pressure", "Routine steroids are first-line pressure treatment",
|
||||
"Global Guideline for the Diagnosis and Management of Cryptococcosis", "Infectious Diseases Society of America", "https://www.idsociety.org/practice-guideline/Diagnosis-and-Management-of-Cryptococcosis/", "Induction therapy and management of raised intracranial pressure in cryptococcal meningitis."),
|
||||
|
||||
new("pcp-hypoxemia-adjunctive-steroids", "Respiratory System", "Infectious Disease", "Respiratory System", "pneumocystis pneumonia",
|
||||
"A 45-year-old man with untreated HIV has subacute dyspnea, dry cough, and diffuse bilateral ground-glass opacities. Pneumocystis testing is positive. His resting oxygenation demonstrates a severe gas-exchange impairment, he is not receiving glucocorticoids, and there is no bacterial consolidation.",
|
||||
"Which addition to trimethoprim-sulfamethoxazole is indicated?",
|
||||
["Adjunctive systemic glucocorticoids", "Inhaled bronchodilator only", "Immediate antiretroviral therapy as the sole pneumonia treatment", "Loop diuresis", "Therapeutic anticoagulation"],
|
||||
"Moderate-to-severe Pneumocystis pneumonia with substantial hypoxemia benefits from early adjunctive corticosteroids in addition to antimicrobial therapy.",
|
||||
"Add corticosteroids early for HIV-associated Pneumocystis pneumonia with moderate-to-severe hypoxemia.",
|
||||
"Antiretroviral therapy is important but does not replace acute antimicrobial and adjunctive treatment.",
|
||||
"diffuse bilateral ground-glass opacities", "a severe gas-exchange impairment", "Pneumocystis testing is positive",
|
||||
"Steroids are used only after antimicrobial failure", "Antiretroviral therapy alone clears acute pneumonia",
|
||||
"Guidelines for the Prevention and Treatment of Opportunistic Infections in Adults With HIV", "National Institutes of Health", "https://clinicalinfo.hiv.gov/en/guidelines/hiv-clinical-guidelines-adult-and-adolescent-opportunistic-infections/pneumocystis", "Adjunctive corticosteroid indications in moderate-to-severe Pneumocystis pneumonia."),
|
||||
|
||||
new("septic-shock-antibiotics-source-control", "Multisystem", "Emergency Medicine", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "septic shock",
|
||||
"A 68-year-old woman with obstructive pyelonephritis remains hypotensive after initial crystalloid resuscitation. Cultures have been drawn, broad-spectrum antibiotics are available, an obstructing ureteral stone is confirmed, and urology is at the bedside.",
|
||||
"Which combined strategy is most appropriate now?",
|
||||
["Prompt antibiotics, norepinephrine, and urgent urinary decompression", "Delay antibiotics until culture speciation", "Continue unlimited crystalloid without vasopressor", "Treat with glucocorticoids alone", "Schedule outpatient stone removal", "Start vasopressin monotherapy without drainage", "Give diuretics to increase urine flow through the obstruction"],
|
||||
"Septic shock requires prompt effective antimicrobials, norepinephrine when hypotension persists after initial fluids, and rapid control of an anatomic infection source such as an obstructed collecting system.",
|
||||
"Treat septic shock simultaneously with antimicrobials, hemodynamic support, and timely source control.",
|
||||
"No amount of vasopressor support substitutes for drainage of an infected obstruction.",
|
||||
"remains hypotensive after initial crystalloid resuscitation", "an obstructing ureteral stone is confirmed", "urology is at the bedside",
|
||||
"Culture speciation should precede antibiotics in shock", "Vasopressors eliminate the need for source control",
|
||||
"Surviving Sepsis Campaign Adult Guidelines", "Society of Critical Care Medicine", "https://www.sccm.org/survivingsepsiscampaign/guidelines-and-resources/surviving-sepsis-campaign-adult-guidelines", "Prompt antimicrobials, norepinephrine, and source control in septic shock."),
|
||||
|
||||
new("severe-malaria-iv-artesunate", "Multisystem", "Infectious Disease", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "severe malaria",
|
||||
"A 32-year-old traveler returning from West Africa has confusion, jaundice, and Plasmodium falciparum parasitemia. He cannot take oral medication. Kidney function is worsening, blood glucose is being monitored, and no alternative diagnosis explains the illness.",
|
||||
"Which treatment should be started immediately?",
|
||||
["Intravenous artesunate", "Oral chloroquine", "Doxycycline monotherapy", "Exchange transfusion without antimalarial therapy", "Wait for species confirmation at a reference laboratory"],
|
||||
"Falciparum malaria with altered mental status and organ dysfunction is severe malaria. Intravenous artesunate should be started immediately while additional doses and follow-on oral therapy are arranged.",
|
||||
"Treat severe malaria immediately with intravenous artesunate.",
|
||||
"Treatment should not wait for confirmatory testing when severe falciparum malaria is identified.",
|
||||
"confusion", "he cannot take oral medication", "kidney function is worsening",
|
||||
"Oral monotherapy is adequate despite organ dysfunction", "Reference-laboratory confirmation should delay therapy",
|
||||
"Treatment of Severe Malaria", "Centers for Disease Control and Prevention", "https://www.cdc.gov/malaria/hcp/clinical-guidance/treatment-of-severe-malaria.html", "Immediate intravenous artesunate for severe malaria."),
|
||||
|
||||
new("prosthetic-valve-endocarditis-surgery", "Cardiovascular System", "Infectious Disease", "Cardiovascular System", "infective endocarditis",
|
||||
"A 70-year-old man with a prosthetic aortic valve has persistent Staphylococcus aureus bacteremia despite appropriate antibiotics. Transesophageal echocardiography shows a mobile vegetation and a new periannular abscess. He has new conduction delay, coronary angiography is not urgently needed, and there is no intracranial hemorrhage.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Urgent cardiac surgical evaluation while antibiotics continue", "Complete antibiotics before considering surgery", "Stop antibiotics and repeat cultures next month", "Begin thrombolysis", "Treat with anticoagulation alone", "Remove the prosthesis percutaneously without surgical planning"],
|
||||
"Prosthetic-valve endocarditis with persistent bacteremia, periannular abscess, and conduction abnormality indicates uncontrolled invasive infection and is a strong indication for urgent surgery.",
|
||||
"Seek early surgery for endocarditis complicated by abscess, uncontrolled infection, or prosthetic-valve involvement.",
|
||||
"New conduction disease suggests extension near the conduction system.",
|
||||
"persistent Staphylococcus aureus bacteremia", "a new periannular abscess", "new conduction delay",
|
||||
"Antibiotics reliably sterilize every periannular abscess", "Surgery should always wait until antibiotics are complete",
|
||||
"Infective Endocarditis in Adults", "American Heart Association", "https://www.ahajournals.org/doi/10.1161/CIR.0000000000000296", "Surgical indications for prosthetic-valve endocarditis and periannular abscess."),
|
||||
|
||||
new("meningitis-dexamethasone-before-antibiotics", "Nervous System", "Infectious Disease", "Nervous System & Special Senses", "bacterial meningitis",
|
||||
"A 57-year-old asplenic man has abrupt headache, neck stiffness, confusion, and purpuric lesions. Blood cultures are obtained. Lumbar puncture will be delayed for neuroimaging, empiric antibiotics are ready, and he has no antibiotic allergy.",
|
||||
"Which adjunctive treatment should be given with the first antibiotic dose?",
|
||||
["Dexamethasone", "Acyclovir as the only antimicrobial", "Mannitol routinely", "Therapeutic heparin", "Delay all treatment until lumbar puncture"],
|
||||
"When pneumococcal bacterial meningitis is possible, dexamethasone should be administered before or with the first antibiotic dose. Imaging or lumbar puncture delays must not delay antimicrobial treatment.",
|
||||
"Give dexamethasone before or with initial antibiotics when bacterial meningitis is suspected and pneumococcus is possible.",
|
||||
"Dexamethasone started after multiple antibiotic doses is less likely to provide benefit.",
|
||||
"neck stiffness", "lumbar puncture will be delayed", "empiric antibiotics are ready",
|
||||
"Lumbar puncture must precede every treatment", "Steroids replace bactericidal antibiotics",
|
||||
"Practice Guidelines for the Management of Bacterial Meningitis", "Infectious Diseases Society of America", "https://www.idsociety.org/practice-guideline/bacterial-meningitis/", "Timing of dexamethasone and empiric antibiotics in suspected bacterial meningitis."),
|
||||
|
||||
new("tumor-lysis-rasburicase", "Hematologic System", "Hematology", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "tumor lysis syndrome",
|
||||
"A 19-year-old man with bulky Burkitt lymphoma develops acute kidney injury and arrhythmia shortly after chemotherapy. He has marked hyperuricemia, phosphate elevation, and hypocalcemia. Intravenous fluids are running, urine output is falling, and no obstructive uropathy is present.",
|
||||
"Which medication should be administered urgently?",
|
||||
["Rasburicase", "Allopurinol as the only treatment for existing severe hyperuricemia", "Calcium routinely despite no tetany", "Hydrochlorothiazide", "Oral iron", "Probenecid despite acute kidney injury"],
|
||||
"Established high-risk tumor lysis syndrome with severe hyperuricemia and acute kidney injury requires rapid uric-acid degradation with rasburicase in addition to fluids and electrolyte management.",
|
||||
"Use rasburicase for established or high-risk tumor lysis with substantial hyperuricemia.",
|
||||
"Allopurinol prevents new uric acid formation but does not rapidly remove uric acid already present.",
|
||||
"marked hyperuricemia", "urine output is falling", "no obstructive uropathy is present",
|
||||
"Allopurinol rapidly degrades existing uric acid", "Asymptomatic hypocalcemia should always be corrected aggressively",
|
||||
"Rasburicase Expert Review for the Essential Medicines List", "World Health Organization", "https://cdn.who.int/media/docs/default-source/essential-medicines/2021-eml-expert-committee/expert-reviews/a27_rasburicase_rev1.pdf?sfvrsn=12301379_8", "Rasburicase treatment of severe hyperuricemia associated with tumor lysis syndrome."),
|
||||
|
||||
new("malignancy-hypercalcemia-calcitonin-bisphosphonate", "Endocrine System", "Oncology", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "hypercalcemia of malignancy",
|
||||
"A 65-year-old woman with metastatic breast cancer has confusion, constipation, profound dehydration, and severe symptomatic hypercalcemia. Isotonic intravenous fluid has begun. Kidney function permits antiresorptive therapy, there is no heart failure, and the electrocardiogram shows a shortened QT interval.",
|
||||
"Which pharmacologic treatment is most appropriate now?",
|
||||
["Calcitonin plus an intravenous bisphosphonate", "Thiazide diuretic", "Vitamin D supplementation", "Observation after fluids only", "Teriparatide"],
|
||||
"Severe symptomatic hypercalcemia of malignancy needs rapid temporary calcium reduction with calcitonin plus durable antiresorptive treatment such as an intravenous bisphosphonate.",
|
||||
"Combine calcitonin for rapid effect with antiresorptive therapy for severe hypercalcemia of malignancy.",
|
||||
"Calcitonin alone develops tachyphylaxis and should not be relied on for durable control.",
|
||||
"severe symptomatic hypercalcemia", "profound dehydration", "kidney function permits antiresorptive therapy",
|
||||
"Thiazides increase urinary calcium excretion", "Calcitonin alone provides durable control",
|
||||
"Treatment of Hypercalcemia of Malignancy in Adults", "Endocrine Society", "https://www.endocrine.org/clinical-practice-guidelines/hypercalcemia-of-malignancy", "Calcitonin and antiresorptive therapy for severe hypercalcemia of malignancy."),
|
||||
|
||||
new("malignant-spinal-cord-compression", "Nervous System", "Oncology", "Nervous System & Special Senses", "spinal cord compression",
|
||||
"A 68-year-old man with metastatic prostate cancer develops severe thoracic back pain, progressive leg weakness, and urinary hesitancy. Examination shows a sensory level and hyperreflexia. Symptoms progressed overnight, he can still move both legs, and no fever is present.",
|
||||
"Which is the most appropriate immediate management?",
|
||||
["Administer dexamethasone and obtain emergency spinal MRI", "Schedule routine radiographs next week", "Perform lumbar puncture", "Start physical therapy only", "Wait for complete paralysis before imaging", "Obtain a bone scan before treating cord compression"],
|
||||
"Cancer with new pain, myelopathic deficits, and bladder symptoms indicates metastatic spinal cord compression. Dexamethasone and urgent whole-spine MRI should be initiated while definitive radiation or surgery is arranged.",
|
||||
"Suspected malignant spinal cord compression requires immediate glucocorticoids and emergency MRI.",
|
||||
"Neurologic function at treatment strongly predicts functional outcome.",
|
||||
"progressive leg weakness", "a sensory level", "urinary hesitancy",
|
||||
"Imaging can wait while some movement remains", "Lumbar puncture defines epidural compression",
|
||||
"Spinal Metastases and Metastatic Spinal Cord Compression", "National Institute for Health and Care Excellence", "https://www.nice.org.uk/guidance/ng234", "Immediate corticosteroids, MRI, and definitive management of metastatic spinal cord compression."),
|
||||
|
||||
new("acute-promyelocytic-leukemia-atra", "Hematologic System", "Hematology", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "acute promyelocytic leukemia",
|
||||
"A 31-year-old woman has gingival bleeding, bruising, and pancytopenia. The smear shows abnormal promyelocytes with multiple Auer rods. Coagulation studies indicate disseminated intravascular coagulation, molecular confirmation is pending, and she has no prior leukemia treatment.",
|
||||
"Which treatment should be started immediately?",
|
||||
["All-trans retinoic acid", "Wait for molecular confirmation before therapy", "Warfarin", "Platelet transfusion as the only treatment", "Imatinib", "Delay leukemia therapy until the coagulopathy resolves spontaneously"],
|
||||
"Suspected acute promyelocytic leukemia with coagulopathy is a medical emergency. All-trans retinoic acid should begin immediately on morphologic suspicion because early hemorrhagic death can occur.",
|
||||
"Start all-trans retinoic acid immediately when acute promyelocytic leukemia is suspected.",
|
||||
"Do not wait for genetic confirmation before initiating ATRA in a compatible presentation.",
|
||||
"multiple Auer rods", "disseminated intravascular coagulation", "molecular confirmation is pending",
|
||||
"Molecular confirmation must precede ATRA", "Anticoagulation treats the leukemia-associated coagulopathy",
|
||||
"Management of Acute Promyelocytic Leukemia", "European LeukemiaNet", "https://ashpublications.org/blood/article/133/15/1630/275931/Management-of-acute-promyelocytic-leukemia-updated", "Immediate ATRA for suspected acute promyelocytic leukemia."),
|
||||
|
||||
new("acute-leukemia-leukostasis-leukapheresis", "Hematologic System", "Hematology", "Immune/Blood & Lymphoreticular/Endocrine/Multisystem", "leukostasis",
|
||||
"A 46-year-old man with newly diagnosed acute myeloid leukemia develops confusion, hypoxemia, and diffuse pulmonary opacities. The peripheral blood has extreme leukocytosis dominated by blasts. There is no focal infection, blood viscosity symptoms are progressing, and definitive induction planning is underway.",
|
||||
"Which immediate strategy is most appropriate?",
|
||||
["Urgent cytoreduction with supportive tumor-lysis prevention", "Red-cell transfusion to raise viscosity", "Observation until induction starts next week", "Platelet inhibition alone", "Therapeutic anticoagulation"],
|
||||
"Neurologic and pulmonary dysfunction with extreme blast leukocytosis indicates leukostasis. Immediate cytoreduction, commonly hydroxyurea with consideration of leukapheresis, and tumor-lysis prophylaxis are required.",
|
||||
"Treat symptomatic leukostasis as an emergency with immediate cytoreduction and tumor-lysis prevention.",
|
||||
"Do not delay cytoreduction while completing the full leukemia classification.",
|
||||
"confusion", "hypoxemia", "extreme leukocytosis dominated by blasts",
|
||||
"Leukostasis is asymptomatic by definition", "Higher blood viscosity improves tissue oxygenation",
|
||||
"Acute Myeloid Leukemia: ESMO Clinical Practice Guidelines", "European Society for Medical Oncology", "https://www.annalsofoncology.org/article/S0923-7534(20)36079-8/fulltext", "Emergency cytoreduction for symptomatic hyperleukocytosis in acute myeloid leukemia."),
|
||||
|
||||
new("papillary-muscle-rupture-emergency-surgery", "Cardiovascular System", "Cardiology", "Cardiovascular System", "mechanical MI complication",
|
||||
"A 73-year-old man develops sudden pulmonary edema and shock 3 days after an inferior myocardial infarction. A new loud systolic murmur is present. Echocardiography shows a flail mitral leaflet with severe acute regurgitation, the ventricular septum is intact, and vasopressor support has begun.",
|
||||
"Which definitive management is most appropriate?",
|
||||
["Emergency mitral-valve surgery", "Outpatient diuretic adjustment", "Thrombolysis as sole treatment", "Observation for spontaneous papillary-muscle healing", "Permanent pacemaker placement"],
|
||||
"Papillary-muscle rupture causes catastrophic acute mitral regurgitation after myocardial infarction. Stabilization is a bridge to emergency surgical repair or replacement.",
|
||||
"Acute severe mitral regurgitation from papillary-muscle rupture requires emergency surgery.",
|
||||
"The posteromedial papillary muscle is vulnerable after inferior infarction because it often has a single blood supply.",
|
||||
"a flail mitral leaflet", "severe acute regurgitation", "the ventricular septum is intact",
|
||||
"Medical therapy definitively repairs papillary rupture", "Pacemaker placement corrects acute regurgitation",
|
||||
"2021 Guideline for Coronary Artery Revascularization", "American College of Cardiology and American Heart Association", "https://www.ahajournals.org/doi/10.1161/CIR.0000000000001038", "Emergency surgery for mechanical complications after myocardial infarction."),
|
||||
|
||||
new("symptomatic-severe-aortic-stenosis-replacement", "Cardiovascular System", "Cardiology", "Cardiovascular System", "aortic stenosis",
|
||||
"An 82-year-old man has exertional syncope, angina, and dyspnea. Echocardiography confirms severe calcific aortic stenosis with preserved ventricular function. Symptoms are attributable to the valve, estimated survival exceeds one year, and a multidisciplinary valve team considers transfemoral access feasible.",
|
||||
"Which treatment offers the best outcome?",
|
||||
["Aortic-valve replacement after shared valve-team assessment", "Exercise training alone", "Long-term nitrate escalation", "Balloon valvuloplasty as definitive routine therapy", "Observation until ventricular function declines"],
|
||||
"Symptomatic severe aortic stenosis has a poor prognosis without valve replacement. In an older patient, the heart team selects transcatheter or surgical replacement according to anatomy, risk, life expectancy, and preferences.",
|
||||
"Valve replacement is indicated for symptomatic severe aortic stenosis.",
|
||||
"Preserved ejection fraction does not justify delaying treatment when classic symptoms are present.",
|
||||
"exertional syncope", "severe calcific aortic stenosis", "transfemoral access feasible",
|
||||
"Preserved ventricular function eliminates the need for replacement", "Vasodilators reverse fixed valvular obstruction",
|
||||
"2020 Guideline for the Management of Patients With Valvular Heart Disease", "American College of Cardiology and American Heart Association", "https://www.ahajournals.org/doi/10.1161/CIR.0000000000000923", "Indications and modality selection for aortic-valve replacement."),
|
||||
|
||||
new("preexcited-af-procainamide", "Cardiovascular System", "Emergency Medicine", "Cardiovascular System", "preexcited atrial fibrillation",
|
||||
"A 28-year-old man with Wolff-Parkinson-White pattern develops palpitations. Electrocardiography shows an irregular wide-complex tachycardia with beat-to-beat variation. Blood pressure is stable, he is alert, no ischemic pain is present, and defibrillation equipment is ready.",
|
||||
"Which medication is most appropriate?",
|
||||
["Intravenous procainamide", "Intravenous adenosine", "Intravenous diltiazem", "Intravenous digoxin", "Intravenous metoprolol", "Intravenous verapamil", "Carotid sinus massage"],
|
||||
"Stable preexcited atrial fibrillation requires an agent such as procainamide that slows the accessory pathway. AV-nodal blockers can preferentially route conduction down the pathway and precipitate ventricular fibrillation.",
|
||||
"Avoid AV-nodal blockers in preexcited atrial fibrillation; use procainamide when stable and cardioversion when unstable.",
|
||||
"An irregular wide-complex rhythm in a patient with preexcitation is not treated like routine narrow-complex SVT.",
|
||||
"an irregular wide-complex tachycardia", "blood pressure is stable", "Wolff-Parkinson-White pattern",
|
||||
"Adenosine safely blocks every accessory pathway", "Diltiazem prevents pathway-mediated ventricular activation",
|
||||
"2023 Guideline for Diagnosis and Management of Atrial Fibrillation", "American College of Cardiology and American Heart Association", "https://www.ahajournals.org/doi/10.1161/CIR.0000000000001193", "Treatment of atrial fibrillation with ventricular preexcitation."),
|
||||
|
||||
new("cardiac-tamponade-urgent-drainage", "Cardiovascular System", "Cardiology", "Cardiovascular System", "cardiac tamponade",
|
||||
"A 55-year-old woman with metastatic lung cancer develops dyspnea, hypotension, jugular venous distention, and pulsus paradoxus. Echocardiography shows a large pericardial effusion with right-ventricular diastolic collapse. She remains unstable after cautious fluid administration, no tension pneumothorax is present, and platelets are adequate.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Urgent pericardial drainage", "Aggressive diuresis", "Positive-pressure ventilation before drainage", "Observation with repeat imaging next week", "Systemic thrombolysis", "Increase beta-blocker therapy"],
|
||||
"Hemodynamic compromise plus chamber collapse indicates cardiac tamponade. Urgent pericardiocentesis or surgical drainage is required.",
|
||||
"Drain the pericardium urgently when tamponade causes hemodynamic instability.",
|
||||
"Positive-pressure ventilation and diuresis can further reduce preload before decompression.",
|
||||
"right-ventricular diastolic collapse", "she remains unstable", "a large pericardial effusion",
|
||||
"Diuresis improves preload-dependent tamponade", "Echocardiographic chamber collapse can be observed despite shock",
|
||||
"2015 Guidelines for the Diagnosis and Management of Pericardial Diseases", "European Society of Cardiology", "https://academic.oup.com/eurheartj/article/36/42/2921/2293378", "Urgent drainage for unstable cardiac tamponade."),
|
||||
|
||||
new("acute-mesenteric-ischemia-cta", "Gastrointestinal System", "Surgery", "Gastrointestinal System", "mesenteric ischemia",
|
||||
"A 74-year-old woman with atrial fibrillation develops abrupt severe periumbilical pain. Early abdominal examination shows little tenderness. Lactate is rising, she has no contrast allergy, kidney function is acceptable, and there is no free intraperitoneal air.",
|
||||
"Which diagnostic study should be obtained immediately?",
|
||||
["CT angiography of the abdomen and pelvis", "Routine colonoscopy", "Noncontrast abdominal radiography only", "Barium enema", "Elective magnetic resonance imaging next month", "Repeat lactate after analgesia before imaging"],
|
||||
"Pain out of proportion in a patient with an embolic risk suggests acute mesenteric ischemia. CT angiography is the rapid diagnostic test of choice and should not be delayed for nonspecific laboratory testing.",
|
||||
"Obtain urgent CT angiography when acute mesenteric ischemia is suspected.",
|
||||
"A benign early abdominal examination does not exclude evolving bowel infarction.",
|
||||
"abrupt severe periumbilical pain", "atrial fibrillation", "little tenderness",
|
||||
"Minimal tenderness excludes bowel ischemia", "Colonoscopy is the fastest test for mesenteric arterial occlusion",
|
||||
"Acute Mesenteric Ischemia Guidelines", "World Society of Emergency Surgery", "https://wjes.biomedcentral.com/articles/10.1186/s13017-022-00443-x", "Immediate CT angiography and management of acute mesenteric ischemia."),
|
||||
|
||||
new("toxic-megacolon-surgical-escalation", "Gastrointestinal System", "Surgery", "Gastrointestinal System", "toxic megacolon",
|
||||
"A 39-year-old man with ulcerative colitis is hospitalized for toxic megacolon. Despite intensive intravenous glucocorticoids and supportive care, abdominal distention worsens and he develops increasing tenderness and shock. Colonic dilation persists, broad-spectrum antibiotics are running, and there is no clinical improvement.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Urgent subtotal colectomy with end ileostomy", "Colonoscopy with full bowel preparation", "Escalate opioid analgesics", "Discharge on oral mesalamine", "Continue medical therapy indefinitely", "Administer an antimotility agent", "Attempt endoscopic balloon dilation"],
|
||||
"Toxic megacolon with deterioration or failure of intensive medical therapy requires urgent surgery. Subtotal colectomy avoids further delay and perforation.",
|
||||
"Escalate promptly to colectomy when toxic megacolon worsens despite intensive medical therapy.",
|
||||
"Colonoscopy and bowel preparation increase perforation risk in a severely dilated colon.",
|
||||
"abdominal distention worsens", "he develops increasing tenderness and shock", "there is no clinical improvement",
|
||||
"Shock is an indication to continue indefinite medical therapy", "Full colonoscopy safely decompresses toxic megacolon",
|
||||
"Ulcerative Colitis in Adults", "American College of Gastroenterology", "https://gi.org/guideline/ulcerative-colitis-in-adults/", "Surgical indications and urgent colectomy for toxic megacolon."),
|
||||
|
||||
new("strangulated-small-bowel-obstruction-surgery", "Gastrointestinal System", "Surgery", "Gastrointestinal System", "small bowel obstruction",
|
||||
"A 61-year-old woman with prior abdominal surgery has vomiting and distention. CT shows a closed-loop small-bowel obstruction with mesenteric edema and reduced bowel-wall enhancement. Pain has become continuous, localized guarding is present, and nasogastric decompression has begun.",
|
||||
"Which is the most appropriate next step?",
|
||||
["Emergency operative exploration", "Continue nonoperative observation for 72 hours", "Administer an oral laxative", "Perform colonoscopy", "Discharge after antiemetic therapy"],
|
||||
"Closed-loop obstruction with reduced enhancement, continuous pain, and guarding suggests strangulation and ischemia. Emergency exploration is required rather than a prolonged nonoperative trial.",
|
||||
"Operate urgently when small-bowel obstruction has signs of strangulation or ischemia.",
|
||||
"Nonoperative management is reserved for patients without peritonitis, strangulation, or ischemic imaging findings.",
|
||||
"a closed-loop small-bowel obstruction", "reduced bowel-wall enhancement", "localized guarding is present",
|
||||
"Every adhesive obstruction warrants a prolonged observation trial", "Nasogastric decompression reverses infarcted bowel",
|
||||
"Bologna Guidelines for Adhesive Small Bowel Obstruction", "World Society of Emergency Surgery", "https://wjes.biomedcentral.com/articles/10.1186/s13017-018-0185-2", "Emergency surgery for obstruction with strangulation or bowel ischemia."),
|
||||
|
||||
new("basilar-artery-occlusion-thrombectomy", "Nervous System", "Neurology", "Nervous System & Special Senses", "basilar artery occlusion",
|
||||
"A 64-year-old man develops sudden dysarthria, quadriparesis, and declining consciousness. CT angiography shows proximal basilar artery occlusion with salvageable posterior-circulation tissue. He was last known well 8 hours ago, there is no intracranial hemorrhage, and the thrombectomy team is available.",
|
||||
"Which treatment is most appropriate?",
|
||||
["Urgent endovascular thrombectomy", "Aspirin alone and routine ward admission", "Delay treatment for repeat imaging tomorrow", "Carotid endarterectomy", "Lumbar puncture"],
|
||||
"Disabling basilar artery occlusion with favorable imaging can benefit from endovascular thrombectomy within an extended treatment window. Rapid reperfusion evaluation is essential.",
|
||||
"Evaluate eligible patients with acute basilar artery occlusion urgently for endovascular thrombectomy.",
|
||||
"Posterior-circulation large-vessel occlusion can cause catastrophic deterioration despite initially limited findings.",
|
||||
"proximal basilar artery occlusion", "salvageable posterior-circulation tissue", "there is no intracranial hemorrhage",
|
||||
"An extended time window automatically excludes thrombectomy", "Carotid surgery treats basilar occlusion",
|
||||
"ESO-ESMINT Guideline on Acute Management of Basilar Artery Occlusion", "European Stroke Organisation", "https://journals.sagepub.com/doi/10.1177/23969873241249980", "Selection of patients with basilar artery occlusion for endovascular treatment."),
|
||||
|
||||
new("cerebral-venous-thrombosis-anticoagulation", "Nervous System", "Neurology", "Nervous System & Special Senses", "cerebral venous thrombosis",
|
||||
"A 30-year-old woman 2 weeks postpartum has progressive headache, focal seizure, and left arm weakness. Venography confirms superior sagittal sinus thrombosis with a small hemorrhagic venous infarct. Platelets are normal, there is no aneurysm, and blood pressure is controlled.",
|
||||
"Which treatment is most appropriate?",
|
||||
["Therapeutic anticoagulation", "Withhold anticoagulation because any intracranial blood is present", "Aspirin only", "Immediate carotid endarterectomy", "Glucocorticoids alone", "Treat the seizure without addressing the thrombosis"],
|
||||
"Cerebral venous thrombosis is treated with therapeutic heparin even when a small hemorrhagic venous infarct is present, because anticoagulation prevents thrombus propagation and improves venous drainage.",
|
||||
"Anticoagulate cerebral venous thrombosis despite hemorrhagic venous infarction unless a specific contraindication exists.",
|
||||
"Hemorrhage caused by venous congestion is not managed like primary hypertensive intracerebral hemorrhage.",
|
||||
"superior sagittal sinus thrombosis", "a small hemorrhagic venous infarct", "platelets are normal",
|
||||
"Any intracranial hemorrhage absolutely prohibits anticoagulation", "Antiplatelet therapy is equivalent to therapeutic anticoagulation",
|
||||
"Diagnosis and Management of Cerebral Venous Thrombosis", "American Heart Association", "https://www.ahajournals.org/doi/10.1161/STR.0000000000000456", "Anticoagulation for cerebral venous thrombosis including hemorrhagic venous infarction."),
|
||||
|
||||
new("wernicke-thiamine-before-glucose", "Nervous System", "Medicine", "Nervous System & Special Senses", "Wernicke encephalopathy",
|
||||
"A 52-year-old man with severe alcohol use disorder is confused and unable to walk. Examination shows gaze-evoked nystagmus and truncal ataxia. He is malnourished, intravenous dextrose is being prepared, and no focal motor deficit is present.",
|
||||
"Which treatment should be administered immediately?",
|
||||
["Parenteral thiamine before or with glucose", "Oral folate alone", "Haloperidol as sole treatment", "Wait for thiamine-level confirmation", "Dextrose alone"],
|
||||
"Confusion, ocular findings, and ataxia in a malnourished patient indicate Wernicke encephalopathy. High-dose parenteral thiamine should be given promptly before or with carbohydrate.",
|
||||
"Treat suspected Wernicke encephalopathy immediately with parenteral thiamine.",
|
||||
"A normal or pending serum thiamine measurement should not delay treatment.",
|
||||
"gaze-evoked nystagmus", "truncal ataxia", "he is malnourished",
|
||||
"Laboratory confirmation must precede thiamine", "Glucose alone corrects thiamine-dependent metabolism",
|
||||
"Alcohol-Use Disorders: Diagnosis and Management of Physical Complications", "National Institute for Health and Care Excellence", "https://www.nice.org.uk/guidance/cg100", "Immediate parenteral thiamine for suspected Wernicke encephalopathy."),
|
||||
|
||||
new("catatonia-lorazepam-challenge", "Behavioral Health", "Psychiatry", "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety", "catatonia",
|
||||
"A 33-year-old woman with bipolar disorder becomes mute and motionless. She maintains imposed postures, resists repositioning, and intermittently echoes the examiner's words. Vital signs are stable, neurologic imaging is unrevealing, and there is no lead-pipe rigidity.",
|
||||
"Which is the most appropriate initial treatment?",
|
||||
["Lorazepam challenge and scheduled benzodiazepine treatment", "High-potency antipsychotic escalation", "Physical restraints as definitive therapy", "Dopamine agonist for Parkinson disease", "Discharge without treatment", "Supportive observation until spontaneous movement returns"],
|
||||
"Mutism, posturing, negativism, and echolalia indicate catatonia. A lorazepam challenge is both diagnostic and therapeutic; electroconvulsive therapy is used for refractory or malignant cases.",
|
||||
"Use benzodiazepines first for catatonia and escalate promptly to electroconvulsive therapy when needed.",
|
||||
"Antipsychotics can worsen catatonia and increase malignant-catatonia risk before the syndrome is controlled.",
|
||||
"she maintains imposed postures", "intermittently echoes the examiner's words", "vital signs are stable",
|
||||
"High-potency antipsychotics are the diagnostic test for catatonia", "Immobility should be managed only with restraint",
|
||||
"Evidence-Based Consensus Guidelines for the Management of Catatonia", "British Association for Psychopharmacology", "https://journals.sagepub.com/doi/10.1177/02698811231158232", "Lorazepam challenge, benzodiazepine treatment, and ECT for catatonia."),
|
||||
|
||||
new("lithium-toxicity-hemodialysis", "Behavioral Health", "Psychiatry", "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety", "lithium toxicity",
|
||||
"A 67-year-old man taking lithium develops coarse tremor, ataxia, confusion, and repeated seizures after gastroenteritis and acute kidney injury. Lithium dosing has stopped, isotonic fluid is running, neurologic toxicity remains severe, and kidney clearance is impaired.",
|
||||
"Which additional treatment is most appropriate?",
|
||||
["Urgent hemodialysis", "Activated charcoal", "Restart lithium at a lower dose", "Urinary alkalinization", "Flumazenil", "Whole-bowel irrigation as definitive therapy"],
|
||||
"Severe neurologic lithium toxicity with seizures and impaired kidney clearance is an indication for extracorporeal removal because lithium is small, minimally protein bound, and dialyzable.",
|
||||
"Use hemodialysis for severe lithium poisoning, especially with seizures, coma, or impaired elimination.",
|
||||
"Activated charcoal does not bind lithium effectively.",
|
||||
"repeated seizures", "acute kidney injury", "neurologic toxicity remains severe",
|
||||
"Activated charcoal reliably removes lithium", "Stopping lithium alone is adequate despite seizures",
|
||||
"Extracorporeal Treatment for Lithium Poisoning", "EXTRIP Workgroup", "https://pmc.ncbi.nlm.nih.gov/articles/PMC4422246/", "Hemodialysis indications in severe lithium poisoning."),
|
||||
|
||||
new("neuroleptic-malignant-syndrome-stop-agent", "Behavioral Health", "Psychiatry", "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety", "neuroleptic malignant syndrome",
|
||||
"A 40-year-old man develops generalized lead-pipe rigidity, autonomic instability, and altered consciousness after a rapid haloperidol dose increase. Creatine kinase is markedly elevated. Haloperidol is still ordered, infection evaluation is unrevealing, and clonus is absent.",
|
||||
"Which intervention is most important immediately?",
|
||||
["Stop the dopamine antagonist and begin intensive supportive care", "Continue haloperidol to treat agitation", "Administer an SSRI", "Give succinylcholine", "Discharge with oral hydration"],
|
||||
"Lead-pipe rigidity, dysautonomia, altered mental status, and muscle injury after dopamine blockade indicate neuroleptic malignant syndrome. The offending drug must be stopped immediately and aggressive supportive care begun.",
|
||||
"Immediately discontinue dopamine antagonists and provide intensive supportive care for neuroleptic malignant syndrome.",
|
||||
"Dantrolene or bromocriptine may be considered in severe cases, but drug withdrawal and supportive care cannot wait.",
|
||||
"generalized lead-pipe rigidity", "clonus is absent", "creatine kinase is markedly elevated",
|
||||
"Continuing the dopamine blocker reverses rigidity", "An SSRI treats dopamine-blockade toxicity",
|
||||
"Evidence-Based Consensus Guidelines for the Management of Catatonia", "British Association for Psychopharmacology", "https://pmc.ncbi.nlm.nih.gov/articles/PMC10101189/", "Recognition and immediate withdrawal and supportive management of neuroleptic malignant syndrome."),
|
||||
|
||||
new("capacity-task-specific-assessment", "Professionalism and Ethics", "Ethics", "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety", "decision-making capacity",
|
||||
"A hospitalized 79-year-old man with mild dementia refuses amputation for an ischemic infected foot. He clearly explains that surgery could prevent death, describes the likely consequence of refusal, compares alternatives consistently, and communicates a stable preference. His daughter demands that the physician override him, he is not delirious, and no court has appointed a guardian.",
|
||||
"Which is the most appropriate response?",
|
||||
["Honor the refusal after confirming and documenting decision-making capacity", "Assume dementia eliminates capacity", "Let the daughter decide because she disagrees", "Seek a court order solely because the choice is risky", "Sedate the patient and operate"],
|
||||
"Capacity is decision-specific and requires understanding, appreciation, reasoning, and communication. This patient demonstrates all four abilities despite making a high-risk choice.",
|
||||
"Respect a capable patient's informed refusal even when clinicians or family strongly disagree.",
|
||||
"Diagnosis of dementia does not automatically establish incapacity.",
|
||||
"he clearly explains that surgery could prevent death", "he is not delirious", "communicates a stable preference",
|
||||
"A risky choice proves incapacity", "Family preference overrides a capable adult",
|
||||
"Decisions for Adult Patients Who Lack Capacity", "American Medical Association", "https://code-medical-ethics.ama-assn.org/ethics-opinions/decisions-adult-patients-who-lack-capacity", "Assessment of capacity and respect for decisions of capable adults."),
|
||||
|
||||
new("wrong-site-surgery-timeout", "Social Sciences and Patient Safety", "Patient Safety", "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety", "surgical safety",
|
||||
"A patient is in the operating room for left knee arthroscopy. The consent says left knee, but the electronic schedule says right knee. The surgeon has scrubbed, anesthesia has begun, and an old right-knee radiograph is displayed.",
|
||||
"Which is the most appropriate action?",
|
||||
["Stop and resolve the discrepancy during the procedural time-out", "Proceed using the electronic schedule", "Let the most senior clinician choose a side", "Operate on both knees", "Document the discrepancy after surgery", "Ask anesthesia to choose based on room setup", "Cover the conflicting radiograph and proceed"],
|
||||
"Conflicting procedure information requires an immediate stop before incision. The team must perform the universal-protocol verification and resolve the site using reliable source documents and patient confirmation when possible.",
|
||||
"Stop the procedure whenever patient, site, or procedure information conflicts; resolve it before incision.",
|
||||
"Hierarchy must not suppress any team member's safety concern.",
|
||||
"the consent says left knee", "the electronic schedule says right knee", "an old right-knee radiograph is displayed",
|
||||
"The schedule always overrides signed consent", "A discrepancy can be documented after incision",
|
||||
"Universal Protocol for Preventing Wrong Site, Wrong Procedure, Wrong Person Surgery", "The Joint Commission", "https://www.jointcommission.org/standards/universal-protocol/", "Preprocedure verification, site marking, and time-out requirements."),
|
||||
|
||||
new("asplenia-vaccination-prevention", "Preventive Medicine & Health Maintenance", "Preventive Medicine", "Preventive Medicine & Health Maintenance", "asplenia vaccination",
|
||||
"A 42-year-old woman is scheduled for elective splenectomy. Her routine childhood vaccines are complete, but she has not received vaccines specifically recommended for asplenia. Surgery is planned in 3 weeks, she is not immunosuppressed, and she has no vaccine allergy.",
|
||||
"Which preventive strategy is most appropriate?",
|
||||
["Administer indicated pneumococcal, meningococcal, and Haemophilus influenzae type b vaccines before surgery", "Give no additional vaccines because childhood vaccination is complete", "Wait until overwhelming sepsis occurs", "Administer live oral polio vaccine", "Use antibiotics instead of all vaccines"],
|
||||
"Anatomic asplenia increases risk from encapsulated organisms. Indicated pneumococcal, meningococcal, and Hib vaccines should be given before elective splenectomy when feasible.",
|
||||
"Vaccinate patients with planned asplenia against pneumococcus, meningococcus, and Hib before surgery when possible.",
|
||||
"Routine childhood vaccination may not include the complete risk-based adult schedules.",
|
||||
"scheduled for elective splenectomy", "surgery is planned in 3 weeks", "she has no vaccine allergy",
|
||||
"Childhood vaccination eliminates all asplenia recommendations", "Vaccination should wait until after invasive infection",
|
||||
"Adult Immunization Schedule by Medical Condition", "Centers for Disease Control and Prevention", "https://www.cdc.gov/vaccines/hcp/imz-schedules/adult-medical-condition.html", "Risk-based immunization recommendations for anatomic or functional asplenia."),
|
||||
|
||||
new("quality-improvement-pdsa", "Social Sciences and Patient Safety", "Patient Safety", "Behavioral Health & Social Sciences: Communication, Ethics, Patient Safety", "quality improvement",
|
||||
"A clinic wants to reduce missed follow-up of abnormal test results. The team maps the current process and proposes a reminder assigned to one nurse. Before changing the whole network, it tests the reminder for one physician during one week, measures completion, and plans modification based on results.",
|
||||
"Which quality-improvement method is being used?",
|
||||
["Plan-Do-Study-Act cycle", "Case-control study", "Root cause analysis after a sentinel event", "Randomized phase 3 drug trial", "Failure concealment"],
|
||||
"A small, time-limited test of change followed by measurement and adaptation is a Plan-Do-Study-Act cycle.",
|
||||
"Use iterative small tests of change and measurement in Plan-Do-Study-Act improvement.",
|
||||
"Quality improvement changes are refined through repeated cycles rather than deployed once without feedback.",
|
||||
"tests the reminder for one physician", "during one week", "plans modification based on results",
|
||||
"PDSA requires immediate systemwide implementation", "Measurement is unnecessary in improvement work",
|
||||
"Plan-Do-Study-Act Worksheet", "Institute for Healthcare Improvement", "https://www.ihi.org/resources/tools/plan-do-study-act-pdsa-worksheet", "Use of iterative PDSA cycles for testing healthcare process changes.")
|
||||
];
|
||||
}
|
||||