Author SHA1 Message Date
hz da141670d1 Add dashboard performance trends 2026-07-25 22:35:02 -05:00
114 changed files with 417 additions and 7005 deletions
-4
View File
@@ -4,10 +4,6 @@ on:
push: push:
branches: branches:
- production - production
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
workflow_dispatch: {} workflow_dispatch: {}
concurrency: concurrency:
-4
View File
@@ -4,10 +4,6 @@ on:
push: push:
branches: branches:
- main - main
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
workflow_dispatch: {} workflow_dispatch: {}
concurrency: concurrency:
+13 -12
View File
@@ -1,4 +1,4 @@
name: Promote ready UAT questions to PRD name: Promote UAT content to PRD
on: on:
workflow_dispatch: workflow_dispatch:
@@ -13,7 +13,7 @@ on:
default: "" default: ""
concurrency: concurrency:
group: step3-question-promotion group: step3-content-promotion
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
@@ -45,26 +45,26 @@ jobs:
test "$CONFIRMATION" = "PROMOTE_TO_PRD" test "$CONFIRMATION" = "PROMOTE_TO_PRD"
fi fi
- name: Configure question promotion credentials - name: Configure promotion credentials
env: env:
SOURCE_CONNECTION: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }} SOURCE_CONNECTION: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
TARGET_CONNECTION: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }} TARGET_CONNECTION: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }}
run: | run: |
test -n "$SOURCE_CONNECTION" test -n "$SOURCE_CONNECTION"
test -n "$TARGET_CONNECTION" test -n "$TARGET_CONNECTION"
kubectl create secret generic step3-question-promotion \ kubectl create secret generic step3-content-promotion \
--from-literal=source-connection="$SOURCE_CONNECTION" \ --from-literal=source-connection="$SOURCE_CONNECTION" \
--from-literal=target-connection="$TARGET_CONNECTION" \ --from-literal=target-connection="$TARGET_CONNECTION" \
--dry-run=client -o yaml | kubectl apply -n step3-prd -f - --dry-run=client -o yaml | kubectl apply -n step3-prd -f -
- name: Run ready-question promotion - name: Run promotion
env: env:
MODE: ${{ inputs.mode }} MODE: ${{ inputs.mode }}
CONFIRMATION: ${{ inputs.confirmation }} CONFIRMATION: ${{ inputs.confirmation }}
run: | run: |
IMAGE="$(kubectl get deployment step3-api -n step3-prd -o jsonpath='{.spec.template.spec.containers[0].image}')" IMAGE="$(kubectl get deployment step3-api -n step3-prd -o jsonpath='{.spec.template.spec.containers[0].image}')"
REVISION="${IMAGE##*:}" REVISION="${IMAGE##*:}"
POD="question-promotion-${GITHUB_RUN_NUMBER}" POD="content-promotion-${GITHUB_RUN_NUMBER}"
APPLY_ARGS="" APPLY_ARGS=""
if [ "$MODE" = "apply" ]; then if [ "$MODE" = "apply" ]; then
APPLY_ARGS="--apply --confirm $CONFIRMATION" APPLY_ARGS="--apply --confirm $CONFIRMATION"
@@ -76,7 +76,7 @@ jobs:
name: ${POD} name: ${POD}
namespace: step3-prd namespace: step3-prd
labels: labels:
app: step3-question-promotion app: step3-content-promotion
spec: spec:
restartPolicy: Never restartPolicy: Never
containers: containers:
@@ -86,7 +86,7 @@ jobs:
command: ["/bin/sh", "-c"] command: ["/bin/sh", "-c"]
args: args:
- >- - >-
dotnet QuestionsApi.dll promote-questions dotnet QuestionsApi.dll promote-content
--source-database ranaze_step3_uat --source-database ranaze_step3_uat
--target-database ranaze_step3_prd --target-database ranaze_step3_prd
--operator "${GITHUB_ACTOR:-gitea-operator}" --operator "${GITHUB_ACTOR:-gitea-operator}"
@@ -96,12 +96,12 @@ jobs:
- name: SOURCE_MONGODB_CONNECTION_STRING - name: SOURCE_MONGODB_CONNECTION_STRING
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: step3-question-promotion name: step3-content-promotion
key: source-connection key: source-connection
- name: TARGET_MONGODB_CONNECTION_STRING - name: TARGET_MONGODB_CONNECTION_STRING
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: step3-question-promotion name: step3-content-promotion
key: target-connection key: target-connection
EOF EOF
for attempt in $(seq 1 120); do for attempt in $(seq 1 120); do
@@ -115,5 +115,6 @@ jobs:
- name: Remove promotion credentials and pod - name: Remove promotion credentials and pod
if: always() if: always()
run: | run: |
kubectl delete pod "question-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found kubectl delete pod "content-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
kubectl delete secret step3-question-promotion -n step3-prd --ignore-not-found kubectl delete secret step3-content-promotion -n step3-prd --ignore-not-found
+5 -22
View File
@@ -57,9 +57,9 @@ The hostname change creates a new browser origin, so testers should expect to si
1. Create and protect the `production` branch, then merge the tested `main` revision. 1. Create and protect the `production` branch, then merge the tested `main` revision.
2. Route `step3.ranaze.com` and `s3q.ranaze.com` to PRD NodePorts. 2. Route `step3.ranaze.com` and `s3q.ranaze.com` to PRD NodePorts.
3. Run **Promote ready UAT questions to PRD** in `dry-run` mode and inspect the insert, update, unchanged, extra, media, and hash values. 3. Run **Promote UAT content to PRD** in `dry-run` mode and inspect the counts and hash.
4. Run it again in `apply` mode with confirmation `PROMOTE_TO_PRD`. 4. Run it again in `apply` mode with confirmation `PROMOTE_TO_PRD`.
5. Verify `ranaze_step3_prd` contains the published, unflagged UAT questions and has no user/activity documents. 5. Verify `ranaze_step3_prd` contains published content and has no user/activity documents.
6. Register the initial PRD account normally, then promote it manually: 6. Register the initial PRD account normally, then promote it manually:
```javascript ```javascript
@@ -69,31 +69,14 @@ The hostname change creates a new browser origin, so testers should expect to si
7. Configure and test the live Stripe webhook and Steam callback before announcing PRD. 7. Configure and test the live Stripe webhook and Steam callback before announcing PRD.
## Question promotion behavior ## Content promotion behavior
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. Any promoted question containing media is flagged again in PRD and remains hidden from students until a SUPER user explicitly approves it in production. UAT media approval is not treated as production approval. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations. The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only published questions, referenced media, current published CCS revisions, and the CCS order catalog. It never copies users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation. Promotion is idempotent and does not delete or implicitly unpublish PRD content. Every applied promotion writes a `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
## Verification and rollback ## 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: Check both internal and public readiness after every rollout:
```bash ```bash
-39
View File
@@ -148,45 +148,6 @@ public sealed class ChoicePoolTests
Assert.Null(QuestionValidator.Validate(request)); Assert.Null(QuestionValidator.Validate(request));
} }
[Fact]
public void PublishedQuestionRequiresCompleteLabReferenceRanges()
{
var request = BuildRequest(5);
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Contains("requires a reference range", QuestionValidator.Validate(request));
request.Labs[0].Reference = "136–145 mEq/L";
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void DraftQuestionMaySaveLabWithoutReferenceRange()
{
var request = BuildRequest(5);
request.IsPublished = false;
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void LabEntriesAlwaysRequireNamesAndValues()
{
var request = BuildRequest(5);
request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
}
[Theory] [Theory]
[InlineData("http://example.org/guideline", "HTTPS")] [InlineData("http://example.org/guideline", "HTTPS")]
[InlineData("https://example.org/guideline", null)] [InlineData("https://example.org/guideline", null)]
@@ -31,167 +31,4 @@ public sealed class ContentPromotionTests
ContentPromotionCommand.HashDocuments([first, second]), ContentPromotionCommand.HashDocuments([first, second]),
ContentPromotionCommand.HashDocuments([second, first])); ContentPromotionCommand.HashDocuments([second, first]));
} }
[Fact]
public void ReadyQuestionEligibilityRequiresPublishedAndUnflagged()
{
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument
{
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value
}));
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", true)));
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument
{
["IsPublished"] = true,
["FlaggedForReviewUtc"] = DateTime.UtcNow
}));
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
}
[Fact]
public void ProductionPromotionFlagsMediaForSeparateApproval()
{
var updated = new DateTime(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc);
var source = new BsonDocument
{
["ExternalId"] = "media-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["UpdatedUtc"] = updated,
["Media"] = new BsonArray { ObjectId.GenerateNewId().ToString() }
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.Equal(updated, prepared["FlaggedForReviewUtc"].ToUniversalTime());
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewReason, prepared["FlagReason"].AsString);
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewActor, prepared["FlaggedByUserId"].AsString);
Assert.True(source["FlaggedForReviewUtc"].IsBsonNull);
}
[Fact]
public void ProductionPromotionLeavesMediaFreeQuestionApprovalStateAlone()
{
var source = new BsonDocument
{
["ExternalId"] = "text-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["Media"] = new BsonArray()
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.True(prepared["FlaggedForReviewUtc"].IsBsonNull);
Assert.False(prepared.Contains("FlagReason"));
}
[Fact]
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
{
var first = ObjectId.GenerateNewId();
var second = ObjectId.GenerateNewId();
var questions = new[]
{
new BsonDocument
{
["ExternalId"] = "one",
["Media"] = new BsonArray { $"/api/questions/media/{first}", second.ToString() }
},
new BsonDocument
{
["ExternalId"] = "two",
["Media"] = new BsonArray { $"https://example.test/api/questions/media/{first}" }
}
};
var result = QuestionPromotionCommand.CollectMediaIds(questions);
Assert.Equal(new HashSet<ObjectId> { first, second }, result);
}
[Fact]
public void InvalidLocalQuestionMediaReferenceFailsValidation()
{
var question = new BsonDocument
{
["ExternalId"] = "bad-media",
["Media"] = new BsonArray { "/api/questions/media/not-an-id" }
};
var exception = Assert.Throws<InvalidOperationException>(
() => QuestionPromotionCommand.CollectMediaIds([question]));
Assert.Contains("bad-media", exception.Message);
}
[Fact]
public void QuestionComparisonClassifiesInsertUpdateAndUnchanged()
{
var source = new[]
{
Question("insert", "new"),
Question("update", "new"),
Question("same", "same")
};
var target = new[]
{
Question("update", "old"),
new BsonDocument
{
["Stem"] = "same",
["ExternalId"] = "same",
["_id"] = ObjectId.GenerateNewId()
}
};
var result = QuestionPromotionCommand.ClassifyQuestions(source, target);
Assert.Equal(1, result.Inserted);
Assert.Equal(1, result.Updated);
Assert.Equal(1, result.Unchanged);
}
[Fact]
public void QuestionHashIgnoresMongoIdsFieldOrderAndInputOrder()
{
var first = Question("a", "one");
first["_id"] = ObjectId.GenerateNewId();
var second = Question("b", "two");
var equivalentFirst = new BsonDocument
{
["Stem"] = "one",
["_id"] = ObjectId.GenerateNewId(),
["ExternalId"] = "a"
};
Assert.Equal(
QuestionPromotionCommand.HashQuestions([first, second]),
QuestionPromotionCommand.HashQuestions([second, equivalentFirst]));
}
[Fact]
public async Task ReadyQuestionApplyRequiresExplicitProductionConfirmation()
{
using var output = new StringWriter();
var result = await QuestionPromotionCommand.RunAsync(
[
"--source-connection", "mongodb://source.invalid",
"--source-database", "uat",
"--target-connection", "mongodb://target.invalid",
"--target-database", "prd",
"--apply"
], output);
Assert.Equal(1, result);
Assert.Contains("--confirm PROMOTE_TO_PRD is required", output.ToString());
}
private static BsonDocument Question(string externalId, string stem) => new()
{
["ExternalId"] = externalId,
["Stem"] = stem
};
} }
@@ -1,44 +0,0 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class CreditEconomyTests
{
[Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit()
{
Assert.Equal(20, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow);
}
[Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{
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"));
}
}
+1 -97
View File
@@ -100,8 +100,7 @@ public sealed class DashboardTests
HistoryStartedUtc = baselineDate HistoryStartedUtc = baselineDate
}; };
var result = DashboardStore.BuildPerformanceTrend( var result = DashboardStore.BuildPerformanceTrend(attempts, rating, today);
attempts, rating, Array.Empty<CcsAttempt>(), new CcsUserRating(), today);
Assert.Equal(30, result.Count); Assert.Equal(30, result.Count);
Assert.Null(result[^4].QuestionBankRating); Assert.Null(result[^4].QuestionBankRating);
@@ -114,39 +113,6 @@ public sealed class DashboardTests
Assert.Equal(100, result[^1].Accuracy); Assert.Equal(100, result[^1].Accuracy);
} }
[Fact]
public void TrendCarriesCcsRatingForwardIndependentlyOfQuestionBankRating()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var baselineDate = today.AddDays(-2).AddHours(8);
var rating = new UserRating();
var ccsRating = new CcsUserRating
{
Elo = 1050,
HistoryBaselineElo = 1000,
HistoryStartedUtc = baselineDate
};
var ccsAttempts = new[]
{
RatedCcsAttempt("one", today.AddDays(-1).AddHours(9), 1030),
RatedCcsAttempt("two", today.AddHours(10), 1050)
};
var result = DashboardStore.BuildPerformanceTrend(
Array.Empty<QuestionAttempt>(), rating, ccsAttempts, ccsRating, today);
Assert.Equal(30, result.Count);
Assert.Equal(1000, result[^3].CcsRating);
Assert.Equal(1030, result[^2].CcsRating);
Assert.Equal(1050, result[^1].CcsRating);
}
private static CcsAttempt RatedCcsAttempt(string caseExternalId, DateTime appliedUtc, int userElo) => new()
{
CaseExternalId = caseExternalId,
Rating = new CcsRatingChange { Rated = true, UserElo = userElo, AppliedUtc = appliedUtc }
};
private static QuestionAttempt Attempt( private static QuestionAttempt Attempt(
string questionId, string questionId,
bool correct, bool correct,
@@ -168,66 +134,4 @@ public sealed class DashboardTests
ExternalId = system, ExternalId = system,
System = system System = system
}; };
[Fact]
public void PercentileReturnsNullWithNoUsers()
{
Assert.Null(DashboardStore.Percentile(0, 0));
}
[Fact]
public void PercentileComputesTopPercentFromUsersRatedHigher()
{
// 18 of 100 users rate higher than this user - "top 18%"
Assert.Equal(18, DashboardStore.Percentile(18, 100));
}
[Fact]
public void PercentileNeverReturnsZero()
{
// Even the single best-rated user should read as "top 1%", not "top 0%"
Assert.Equal(1, DashboardStore.Percentile(0, 100));
}
[Theory]
[InlineData(0.85, "High")]
[InlineData(0.7, "High")]
[InlineData(0.55, "Medium")]
[InlineData(0.4, "Medium")]
[InlineData(0.1, "Low")]
public void ReadinessLabelUsesPassProbabilityThresholds(double passProbability, string expected)
{
Assert.Equal(expected, DashboardStore.ReadinessLabel(passProbability));
}
[Fact]
public void SelectLatestReadinessScoreSkipsSampleSimulationsEvenWhenMostRecent()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var simulations = new[]
{
CompletedSimulation(isSample: true, completedUtc: today, passProbability: 0.98),
CompletedSimulation(isSample: false, completedUtc: today.AddDays(-3), passProbability: 0.12)
};
var score = DashboardStore.SelectLatestReadinessScore(simulations);
Assert.NotNull(score);
Assert.Equal(0.12, score.PassProbability);
}
[Fact]
public void SelectLatestReadinessScoreReturnsNullWhenOnlySamplesExist()
{
var simulations = new[] { CompletedSimulation(isSample: true, completedUtc: DateTime.UtcNow, passProbability: 0.98) };
Assert.Null(DashboardStore.SelectLatestReadinessScore(simulations));
}
private static ExamSimulation CompletedSimulation(bool isSample, DateTime completedUtc, double passProbability) => new()
{
IsSample = isSample,
CompletedUtc = completedUtc,
Score = new ExamSimulationScore { PassProbability = passProbability }
};
} }
+3 -154
View File
@@ -44,104 +44,8 @@ public sealed class ExamSimulationTests
Assert.Equal(0, failing.Correct); 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] [Fact]
public void CorrectHarderQuestionsProduceAHigherAdjustedPrediction() public void SeenItemsAreReportedAndCarryLessScoringWeight()
{
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 questions = BuildQuestions(20);
var unseen = BuildItems(20); var unseen = BuildItems(20);
@@ -155,9 +59,7 @@ public sealed class ExamSimulationTests
Assert.Equal(0, unseenScore.SeenCount); Assert.Equal(0, unseenScore.SeenCount);
Assert.Equal(20, seenScore.SeenCount); Assert.Equal(20, seenScore.SeenCount);
Assert.Equal(unseenScore.PredictedScore, seenScore.PredictedScore); Assert.True(unseenScore.AbilityElo > seenScore.AbilityElo);
Assert.Null(unseenScore.AbilityElo);
Assert.Null(seenScore.AbilityElo);
} }
[Theory] [Theory]
@@ -209,64 +111,11 @@ public sealed class ExamSimulationTests
Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _)); 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) => private static List<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
Enumerable.Range(0, count).Select(index => new ExamSimulationItem Enumerable.Range(0, count).Select(index => new ExamSimulationItem
{ {
QuestionExternalId = $"question-{index}", QuestionExternalId = $"question-{index}",
QuestionElo = 1000, QuestionElo = 1000,
QuestionDifficulty = 3,
WasSeen = wasSeen, WasSeen = wasSeen,
BlueprintCategory = "Cardiovascular" BlueprintCategory = "Cardiovascular"
}).ToList(); }).ToList();
@@ -274,5 +123,5 @@ public sealed class ExamSimulationTests
private static Dictionary<string, Question> BuildQuestions(int count) => private static Dictionary<string, Question> BuildQuestions(int count) =>
Enumerable.Range(0, count).ToDictionary( Enumerable.Range(0, count).ToDictionary(
index => $"question-{index}", index => $"question-{index}",
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A", Difficulty = 3 }); index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A" });
} }
@@ -1,79 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using QuestionsApi.Models;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/credits")]
[Authorize(Roles = "USER,SUPER")]
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] [ApiController]
[Route("api/exam-simulations")] [Route("api/exam-simulations")]
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
{ {
[HttpGet("readiness")] [HttpGet("readiness")]
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
@@ -17,33 +17,12 @@ public class ExamSimulationsController(ExamSimulationStore simulations, CreditSt
[HttpPost] [HttpPost]
public async Task<IActionResult> Create() 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 try
{ {
var simulation = await simulations.CreateAsync(userId); var simulation = await simulations.CreateAsync(UserId());
return Ok(await simulations.ToResponseAsync(simulation)); return Ok(await simulations.ToResponseAsync(simulation));
} }
catch (InvalidOperationException exception) catch (InvalidOperationException exception) { return Conflict(exception.Message); }
{
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")] [HttpGet("preview-results")]
@@ -19,7 +19,6 @@ public class QuestionsController : ControllerBase
private readonly ServiceMetrics _metrics; private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks; private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue; private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
public QuestionsController( public QuestionsController(
QuestionStore questions, QuestionStore questions,
@@ -29,8 +28,7 @@ public class QuestionsController : ControllerBase
AnswerStatisticsStore statistics, AnswerStatisticsStore statistics,
ServiceMetrics metrics, ServiceMetrics metrics,
QuestionBookmarkStore bookmarks, QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue, ReviewQueueStore reviewQueue)
CreditStore credits)
{ {
_questions = questions; _questions = questions;
_attempts = attempts; _attempts = attempts;
@@ -40,7 +38,6 @@ public class QuestionsController : ControllerBase
_metrics = metrics; _metrics = metrics;
_bookmarks = bookmarks; _bookmarks = bookmarks;
_reviewQueue = reviewQueue; _reviewQueue = reviewQueue;
_credits = credits;
} }
[HttpGet("systems")] [HttpGet("systems")]
@@ -157,8 +154,7 @@ public class QuestionsController : ControllerBase
req.ChoiceId.Trim() == question.CorrectChoiceId, req.ChoiceId.Trim() == question.CorrectChoiceId,
false, false,
presentedChoiceIds, presentedChoiceIds,
req.DurationSeconds, req.DurationSeconds);
await _credits.GetBankCycleAsync(userId));
if (record.Inserted) if (record.Inserted)
{ {
_metrics.AttemptCreated(); _metrics.AttemptCreated();
@@ -176,8 +172,6 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate(); _metrics.AttemptDuplicate();
} }
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit)
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
var ratingOwner = Guid.NewGuid().ToString("N"); var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync( var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -210,8 +204,7 @@ public class QuestionsController : ControllerBase
question, question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo, QuestionElo = rating.QuestionElo,
Rating = rating, Rating = rating
Credits = await _credits.GetStatusAsync(userId)
}); });
} }
@@ -277,8 +270,7 @@ public class QuestionsController : ControllerBase
userId, userId,
"", "",
false, false,
true, true);
bankCycle: await _credits.GetBankCycleAsync(userId));
if (record.Inserted) if (record.Inserted)
_metrics.AttemptCreated(); _metrics.AttemptCreated();
else else
-2
View File
@@ -349,8 +349,6 @@ public class CcsUserRating
public string UserId { get; set; } = ""; public string UserId { get; set; } = "";
public int Elo { get; set; } = 1000; public int Elo { get; set; } = 1000;
public int RatedCaseCount { get; set; } public int RatedCaseCount { get; set; }
public int? HistoryBaselineElo { get; set; }
public DateTime? HistoryStartedUtc { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
} }
-183
View File
@@ -1,183 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
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
{
public string OperationId { get; set; } = "";
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]
[BsonRepresentation(BsonType.ObjectId)]
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;
}
public sealed class CreditStatusResponse
{
public int Credits { get; set; }
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();
}
@@ -10,13 +10,6 @@ public sealed class DashboardResponse
public int CcsCasesCompleted { get; set; } public int CcsCasesCompleted { get; set; }
public int FirstAttemptAccuracy { get; set; } public int FirstAttemptAccuracy { get; set; }
public int AccuracySampleSize { get; set; } public int AccuracySampleSize { get; set; }
public int? AccuracyDeltaVsPreviousSample { get; set; }
public int? QuestionBankRatingDeltaThisWeek { get; set; }
public int? QuestionBankPercentile { get; set; }
public int? CcsRatingDeltaThisWeek { get; set; }
public int? CcsPercentile { get; set; }
public string? ExamReadinessLabel { get; set; }
public int? ExamReadinessProjectedPassPercent { get; set; }
public int QuestionsThisWeek { get; set; } public int QuestionsThisWeek { get; set; }
public int StudyTimeSecondsThisWeek { get; set; } public int StudyTimeSecondsThisWeek { get; set; }
public int CurrentStreak { get; set; } public int CurrentStreak { get; set; }
@@ -40,7 +33,6 @@ public sealed class DashboardTrendPoint
public int Total { get; set; } public int Total { get; set; }
public int Accuracy { get; set; } public int Accuracy { get; set; }
public int? QuestionBankRating { get; set; } public int? QuestionBankRating { get; set; }
public int? CcsRating { get; set; }
} }
public sealed class DashboardSystemPerformance public sealed class DashboardSystemPerformance
+1 -6
View File
@@ -41,7 +41,6 @@ public class ExamSimulationItem
public int Position { get; set; } public int Position { get; set; }
public List<string> PresentedChoiceIds { get; set; } = []; public List<string> PresentedChoiceIds { get; set; } = [];
public int QuestionElo { get; set; } = 1000; public int QuestionElo { get; set; } = 1000;
public int QuestionDifficulty { get; set; }
public bool WasSeen { get; set; } public bool WasSeen { get; set; }
public string BlueprintCategory { get; set; } = "Other"; public string BlueprintCategory { get; set; } = "Other";
} }
@@ -53,7 +52,6 @@ public class ExamSimulationResponse
public List<string> EliminatedChoiceIds { get; set; } = []; public List<string> EliminatedChoiceIds { get; set; } = [];
public bool Flagged { get; set; } public bool Flagged { get; set; }
public int ElapsedSeconds { get; set; } public int ElapsedSeconds { get; set; }
public bool? IsSubmitted { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
} }
@@ -66,9 +64,7 @@ public class ExamSimulationScore
public int Correct { get; set; } public int Correct { get; set; }
public int Total { get; set; } public int Total { get; set; }
public int SeenCount { get; set; } public int SeenCount { get; set; }
public double RawAccuracy { get; set; } public double AbilityElo { get; set; }
public double DifficultyAdjustedAccuracy { get; set; }
public double? AbilityElo { get; set; }
public Dictionary<string, ExamSimulationCategoryScore> Categories { get; set; } = []; public Dictionary<string, ExamSimulationCategoryScore> Categories { get; set; } = [];
} }
@@ -85,7 +81,6 @@ public class SaveSimulationResponseRequest
public List<string> EliminatedChoiceIds { get; set; } = []; public List<string> EliminatedChoiceIds { get; set; } = [];
public bool Flagged { get; set; } public bool Flagged { get; set; }
public int ElapsedSeconds { get; set; } public int ElapsedSeconds { get; set; }
public bool IsSubmitted { get; set; }
} }
public class ExamSimulationQuestionResponse public class ExamSimulationQuestionResponse
@@ -11,7 +11,6 @@ public class QuestionAttempt
public string QuestionExternalId { get; set; } = ""; public string QuestionExternalId { get; set; } = "";
public string UserId { get; set; } = ""; public string UserId { get; set; } = "";
public int BankCycle { get; set; }
public string ChoiceId { get; set; } = ""; public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = []; public List<string> PresentedChoiceIds { get; set; } = [];
public bool TracksPresentation { get; set; } public bool TracksPresentation { get; set; }
@@ -49,5 +49,4 @@ public class FirstQuestionAttemptRequest
public string ChoiceId { get; set; } = ""; public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = []; public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; } public int DurationSeconds { get; set; }
public bool MetTimeLimit { get; set; } = true;
} }
@@ -141,7 +141,6 @@ public class FirstQuestionAttemptResponse
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = []; public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000; public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; } public RatingChangeResponse? Rating { get; set; }
public CreditStatusResponse? Credits { get; set; }
} }
public class SkipQuestionResponse public class SkipQuestionResponse
-2
View File
@@ -47,6 +47,4 @@ public class RatingProfileResponse
public int OverallElo { get; set; } public int OverallElo { get; set; }
public int RatedQuestionCount { get; set; } public int RatedQuestionCount { get; set; }
public Dictionary<string, int> CategoryElos { get; set; } = []; public Dictionary<string, int> CategoryElos { get; set; } = [];
public string Tier { get; set; } = "";
public int TierDivision { get; set; }
} }
-20
View File
@@ -17,12 +17,6 @@ if (args.FirstOrDefault() == "promote-content")
return; return;
} }
if (args.FirstOrDefault() == "promote-questions")
{
Environment.ExitCode = await QuestionPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "promote-dynamic-questions") if (args.FirstOrDefault() == "promote-dynamic-questions")
{ {
Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
@@ -55,17 +49,6 @@ if (args.FirstOrDefault() == "flag-media-questions")
return; 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); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -77,8 +60,6 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
}); });
builder.Services.AddSingleton<MongoDatabaseProvider>(); builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>();
builder.Services.AddSingleton<QuestionStore>(); builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>(); builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>(); builder.Services.AddSingleton<AnswerStatisticsStore>();
@@ -125,7 +106,6 @@ builder.Services.AddSingleton<DonationStore>();
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>(); builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
builder.Services.AddSingleton<ServiceMetrics>(); builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>(); builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
builder.Services.AddHostedService<ExamSimulationScoringMigrationService>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddRateLimiter(options => builder.Services.AddRateLimiter(options =>
{ {
@@ -31,12 +31,6 @@ public sealed class CcsRatingStore
return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount }; return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount };
} }
public async Task<CcsUserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserAsync(userId);
return await EnsureHistoryBaselineAsync(rating, nowUtc);
}
public async Task<CcsRatingChange> ApplyAsync(CcsAttempt attempt) public async Task<CcsRatingChange> ApplyAsync(CcsAttempt attempt)
{ {
var actual = Math.Clamp((attempt.Score?.Percent ?? 0) / 100.0, 0, 1); var actual = Math.Clamp((attempt.Score?.Percent ?? 0) / 100.0, 0, 1);
@@ -58,7 +52,6 @@ public sealed class CcsRatingStore
} }
var user = await GetOrCreateUserAsync(attempt.UserId); var user = await GetOrCreateUserAsync(attempt.UserId);
user = await EnsureHistoryBaselineAsync(user, DateTime.UtcNow);
var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId); var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId);
var expected = ExpectedScore(user.Elo, caseRating.Elo); var expected = ExpectedScore(user.Elo, caseRating.Elo);
var userDelta = (int)Math.Round(UserK(user.RatedCaseCount) * (actual - expected), MidpointRounding.AwayFromZero); var userDelta = (int)Math.Round(UserK(user.RatedCaseCount) * (actual - expected), MidpointRounding.AwayFromZero);
@@ -144,26 +137,6 @@ public sealed class CcsRatingStore
} }
} }
private async Task<CcsUserRating> EnsureHistoryBaselineAsync(CcsUserRating rating, DateTime nowUtc)
{
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
return rating;
var result = await _users.UpdateOneAsync(
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
Builders<CcsUserRating>.Update
.Set(value => value.HistoryBaselineElo, rating.Elo)
.Set(value => value.HistoryStartedUtc, nowUtc));
if (result.ModifiedCount > 0)
{
rating.HistoryBaselineElo = rating.Elo;
rating.HistoryStartedUtc = nowUtc;
return rating;
}
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
}
private async Task<CcsCaseRating> UpdateCaseAsync(CcsCaseRating current, int delta) private async Task<CcsCaseRating> UpdateCaseAsync(CcsCaseRating current, int delta)
{ {
while (true) while (true)
@@ -25,11 +25,8 @@ public static class ContentPromotionCommand
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1)); await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1)); await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions") var questions = await source.GetCollection<BsonDocument>("Questions")
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync(); .Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var questions = sourceQuestions
.Select(QuestionPromotionCommand.PrepareForProduction)
.ToList();
var mediaIds = CollectMediaIds(questions); var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0 var media = mediaIds.Count == 0
? [] ? []
-300
View File
@@ -1,300 +0,0 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly TimeProvider _clock;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_clock = clock;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
}
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
{
if (string.IsNullOrWhiteSpace(attemptId)) return false;
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserCreditAccount>.Filter;
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
{
new BsonDocument("$size", "$RecentEarnings"),
CreditEconomy.RollingEarningLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.ElemMatch(value => value.RecentEarnings,
earning => earning.OperationId == attemptId)) &
belowCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, 1)
.Push(value => value.RecentEarnings, new CreditEvent
{
OperationId = attemptId,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
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(
value => value.UserId == userId,
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)
};
}
+12 -80
View File
@@ -19,8 +19,6 @@ public sealed class DashboardStore
private readonly FlashcardStudyService _flashcards; private readonly FlashcardStudyService _flashcards;
private readonly FlashcardScheduleStore _flashcardSchedules; private readonly FlashcardScheduleStore _flashcardSchedules;
private readonly RatingStore _ratingStore; private readonly RatingStore _ratingStore;
private readonly CcsRatingStore _ccsRatingStore;
private readonly IMongoCollection<UserRating> _ratings;
public static readonly string[] Step3Systems = public static readonly string[] Step3Systems =
[ [
@@ -47,8 +45,7 @@ public sealed class DashboardStore
DynamicQuestionStore dynamicQuestions, DynamicQuestionStore dynamicQuestions,
FlashcardStudyService flashcards, FlashcardStudyService flashcards,
FlashcardScheduleStore flashcardSchedules, FlashcardScheduleStore flashcardSchedules,
RatingStore ratingStore, RatingStore ratingStore)
CcsRatingStore ccsRatingStore)
{ {
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts"); _attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings"); _ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
@@ -57,14 +54,12 @@ public sealed class DashboardStore
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations"); _simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks"); _bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads"); _notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
_questions = questions; _questions = questions;
_reviewQueue = reviewQueue; _reviewQueue = reviewQueue;
_dynamicQuestions = dynamicQuestions; _dynamicQuestions = dynamicQuestions;
_flashcards = flashcards; _flashcards = flashcards;
_flashcardSchedules = flashcardSchedules; _flashcardSchedules = flashcardSchedules;
_ratingStore = ratingStore; _ratingStore = ratingStore;
_ccsRatingStore = ccsRatingStore;
} }
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc) public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
@@ -74,29 +69,24 @@ public sealed class DashboardStore
var weekStart = today.AddDays(-6); var weekStart = today.AddDays(-6);
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync(); var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc); var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc); var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync(); var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var ccsAllAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId).ToListAsync();
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue); && value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue);
var simulationsTask = _simulations.Find(value => value.UserId == userId && !value.IsSample && value.CreatedUtc >= trendStart).ToListAsync(); var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var completedSimulationsTask = _simulations.Find(value => value.UserId == userId
&& value.State == ExamSimulationStates.Completed && value.Score != null)
.SortByDescending(value => value.CompletedUtc).ToListAsync();
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId); var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart); var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart); var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart);
var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart); var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart);
var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc); var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, ccsAllAttemptsTask, completedCcsCountTask, await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
simulationsTask, completedSimulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask, simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
flashcardReviewsTask, dynamicReviewsTask); flashcardReviewsTask, dynamicReviewsTask);
var attempts = attemptsTask.Result; var attempts = attemptsTask.Result;
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList(); var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
var recentAnswered = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Take(100).ToList(); var recentAnswered = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Take(100).ToList();
var previousHundred = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Skip(100).Take(100).ToList();
var questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId)); var questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId));
var ccsAttempts = ccsAttemptsTask.Result; var ccsAttempts = ccsAttemptsTask.Result;
var simulations = simulationsTask.Result; var simulations = simulationsTask.Result;
@@ -104,39 +94,16 @@ public sealed class DashboardStore
await _reviewQueue.EnsureUserQueueAsync(userId); await _reviewQueue.EnsureUserQueueAsync(userId);
var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId); var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId);
var qbankElo = ratingTask.Result.OverallElo;
var ccsElo = ccsRatingTask.Result.Elo;
var qbankHigherTask = _ratings.CountDocumentsAsync(value => value.OverallElo > qbankElo);
var qbankTotalTask = _ratings.CountDocumentsAsync(FilterDefinition<UserRating>.Empty);
var ccsHigherTask = _ccsRatings.CountDocumentsAsync(value => value.Elo > ccsElo);
var ccsTotalTask = _ccsRatings.CountDocumentsAsync(FilterDefinition<CcsUserRating>.Empty);
await Task.WhenAll(qbankHigherTask, qbankTotalTask, ccsHigherTask, ccsTotalTask);
var latestScore = SelectLatestReadinessScore(completedSimulationsTask.Result);
var accuracySampleSize = recentAnswered.Count;
var firstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count);
var performanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, ccsAllAttemptsTask.Result, ccsRatingTask.Result, today);
var weekAgoTrend = performanceTrend[^8];
var response = new DashboardResponse var response = new DashboardResponse
{ {
QuestionBankRating = qbankElo, QuestionBankRating = ratingTask.Result.OverallElo,
RatedQuestionCount = ratingTask.Result.RatedQuestionCount, RatedQuestionCount = ratingTask.Result.RatedQuestionCount,
CcsRating = ccsElo, CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
RatedCcsCaseCount = ccsRatingTask.Result.RatedCaseCount, RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
QuestionsCompleted = answeredAttempts.Count, QuestionsCompleted = answeredAttempts.Count,
CcsCasesCompleted = checked((int)completedCcsCountTask.Result), CcsCasesCompleted = checked((int)completedCcsCountTask.Result),
AccuracySampleSize = accuracySampleSize, AccuracySampleSize = recentAnswered.Count,
FirstAttemptAccuracy = firstAttemptAccuracy, FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
AccuracyDeltaVsPreviousSample = previousHundred.Count == 0
? null
: firstAttemptAccuracy - Percentage(previousHundred.Count(value => value.IsCorrect), previousHundred.Count),
QuestionBankRatingDeltaThisWeek = weekAgoTrend.QuestionBankRating is null ? null : qbankElo - weekAgoTrend.QuestionBankRating,
QuestionBankPercentile = Percentile(qbankHigherTask.Result, qbankTotalTask.Result),
CcsRatingDeltaThisWeek = weekAgoTrend.CcsRating is null ? null : ccsElo - weekAgoTrend.CcsRating,
CcsPercentile = Percentile(ccsHigherTask.Result, ccsTotalTask.Result),
ExamReadinessLabel = latestScore is null ? null : ReadinessLabel(latestScore.PassProbability),
ExamReadinessProjectedPassPercent = latestScore is null ? null : (int)Math.Round(latestScore.PassProbability * 100),
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart), QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
BookmarkCount = checked((int)bookmarkCountTask.Result), BookmarkCount = checked((int)bookmarkCountTask.Result),
NoteCount = CountNotes(notepadTask.Result?.Content), NoteCount = CountNotes(notepadTask.Result?.Content),
@@ -147,7 +114,7 @@ public sealed class DashboardStore
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
}; };
response.PerformanceTrend = performanceTrend; response.PerformanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, today);
response.Systems = BuildSystemPerformance(answeredAttempts, questionMap); response.Systems = BuildSystemPerformance(answeredAttempts, questionMap);
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset => response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
@@ -181,22 +148,6 @@ public sealed class DashboardStore
public static int Percentage(int correct, int total) => public static int Percentage(int correct, int total) =>
total <= 0 ? 0 : (int)Math.Round(correct * 100.0 / total, MidpointRounding.AwayFromZero); total <= 0 ? 0 : (int)Math.Round(correct * 100.0 / total, MidpointRounding.AwayFromZero);
public static int? Percentile(long usersRatedHigher, long totalUsers) =>
totalUsers <= 0 ? null : Math.Max(1, (int)Math.Round(usersRatedHigher * 100.0 / totalUsers, MidpointRounding.AwayFromZero));
// Thresholds are a reasonable default for labeling an existing, tested
// pass-probability estimate (ExamSimulationScoring.Calculate) - tune freely,
// this isn't a validated clinical cutoff.
public static string ReadinessLabel(double passProbability) =>
passProbability >= 0.7 ? "High" : passProbability >= 0.4 ? "Medium" : "Low";
// completedSimulationsByRecency must already be sorted newest-first (by CompletedUtc).
// Sample simulations (QA-only, seeded via the SUPER-role "preview results" tooling with
// scripted answers) never reflect real performance, so they're skipped here rather than
// in the Mongo filter - keeping the "which one counts" decision unit-testable.
public static ExamSimulationScore? SelectLatestReadinessScore(IEnumerable<ExamSimulation> completedSimulationsByRecency) =>
completedSimulationsByRecency.FirstOrDefault(value => !value.IsSample)?.Score;
public static int CalculateCurrentStreak(IEnumerable<DateTime> activeDates, DateTime todayUtc) public static int CalculateCurrentStreak(IEnumerable<DateTime> activeDates, DateTime todayUtc)
{ {
var dates = activeDates.Select(value => value.Date).ToHashSet(); var dates = activeDates.Select(value => value.Date).ToHashSet();
@@ -214,12 +165,9 @@ public sealed class DashboardStore
public static List<DashboardTrendPoint> BuildPerformanceTrend( public static List<DashboardTrendPoint> BuildPerformanceTrend(
IEnumerable<QuestionAttempt> attempts, IEnumerable<QuestionAttempt> attempts,
UserRating rating, UserRating rating,
IEnumerable<CcsAttempt> ccsAttempts,
CcsUserRating ccsRating,
DateTime todayUtc) DateTime todayUtc)
{ {
var attemptList = attempts.ToList(); var attemptList = attempts.ToList();
var ccsAttemptList = ccsAttempts.ToList();
var trendStart = todayUtc.Date.AddDays(-29); var trendStart = todayUtc.Date.AddDays(-29);
var ratingEvents = new List<(DateTime AtUtc, int Elo)>(); var ratingEvents = new List<(DateTime AtUtc, int Elo)>();
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue) if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
@@ -229,18 +177,8 @@ public sealed class DashboardStore
.Select(value => (value.RatingAppliedUtc!.Value, value.UserOverallEloAfter!.Value))); .Select(value => (value.RatingAppliedUtc!.Value, value.UserOverallEloAfter!.Value)));
ratingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc)); ratingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
var ccsRatingEvents = new List<(DateTime AtUtc, int Elo)>();
if (ccsRating.HistoryBaselineElo.HasValue && ccsRating.HistoryStartedUtc.HasValue)
ccsRatingEvents.Add((ccsRating.HistoryStartedUtc.Value, ccsRating.HistoryBaselineElo.Value));
ccsRatingEvents.AddRange(ccsAttemptList
.Where(value => value.Rating is { AppliedUtc: not null })
.Select(value => (value.Rating!.AppliedUtc!.Value, value.Rating!.UserElo)));
ccsRatingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
int? currentRating = null; int? currentRating = null;
int? currentCcsRating = null;
var eventIndex = 0; var eventIndex = 0;
var ccsEventIndex = 0;
var result = new List<DashboardTrendPoint>(); var result = new List<DashboardTrendPoint>();
foreach (var offset in Enumerable.Range(0, 30)) foreach (var offset in Enumerable.Range(0, 30))
{ {
@@ -251,11 +189,6 @@ public sealed class DashboardStore
currentRating = ratingEvents[eventIndex].Elo; currentRating = ratingEvents[eventIndex].Elo;
eventIndex++; eventIndex++;
} }
while (ccsEventIndex < ccsRatingEvents.Count && ccsRatingEvents[ccsEventIndex].AtUtc < nextDate)
{
currentCcsRating = ccsRatingEvents[ccsEventIndex].Elo;
ccsEventIndex++;
}
var daily = attemptList var daily = attemptList
.Where(value => !value.IsSkip && value.CreatedUtc >= date && value.CreatedUtc < nextDate) .Where(value => !value.IsSkip && value.CreatedUtc >= date && value.CreatedUtc < nextDate)
@@ -267,8 +200,7 @@ public sealed class DashboardStore
Correct = correct, Correct = correct,
Total = daily.Count, Total = daily.Count,
Accuracy = Percentage(correct, daily.Count), Accuracy = Percentage(correct, daily.Count),
QuestionBankRating = currentRating, QuestionBankRating = currentRating
CcsRating = currentCcsRating
}); });
} }
return result; return result;
@@ -4,35 +4,33 @@ namespace QuestionsApi.Services;
public static class ExamSimulationScoring public static class ExamSimulationScoring
{ {
public const string ModelVersion = "provisional-2"; public const string ModelVersion = "provisional-1";
private const double PassingAccuracy = 0.60; private const double PriorMean = 1000.0;
private const double ScoreIntercept = 50.0; private const double PriorSd = 400.0;
private const double ScoreScale = 250.0;
public static ExamSimulationScore Calculate( public static ExamSimulationScore Calculate(
IReadOnlyList<ExamSimulationItem> items, IReadOnlyList<ExamSimulationItem> items,
IReadOnlyDictionary<string, ExamSimulationResponse> responses, IReadOnlyDictionary<string, ExamSimulationResponse> responses,
IReadOnlyDictionary<string, Question> questions) 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 correct = 0;
var seen = 0; var seen = 0;
var weightedCorrect = 0.0;
var weightSum = 0.0;
var squaredWeightSum = 0.0;
var categories = new Dictionary<string, ExamSimulationCategoryScore>(); var categories = new Dictionary<string, ExamSimulationCategoryScore>();
foreach (var item in items) foreach (var item in items)
{ {
questions.TryGetValue(item.QuestionExternalId, out var question); if (!questions.TryGetValue(item.QuestionExternalId, out var question))
continue;
responses.TryGetValue(item.QuestionExternalId, out var response); responses.TryGetValue(item.QuestionExternalId, out var response);
var isCorrect = question is not null && response?.ChoiceId == question.CorrectChoiceId; var isCorrect = response?.ChoiceId == question.CorrectChoiceId;
if (isCorrect) correct++; if (isCorrect) correct++;
if (item.WasSeen) seen++; if (item.WasSeen) seen++;
var difficulty = ResolveDifficulty(item, question); var weight = item.WasSeen ? 0.5 : 1.0;
var weight = DifficultyWeight(difficulty); var probability = Expected(ability, item.QuestionElo);
weightSum += weight; information += weight * probability * (1.0 - probability) * Math.Pow(Math.Log(10.0) / 400.0, 2);
squaredWeightSum += weight * weight;
if (isCorrect) weightedCorrect += weight;
if (!categories.TryGetValue(item.BlueprintCategory, out var category)) if (!categories.TryGetValue(item.BlueprintCategory, out var category))
{ {
category = new ExamSimulationCategoryScore(); category = new ExamSimulationCategoryScore();
@@ -42,53 +40,51 @@ public static class ExamSimulationScoring
if (isCorrect) category.Correct++; if (isCorrect) category.Correct++;
} }
var rawAccuracy = items.Count == 0 ? 0.0 : (double)correct / items.Count; var abilitySe = Math.Sqrt(1.0 / information);
var adjustedAccuracy = weightSum == 0.0 ? 0.0 : weightedCorrect / weightSum; var scoreSe = abilitySe * 15.0 / 200.0;
var predicted = ToScore(adjustedAccuracy); var combinedSe = Math.Sqrt(7.0 * 7.0 + 15.0 * 15.0 + scoreSe * scoreSe);
var effectiveSampleSize = squaredWeightSum == 0.0 ? 0.0 : weightSum * weightSum / squaredWeightSum; var range = (int)Math.Ceiling(combinedSe);
var accuracySe = effectiveSampleSize == 0.0 var passProbability = NormalCdf((predicted - 200.0) / combinedSe);
? 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 return new ExamSimulationScore
{ {
PredictedScore = predicted, PredictedScore = predicted,
RangeLow = rangeLow, RangeLow = Math.Clamp(predicted - range, 1, 300),
RangeHigh = rangeHigh, RangeHigh = Math.Clamp(predicted + range, 1, 300),
PassProbability = Math.Round(passProbability, 4), PassProbability = Math.Round(passProbability, 4),
Correct = correct, Correct = correct,
Total = items.Count, Total = items.Count,
SeenCount = seen, SeenCount = seen,
RawAccuracy = Math.Round(rawAccuracy, 4), AbilityElo = Math.Round(ability, 1),
DifficultyAdjustedAccuracy = Math.Round(adjustedAccuracy, 4),
AbilityElo = null,
Categories = categories Categories = categories
}; };
} }
public static int ResolveDifficulty(ExamSimulationItem item, Question? question = null) private static double EstimateAbility(
IReadOnlyList<ExamSimulationItem> items,
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
IReadOnlyDictionary<string, Question> questions)
{ {
if (item.QuestionDifficulty is >= 1 and <= 5) return item.QuestionDifficulty; var low = 100.0;
return question?.Difficulty is >= 1 and <= 5 ? question.Difficulty : 3; var high = 1900.0;
for (var iteration = 0; iteration < 80; iteration++)
{
var ability = (low + high) / 2.0;
var derivative = -(ability - PriorMean) / (PriorSd * PriorSd);
foreach (var item in items)
{
if (!questions.TryGetValue(item.QuestionExternalId, out var question)) continue;
responses.TryGetValue(item.QuestionExternalId, out var response);
var actual = response?.ChoiceId == question.CorrectChoiceId ? 1.0 : 0.0;
var weight = item.WasSeen ? 0.5 : 1.0;
derivative += weight * (actual - Expected(ability, item.QuestionElo)) * Math.Log(10.0) / 400.0;
}
if (derivative > 0) low = ability; else high = ability;
}
return (low + high) / 2.0;
} }
private static double DifficultyWeight(int difficulty) => difficulty switch private static double Expected(double ability, int difficulty) =>
{ 1.0 / (1.0 + Math.Pow(10.0, (difficulty - ability) / 400.0));
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))); private static double NormalCdf(double value) => 0.5 * (1.0 + Erf(value / Math.Sqrt(2.0)));
@@ -1,37 +0,0 @@
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,7 +13,6 @@ public class ExamSimulationStore
private readonly IMongoCollection<Step3Outcome> _outcomes; private readonly IMongoCollection<Step3Outcome> _outcomes;
private readonly QuestionStore _questions; private readonly QuestionStore _questions;
private readonly QuestionAttemptStore _attempts; private readonly QuestionAttemptStore _attempts;
private readonly CreditStore _credits;
private readonly AnswerStatisticsStore _statistics; private readonly AnswerStatisticsStore _statistics;
private readonly RatingStore _ratings; private readonly RatingStore _ratings;
private readonly ServiceMetrics _metrics; private readonly ServiceMetrics _metrics;
@@ -23,7 +22,6 @@ public class ExamSimulationStore
MongoDatabaseProvider mongo, MongoDatabaseProvider mongo,
QuestionStore questions, QuestionStore questions,
QuestionAttemptStore attempts, QuestionAttemptStore attempts,
CreditStore credits,
AnswerStatisticsStore statistics, AnswerStatisticsStore statistics,
RatingStore ratings, RatingStore ratings,
ServiceMetrics metrics, ServiceMetrics metrics,
@@ -33,7 +31,6 @@ public class ExamSimulationStore
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes"); _outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
_questions = questions; _questions = questions;
_attempts = attempts; _attempts = attempts;
_credits = credits;
_statistics = statistics; _statistics = statistics;
_ratings = ratings; _ratings = ratings;
_metrics = metrics; _metrics = metrics;
@@ -71,8 +68,7 @@ public class ExamSimulationStore
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList(); var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
if (bank.Count < RequiredQuestions) if (bank.Count < RequiredQuestions)
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required."); throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required.");
var bankCycle = await _credits.GetBankCycleAsync(userId); var seen = (await _attempts.GetUserQuestionIdsAsync(userId)).ToHashSet(StringComparer.Ordinal);
var seen = (await _attempts.GetUserQuestionIdsAsync(userId, bankCycle)).ToHashSet(StringComparer.Ordinal);
var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId)); var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
var targets = BuildTargets(); var targets = BuildTargets();
var selected = SelectForm(bank, seen, targets); var selected = SelectForm(bank, seen, targets);
@@ -91,7 +87,6 @@ public class ExamSimulationStore
Position = offset, Position = offset,
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)), PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000), QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000),
QuestionDifficulty = question.Difficulty,
WasSeen = seen.Contains(question.ExternalId), WasSeen = seen.Contains(question.ExternalId),
BlueprintCategory = BlueprintCategory(question) BlueprintCategory = BlueprintCategory(question)
}); });
@@ -129,7 +124,6 @@ public class ExamSimulationStore
Position = PositionWithinBlock(index), Position = PositionWithinBlock(index),
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)), PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
QuestionElo = 1000, QuestionElo = 1000,
QuestionDifficulty = question.Difficulty,
WasSeen = false, WasSeen = false,
BlueprintCategory = BlueprintCategory(question) BlueprintCategory = BlueprintCategory(question)
}).ToList(); }).ToList();
@@ -175,22 +169,15 @@ public class ExamSimulationStore
(item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive)) (item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive))
.SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync(); .SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync();
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId) public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId) =>
{ (await _simulations.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
var simulation = await _simulations .SortByDescending(item => item.CompletedUtc).FirstOrDefaultAsync())?.Score;
.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) public async Task<ExamSimulation?> GetOwnedAsync(string id, string userId)
{ {
var simulation = await _simulations.Find(item => item.Id == id && item.UserId == userId).FirstOrDefaultAsync(); 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) if (simulation is not null && simulation.State == ExamSimulationStates.BlockActive && simulation.BlockDeadlineUtc <= DateTime.UtcNow)
simulation = await SubmitBlockAsync(simulation, true); simulation = await SubmitBlockAsync(simulation, true);
if (simulation is not null)
simulation = await UpgradeScoringModelAsync(simulation);
return simulation; return simulation;
} }
@@ -204,7 +191,6 @@ public class ExamSimulationStore
var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync(); var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit; var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1); if (hasMore) simulations.RemoveAt(simulations.Count - 1);
await UpgradeScoringModelsAsync(simulations);
return new CompletedExamSimulationPage return new CompletedExamSimulationPage
{ {
Items = simulations.Select(item => new CompletedExamSimulationSummary Items = simulations.Select(item => new CompletedExamSimulationSummary
@@ -271,7 +257,6 @@ public class ExamSimulationStore
var simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync(); var simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit; var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1); if (hasMore) simulations.RemoveAt(simulations.Count - 1);
await UpgradeScoringModelsAsync(simulations.Where(item => item.State == ExamSimulationStates.Completed));
var last = simulations.LastOrDefault(); var last = simulations.LastOrDefault();
return new ExamSimulationHistoryPage return new ExamSimulationHistoryPage
{ {
@@ -348,53 +333,23 @@ public class ExamSimulationStore
if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "") if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "")
throw new ArgumentException("Choice is not presented for this question."); throw new ArgumentException("Choice is not presented for this question.");
var response = simulation.Responses.FirstOrDefault(candidate => candidate.QuestionExternalId == request.QuestionExternalId); 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) if (response is null)
{ {
response = new ExamSimulationResponse response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId };
{
QuestionExternalId = request.QuestionExternalId,
IsSubmitted = false
};
simulation.Responses.Add(response); simulation.Responses.Add(response);
} }
response.ChoiceId = request.ChoiceId; response.ChoiceId = request.ChoiceId;
response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList(); response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList();
response.Flagged = request.Flagged; response.Flagged = request.Flagged;
response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800); response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800);
response.IsSubmitted = request.IsSubmitted;
response.UpdatedUtc = DateTime.UtcNow; response.UpdatedUtc = DateTime.UtcNow;
simulation.UpdatedUtc = DateTime.UtcNow; simulation.UpdatedUtc = DateTime.UtcNow;
await ReplaceAsync(simulation); await ReplaceAsync(simulation);
return 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) public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
{ {
if (IsAlreadySubmittedState(simulation.State))
return simulation;
if (simulation.State != ExamSimulationStates.BlockActive) if (simulation.State != ExamSimulationStates.BlockActive)
throw new InvalidOperationException("There is no active block to submit."); throw new InvalidOperationException("There is no active block to submit.");
if (!expired && simulation.BlockDeadlineUtc is not null) if (!expired && simulation.BlockDeadlineUtc is not null)
@@ -411,9 +366,6 @@ public class ExamSimulationStore
return simulation; return simulation;
} }
public static bool IsAlreadySubmittedState(string state) =>
state is ExamSimulationStates.BetweenBlocks or ExamSimulationStates.Completed;
public async Task AbandonAsync(ExamSimulation simulation) public async Task AbandonAsync(ExamSimulation simulation)
{ {
simulation.State = ExamSimulationStates.Abandoned; simulation.State = ExamSimulationStates.Abandoned;
@@ -481,9 +433,7 @@ public class ExamSimulationStore
{ {
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId)); var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal); var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
SnapshotDifficulties(simulation, questionMap);
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap); simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
simulation.State = ExamSimulationStates.Completed; simulation.State = ExamSimulationStates.Completed;
simulation.CompletedUtc = DateTime.UtcNow; simulation.CompletedUtc = DateTime.UtcNow;
simulation.UpdatedUtc = DateTime.UtcNow; simulation.UpdatedUtc = DateTime.UtcNow;
@@ -493,10 +443,7 @@ public class ExamSimulationStore
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue; if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
responses.TryGetValue(item.QuestionExternalId, out var response); responses.TryGetValue(item.QuestionExternalId, out var response);
var choiceId = response?.ChoiceId ?? ""; var choiceId = response?.ChoiceId ?? "";
var record = await _attempts.RecordFirstAttemptAsync( var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds);
item.QuestionExternalId, simulation.UserId, choiceId,
choiceId == question.CorrectChoiceId, choiceId == "",
item.PresentedChoiceIds, bankCycle: await _credits.GetBankCycleAsync(simulation.UserId));
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (!record.Inserted) continue; if (!record.Inserted) continue;
_metrics.AttemptCreated(); _metrics.AttemptCreated();
@@ -521,71 +468,6 @@ public class ExamSimulationStore
private Task ReplaceAsync(ExamSimulation simulation) => private Task ReplaceAsync(ExamSimulation simulation) =>
_simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, 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) private static int BlockIndexForPosition(int position)
{ {
var consumed = 0; var consumed = 0;
@@ -1,66 +0,0 @@
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,11 +17,10 @@ public class QuestionAttemptStore
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
.Ascending(a => a.QuestionExternalId) .Ascending(a => a.QuestionExternalId)
.Ascending(a => a.UserId) .Ascending(a => a.UserId);
.Ascending(a => a.BankCycle);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>( _col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
firstAttemptIndex, firstAttemptIndex,
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" })); new CreateIndexOptions { Unique = true }));
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId); var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex)); _col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
@@ -39,14 +38,12 @@ public class QuestionAttemptStore
bool isCorrect, bool isCorrect,
bool isSkip = false, bool isSkip = false,
IReadOnlyCollection<string>? presentedChoiceIds = null, IReadOnlyCollection<string>? presentedChoiceIds = null,
int durationSeconds = 0, int durationSeconds = 0)
int bankCycle = 0)
{ {
var attempt = new QuestionAttempt var attempt = new QuestionAttempt
{ {
QuestionExternalId = questionExternalId, QuestionExternalId = questionExternalId,
UserId = userId, UserId = userId,
BankCycle = bankCycle,
ChoiceId = choiceId, ChoiceId = choiceId,
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [], PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
TracksPresentation = presentedChoiceIds is { Count: > 0 }, TracksPresentation = presentedChoiceIds is { Count: > 0 },
@@ -66,8 +63,7 @@ public class QuestionAttemptStore
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{ {
var existing = await _col var existing = await _col
.Find(a => a.QuestionExternalId == questionExternalId && .Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
a.UserId == userId && a.BankCycle == bankCycle)
.FirstAsync(); .FirstAsync();
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing }; return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
} }
@@ -79,17 +75,11 @@ public class QuestionAttemptStore
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc)) Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
.ToListAsync(); .ToListAsync();
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
{
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( _col.Distinct(
attempt => attempt.QuestionExternalId, attempt => attempt.QuestionExternalId,
filter) Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
.ToListAsync(); .ToListAsync();
}
public IMongoCollection<QuestionAttempt> Collection => _col; public IMongoCollection<QuestionAttempt> Collection => _col;
@@ -1,309 +0,0 @@
using MongoDB.Bson;
using MongoDB.Driver;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class QuestionPromotionCommand
{
public const string Confirmation = "PROMOTE_TO_PRD";
internal const string ProductionMediaReviewReason = "Contains media - pending production review";
internal const string ProductionMediaReviewActor = "system:production-promotion";
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
try
{
var options = Parse(args);
if (options.Apply && options.Confirmation != Confirmation)
throw new ArgumentException($"--confirm {Confirmation} is required with --apply");
if (options.SourceDatabase.Equals(options.TargetDatabase, StringComparison.OrdinalIgnoreCase)
&& options.SourceConnection == options.TargetConnection)
throw new ArgumentException("source and target databases must be different");
var source = new MongoClient(options.SourceConnection).GetDatabase(options.SourceDatabase);
var target = new MongoClient(options.TargetConnection).GetDatabase(options.TargetDatabase);
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
.Find(questionFilter).ToListAsync();
var duplicateIds = sourceQuestions
.GroupBy(ExternalId, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToList();
if (duplicateIds.Count > 0)
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
var questions = sourceQuestions.Select(PrepareForProduction).ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
: await source.GetCollection<BsonDocument>("QuestionMediaAssets")
.Find(Builders<BsonDocument>.Filter.In("_id", mediaIds)).ToListAsync();
if (media.Count != mediaIds.Count)
throw new InvalidOperationException($"{mediaIds.Count - media.Count} referenced question media assets are missing");
var sourceIds = questions.Select(ExternalId).ToHashSet(StringComparer.Ordinal);
var targetCollection = target.GetCollection<BsonDocument>("Questions");
var targetQuestions = sourceIds.Count == 0
? []
: await targetCollection.Find(
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
var targetTotal = await targetCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
var comparison = ClassifyQuestions(questions, targetQuestions);
var summary = new PromotionSummary(
questions.Count,
media.Count,
comparison.Inserted,
comparison.Updated,
comparison.Unchanged,
checked((int)Math.Max(0, targetTotal - targetQuestions.Count)),
HashQuestions(questions));
await WriteSummaryAsync(output, options.Apply ? "apply" : "dry-run", options, summary);
if (!options.Apply)
return 0;
await UpsertMediaAsync(target, media);
await UpsertQuestionsAsync(target, questions);
var verifiedQuestions = sourceIds.Count == 0
? []
: await targetCollection.Find(
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
var verification = ClassifyQuestions(questions, verifiedQuestions);
if (verification.Inserted != 0 || verification.Updated != 0)
throw new InvalidOperationException(
$"target verification failed: {verification.Inserted} missing and {verification.Updated} mismatched questions");
var verifiedMedia = mediaIds.Count == 0
? 0
: checked((int)await target.GetCollection<BsonDocument>("QuestionMediaAssets")
.CountDocumentsAsync(Builders<BsonDocument>.Filter.In("_id", mediaIds)));
if (verifiedMedia != mediaIds.Count)
throw new InvalidOperationException(
$"target verification failed: {mediaIds.Count - verifiedMedia} media assets are missing");
await target.GetCollection<BsonDocument>("ContentPromotions").InsertOneAsync(new BsonDocument
{
["PromotionId"] = Guid.NewGuid().ToString("N"),
["AppliedUtc"] = DateTime.UtcNow,
["Operator"] = options.Operator,
["BuildRevision"] = options.BuildRevision,
["SourceDatabase"] = options.SourceDatabase,
["TargetDatabase"] = options.TargetDatabase,
["Scope"] = "questions",
["Eligibility"] = "published_unflagged; media_requires_production_review",
["Questions"] = summary.EligibleQuestions,
["Inserted"] = summary.Inserted,
["Updated"] = summary.Updated,
["Unchanged"] = summary.Unchanged,
["MediaAssets"] = summary.MediaAssets,
["TargetExtras"] = summary.TargetExtras,
["ContentHash"] = summary.ContentHash
});
await output.WriteLineAsync("Question promotion completed and verified.");
return 0;
}
catch (Exception exception)
{
await output.WriteLineAsync($"Question promotion failed: {exception.Message}");
return 1;
}
}
internal static bool IsEligible(BsonDocument question) =>
question.GetValue("IsPublished", false).ToBoolean()
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
internal static BsonDocument PrepareForProduction(BsonDocument source)
{
var prepared = new BsonDocument(source);
if (!HasMedia(prepared))
return prepared;
var reviewUtc = prepared.TryGetValue("UpdatedUtc", out var updated) && updated.IsValidDateTime
? updated
: new BsonDateTime(DateTime.UnixEpoch);
prepared["FlaggedForReviewUtc"] = reviewUtc;
prepared["FlagReason"] = ProductionMediaReviewReason;
prepared["FlaggedByUserId"] = ProductionMediaReviewActor;
return prepared;
}
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
{
var ids = new HashSet<ObjectId>();
foreach (var question in questions)
{
if (!question.TryGetValue("Media", out var raw) || !raw.IsBsonArray)
continue;
foreach (var value in raw.AsBsonArray)
{
if (!value.IsString)
continue;
var reference = value.AsString.Trim();
var candidate = reference.TrimEnd('/').Split('/').LastOrDefault() ?? "";
if (ObjectId.TryParse(reference, out var direct))
ids.Add(direct);
else if (ObjectId.TryParse(candidate, out var fromPath))
ids.Add(fromPath);
else if (reference.Contains("/api/questions/media/", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"question {ExternalId(question)} has an invalid local media reference: {reference}");
}
}
return ids;
}
internal static QuestionComparison ClassifyQuestions(
IEnumerable<BsonDocument> sourceQuestions,
IEnumerable<BsonDocument> targetQuestions)
{
var target = targetQuestions.ToDictionary(ExternalId, StringComparer.Ordinal);
var inserted = 0;
var updated = 0;
var unchanged = 0;
foreach (var source in sourceQuestions)
{
var externalId = ExternalId(source);
if (!target.TryGetValue(externalId, out var existing))
inserted++;
else if (EquivalentQuestion(source, existing))
unchanged++;
else
updated++;
}
return new QuestionComparison(inserted, updated, unchanged);
}
internal static string HashQuestions(IEnumerable<BsonDocument> questions)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var document in questions.OrderBy(ExternalId, StringComparer.Ordinal))
hash.AppendData(Encoding.UTF8.GetBytes(CanonicalQuestion(document).ToJson()));
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
private static bool EquivalentQuestion(BsonDocument source, BsonDocument target) =>
CanonicalQuestion(source).Equals(CanonicalQuestion(target));
private static BsonDocument CanonicalQuestion(BsonDocument source) =>
Canonicalize(new BsonDocument(source.Where(element => element.Name != "_id"))).AsBsonDocument;
private static BsonValue Canonicalize(BsonValue value)
{
if (value.IsBsonDocument)
return new BsonDocument(value.AsBsonDocument
.OrderBy(element => element.Name, StringComparer.Ordinal)
.Select(element => new BsonElement(element.Name, Canonicalize(element.Value))));
if (value.IsBsonArray)
return new BsonArray(value.AsBsonArray.Select(Canonicalize));
return value;
}
private static string ExternalId(BsonDocument document) =>
document.GetValue("ExternalId", "").AsString;
private static bool HasMedia(BsonDocument question) =>
question.TryGetValue("Media", out var media)
&& media.IsBsonArray
&& media.AsBsonArray.Count > 0;
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
{
var collection = target.GetCollection<BsonDocument>("Questions");
foreach (var source in questions)
{
var replacement = new BsonDocument(source.Where(element => element.Name != "_id"));
await collection.ReplaceOneAsync(
Builders<BsonDocument>.Filter.Eq("ExternalId", source["ExternalId"]),
replacement,
new ReplaceOptions { IsUpsert = true });
}
}
private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable<BsonDocument> media)
{
var collection = target.GetCollection<BsonDocument>("QuestionMediaAssets");
foreach (var source in media)
await collection.ReplaceOneAsync(
Builders<BsonDocument>.Filter.Eq("_id", source["_id"]),
source,
new ReplaceOptions { IsUpsert = true });
}
private static Task WriteSummaryAsync(
TextWriter output,
string mode,
PromotionOptions options,
PromotionSummary summary) =>
output.WriteLineAsync(JsonSerializer.Serialize(new
{
mode,
scope = "questions",
eligibility = "published_unflagged; media_requires_production_review",
source = options.SourceDatabase,
target = options.TargetDatabase,
eligibleQuestions = summary.EligibleQuestions,
mediaAssets = summary.MediaAssets,
inserted = summary.Inserted,
updated = summary.Updated,
unchanged = summary.Unchanged,
targetExtras = summary.TargetExtras,
contentHash = summary.ContentHash
}));
private static PromotionOptions Parse(IReadOnlyList<string> args)
{
string Required(string name, string environmentName)
{
var index = args.IndexOf($"--{name}");
var value = index >= 0 && index + 1 < args.Count
? args[index + 1]
: Environment.GetEnvironmentVariable(environmentName);
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException($"--{name} is required")
: value;
}
string Optional(string name, string fallback)
{
var index = args.IndexOf($"--{name}");
return index >= 0 && index + 1 < args.Count ? args[index + 1] : fallback;
}
return new PromotionOptions(
Required("source-connection", "SOURCE_MONGODB_CONNECTION_STRING"),
Required("source-database", "SOURCE_MONGODB_DATABASE"),
Required("target-connection", "TARGET_MONGODB_CONNECTION_STRING"),
Required("target-database", "TARGET_MONGODB_DATABASE"),
args.Contains("--apply"),
Optional("confirm", ""),
Optional("operator", "unknown"),
Optional("build-revision", "unknown"));
}
private sealed record PromotionOptions(
string SourceConnection,
string SourceDatabase,
string TargetConnection,
string TargetDatabase,
bool Apply,
string Confirmation,
string Operator,
string BuildRevision);
internal sealed record QuestionComparison(int Inserted, int Updated, int Unchanged);
internal sealed record PromotionSummary(
int EligibleQuestions,
int MediaAssets,
int Inserted,
int Updated,
int Unchanged,
int TargetExtras,
string ContentHash);
}
@@ -68,31 +68,11 @@ public static class QuestionValidator
if (string.IsNullOrWhiteSpace(req.System)) return "system required"; if (string.IsNullOrWhiteSpace(req.System)) return "system required";
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required"; if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5"; if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5";
var labValidation = ValidateLabs(req.Labs, req.IsPublished);
if (labValidation is not null) return labValidation;
var referenceValidation = ValidateReferences(req.References, req.IsPublished); var referenceValidation = ValidateReferences(req.References, req.IsPublished);
if (referenceValidation is not null) return referenceValidation; if (referenceValidation is not null) return referenceValidation;
return null; return null;
} }
public static string? ValidateLabs(IReadOnlyCollection<QuestionLab>? labs, bool requireComplete)
{
if (labs is null)
return "labs must be an array";
if (labs.Count > 50)
return "questions support at most fifty lab results";
foreach (var lab in labs)
{
if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value))
return "lab name and value are required";
if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference))
return $"published question lab '{lab.Name.Trim()}' requires a reference range";
}
return null;
}
public static string? ValidateReferences(IReadOnlyCollection<QuestionReference>? references, bool required) public static string? ValidateReferences(IReadOnlyCollection<QuestionReference>? references, bool required)
{ {
if (references is null) if (references is null)
+1 -30
View File
@@ -27,43 +27,14 @@ public class RatingStore
public async Task<RatingProfileResponse> GetProfileAsync(string userId) public async Task<RatingProfileResponse> GetProfileAsync(string userId)
{ {
var rating = await GetOrCreateUserRatingAsync(userId); var rating = await GetOrCreateUserRatingAsync(userId);
var (tierName, tierDivision) = GetTier(rating.OverallElo);
return new RatingProfileResponse return new RatingProfileResponse
{ {
OverallElo = rating.OverallElo, OverallElo = rating.OverallElo,
RatedQuestionCount = rating.RatedQuestionCount, RatedQuestionCount = rating.RatedQuestionCount,
CategoryElos = rating.CategoryElos, CategoryElos = rating.CategoryElos
Tier = tierName,
TierDivision = tierDivision
}; };
} }
private static readonly (string Name, int FloorElo)[] TierBands =
[
("Bronze", MinElo),
("Silver", 1000),
("Gold", 1300),
("Platinum", 1900),
("Diamond", 2200)
];
private static (string Name, int Division) GetTier(int elo)
{
var bandIndex = 0;
for (var i = 0; i < TierBands.Length; i++)
{
if (elo >= TierBands[i].FloorElo)
bandIndex = i;
}
var band = TierBands[bandIndex];
var nextFloor = bandIndex + 1 < TierBands.Length ? TierBands[bandIndex + 1].FloorElo : band.FloorElo + 300;
var bandWidth = nextFloor - band.FloorElo;
var progress = bandWidth > 0 ? (elo - band.FloorElo) / (double)bandWidth : 0;
var division = 3 - Math.Clamp((int)(progress * 3), 0, 2);
return (band.Name, division);
}
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc) public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{ {
var rating = await GetOrCreateUserRatingAsync(userId); var rating = await GetOrCreateUserRatingAsync(userId);
@@ -19,8 +19,7 @@ public sealed class UserProfileStore
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles", "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
"ExternalIdentities", "ExternalAuthTransactions", "ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
"UserCreditAccounts"
]; ];
public UserProfileStore(MongoDatabaseProvider mongo) public UserProfileStore(MongoDatabaseProvider mongo)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

-14
View File
@@ -1,14 +0,0 @@
<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="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>

Before

Width:  |  Height:  |  Size: 373 B

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bgv42gr8xm8rf"
path="res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/book-open.svg"
dest_files=["res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.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
-15
View File
@@ -1,15 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 376 B

@@ -1,43 +0,0 @@
[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
-15
View File
@@ -1,15 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 303 B

-43
View File
@@ -1,43 +0,0 @@
[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
-14
View File
@@ -1,14 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 292 B

-43
View File
@@ -1,43 +0,0 @@
[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
-13
View File
@@ -1,13 +0,0 @@
<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="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.79 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>

Before

Width:  |  Height:  |  Size: 381 B

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b6wxo6lr3s8ll"
path="res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/shield.svg"
dest_files=["res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.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
-13
View File
@@ -1,13 +0,0 @@
<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="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
</svg>

Before

Width:  |  Height:  |  Size: 583 B

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dty3x2w0mjrv3"
path="res://.godot/imported/star.svg-5cf15f039032ad2da3c91090b75025e6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/star.svg"
dest_files=["res://.godot/imported/star.svg-5cf15f039032ad2da3c91090b75025e6.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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

@@ -1,40 +0,0 @@
[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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

-40
View File
@@ -1,40 +0,0 @@
[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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

-40
View File
@@ -1,40 +0,0 @@
[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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

@@ -1,40 +0,0 @@
[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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

-40
View File
@@ -1,40 +0,0 @@
[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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

-40
View File
@@ -1,40 +0,0 @@
[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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

@@ -1,40 +0,0 @@
[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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

@@ -1,40 +0,0 @@
[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
-19
View File
@@ -1,19 +0,0 @@
# Question-completion credits
This branch introduces credit earning only. It does not impose a question
limit, redeem credits, sell products, activate passes, consume exam tokens, or
reset QBank progress.
- A successfully stored first answer earns one credit regardless of whether it
is correct.
- A repeated answer does not earn another credit.
- A skipped question does not earn credit.
- At most 20 credits can be earned in a rolling 24-hour window.
- Historical attempts are not backfilled.
- `GET /api/credits` returns the authenticated user's balance and current
rolling-window capacity.
- The first-attempt response also includes the updated credit status.
The API is authoritative. Credit grants use the immutable attempt ID as their
idempotency key, so retrying the same completed question cannot grant another
credit. Credit accounts are included in account export and deletion.
+1 -70
View File
@@ -25,74 +25,5 @@ html/custom_html_shell="res://web/medmind_shell.html"
html/head_include="<meta name=\"description\" content=\"RanaZe: Step 3 Trainer pre-alpha - Focused practice for better clinical decisions.\">" html/head_include="<meta name=\"description\" content=\"RanaZe: Step 3 Trainer pre-alpha - Focused practice for better clinical decisions.\">"
html/canvas_resize_policy=2 html/canvas_resize_policy=2
html/focus_canvas_on_start=true html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=true html/experimental_virtual_keyboard=false
progressive_web_app/enabled=false progressive_web_app/enabled=false
[preset.1]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/windows/RanaZe.exe"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
binary_format/architecture="x86_64"
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
codesign/enable=false
codesign/timestamp=true
codesign/timestamp_server_url=""
codesign/digest_algorithm=1
codesign/description=""
codesign/custom_options=PackedStringArray()
application/modify_resources=true
application/icon="res://assets/branding/ranaze-app-icon.ico"
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.1.0"
application/product_version="0.1.0"
application/company_name="RanaZe"
application/product_name="RanaZe: Step 3 Trainer"
application/file_description="RanaZe: Step 3 Trainer"
application/copyright="RanaZe"
application/trademarks=""
application/export_angle=0
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
$trigger = New-ScheduledTaskTrigger -Once -At 00:00
$settings = New-ScheduledTaskSettingsSet
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
Start-ScheduledTask -TaskName godot_remote_debug
while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false"
ssh_remote_deploy/cleanup_script="$tasks = Get-ScheduledTask godot_remote_debug -ErrorAction Ignore
if ($tasks) {
$tasks | Stop-ScheduledTask
$tasks | Unregister-ScheduledTask -Confirm:$false
}
Remove-Item -Recurse -Force '{temp_dir}'"
+4 -8
View File
@@ -13,13 +13,10 @@ config_version=5
config/name="RanaZe: Step 3 Trainer" config/name="RanaZe: Step 3 Trainer"
config/version="pre-alpha" config/version="pre-alpha"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://assets/branding/ranaze-app-icon.png"
config/use_custom_user_dir=true config/use_custom_user_dir=true
config/custom_user_dir_name="MedMind" config/custom_user_dir_name="MedMind"
config/features=PackedStringArray("4.6", "GL Compatibility")
boot_splash/bg_color=Color(0.019607844, 0.02745098, 0.05882353, 1)
boot_splash/stretch_mode=0
boot_splash/image="res://assets/branding/ranaze-mark.png"
config/icon="res://assets/branding/ranaze-app-icon.png"
[autoload] [autoload]
@@ -39,7 +36,6 @@ NotesService="*res://scripts/autoload/notes_service.gd"
BookmarkService="*res://scripts/autoload/bookmark_service.gd" BookmarkService="*res://scripts/autoload/bookmark_service.gd"
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd" ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
DashboardService="*res://scripts/autoload/dashboard_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd"
CreditsService="*res://scripts/autoload/credits_service.gd"
FlashcardService="*res://scripts/autoload/flashcard_service.gd" FlashcardService="*res://scripts/autoload/flashcard_service.gd"
AccountProfileService="*res://scripts/autoload/account_profile_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
DonationService="*res://scripts/autoload/donation_service.gd" DonationService="*res://scripts/autoload/donation_service.gd"
@@ -57,14 +53,14 @@ window/stretch/aspect="expand"
[rendering] [rendering]
rendering_device/driver.windows="d3d12"
renderer/rendering_method="gl_compatibility" renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility"
rendering_device/driver.windows="d3d12"
textures/vram_compression/import_etc2_astc=true textures/vram_compression/import_etc2_astc=true
[step3] [step3]
deployment_environment="uat" deployment_environment="development"
questions_api_url="https://us3q.ranaze.com/api/questions/search" questions_api_url="https://us3q.ranaze.com/api/questions/search"
question_reports_api_url="https://us3q.ranaze.com/api/question-reports" question_reports_api_url="https://us3q.ranaze.com/api/question-reports"
app_feedback_api_url="https://us3q.ranaze.com/api/app-feedback" app_feedback_api_url="https://us3q.ranaze.com/api/app-feedback"
+2 -62
View File
@@ -28,61 +28,10 @@ var simulation_block_index := 0
var simulation_total_blocks := 12 var simulation_total_blocks := 12
var simulation_deadline_unix := 0.0 var simulation_deadline_unix := 0.0
var review_first_submission_recorded := false var review_first_submission_recorded := false
var core_block_size := DEFAULT_CORE_BLOCK_SIZE
var show_review_followups := true
const CORE_MIN_TARGET_SECONDS := 45.0 const CORE_MIN_TARGET_SECONDS := 45.0
const CORE_MAX_TARGET_SECONDS := 150.0 const CORE_MAX_TARGET_SECONDS := 150.0
const CORE_RETRY_TARGET_SECONDS := 30.0 const CORE_RETRY_TARGET_SECONDS := 30.0
const DEFAULT_CORE_BLOCK_SIZE := 10
const CORE_BLOCK_SIZE_MIN := 5
const CORE_BLOCK_SIZE_MAX := 40
const SETTINGS_PATH := "user://settings.cfg"
const SETTINGS_SECTION := "session"
const SETTINGS_KEY_CORE_BLOCK_SIZE := "core_block_size"
const SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS := "show_review_followups"
func _ready() -> void:
_load_core_block_size()
_load_show_review_followups()
func set_core_block_size(value: int) -> void:
var clamped := clampi(value, CORE_BLOCK_SIZE_MIN, CORE_BLOCK_SIZE_MAX)
if clamped == core_block_size:
return
core_block_size = clamped
var config := ConfigFile.new()
config.load(SETTINGS_PATH)
config.set_value(SETTINGS_SECTION, SETTINGS_KEY_CORE_BLOCK_SIZE, core_block_size)
if config.save(SETTINGS_PATH) != OK:
push_warning("Unable to save core question block size preference.")
func set_show_review_followups(value: bool) -> void:
if show_review_followups == value:
return
show_review_followups = value
var config := ConfigFile.new()
config.load(SETTINGS_PATH)
config.set_value(SETTINGS_SECTION, SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS, show_review_followups)
if config.save(SETTINGS_PATH) != OK:
push_warning("Unable to save follow-up question preference.")
func _load_core_block_size() -> void:
var config := ConfigFile.new()
if config.load(SETTINGS_PATH) == OK:
core_block_size = clampi(
int(config.get_value(SETTINGS_SECTION, SETTINGS_KEY_CORE_BLOCK_SIZE, DEFAULT_CORE_BLOCK_SIZE)),
CORE_BLOCK_SIZE_MIN, CORE_BLOCK_SIZE_MAX)
func _load_show_review_followups() -> void:
var config := ConfigFile.new()
if config.load(SETTINGS_PATH) == OK:
show_review_followups = bool(config.get_value(SETTINGS_SECTION, SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS, true))
func start_dynamic_question(question: Dictionary) -> bool: func start_dynamic_question(question: Dictionary) -> bool:
@@ -256,26 +205,18 @@ func restore_simulation_responses(saved_responses: Array) -> void:
"is_correct": false, "is_correct": false,
"eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(), "eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(),
"flagged": bool(response.get("flagged", false)), "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 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: func get_current_question() -> Dictionary:
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size(): if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
return {} return {}
return active_questions[current_index] return active_questions[current_index]
func answer_current(choice_id: String, eliminated: Array, flagged: bool, is_submitted := false) -> Dictionary: func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
var question := get_current_question() var question := get_current_question()
if question.is_empty(): if question.is_empty():
return {} return {}
@@ -292,7 +233,6 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool, is_subm
answers[current_index] = answer answers[current_index] = answer
if session_kind == "simulation": if session_kind == "simulation":
answer["is_correct"] = false answer["is_correct"] = false
answer["is_submitted"] = is_submitted
answers[current_index] = answer answers[current_index] = answer
session_changed.emit() session_changed.emit()
return answer return answer
-179
View File
@@ -1,179 +0,0 @@
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:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/credits_api_url", DEFAULT_URL)),
"/api/credits")
request = HTTPRequest.new()
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)
func refresh() -> void:
if loading or not AuthState.can_send_authenticated_request():
return
loading = true
request_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % request_token])
var error := request.request(api_url, headers, HTTPClient.METHOD_GET)
if error != OK:
loading = false
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
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()
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
loading = false
if response_code == 401:
AuthState.handle_unauthorized(request_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
credits_failed.emit("The credits service returned an invalid response.")
return
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:
request.cancel_request()
-1
View File
@@ -1 +0,0 @@
uid://bi323du7o7dn2
+2 -46
View File
@@ -8,7 +8,6 @@ signal state_changed
signal request_failed(message: String) signal request_failed(message: String)
signal outcome_changed signal outcome_changed
signal history_changed signal history_changed
signal exam_token_required
var api_url := DEFAULT_API_URL var api_url := DEFAULT_API_URL
var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes" var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes"
@@ -23,8 +22,6 @@ var is_loading := false
var pending_action := "" var pending_action := ""
var request_queue: Array = [] var request_queue: Array = []
var request_access_token := "" var request_access_token := ""
var last_failed_action := ""
var submit_recovery_attempts := 0
func _ready() -> void: func _ready() -> void:
@@ -70,7 +67,7 @@ func start_next_block() -> void:
_send("start", HTTPClient.METHOD_POST, "/%s/blocks/start" % str(simulation.get("id", ""))) _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, is_submitted := false) -> void: func save_response(question_id: String, choice_id: String, eliminated: Array, flagged: bool, elapsed_seconds: int) -> void:
if simulation.is_empty(): if simulation.is_empty():
return return
_send("save", HTTPClient.METHOD_PUT, "/%s/responses" % str(simulation.get("id", "")), { _send("save", HTTPClient.METHOD_PUT, "/%s/responses" % str(simulation.get("id", "")), {
@@ -78,15 +75,13 @@ func save_response(question_id: String, choice_id: String, eliminated: Array, fl
"choiceId": choice_id, "choiceId": choice_id,
"eliminatedChoiceIds": eliminated, "eliminatedChoiceIds": eliminated,
"flagged": flagged, "flagged": flagged,
"elapsedSeconds": elapsed_seconds, "elapsedSeconds": elapsed_seconds
"isSubmitted": is_submitted
}) })
func submit_block() -> void: func submit_block() -> void:
if simulation.is_empty(): if simulation.is_empty():
return return
submit_recovery_attempts = 0
_send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", ""))) _send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", "")))
@@ -145,7 +140,6 @@ func _send_absolute(action: String, method: int, url: String, payload: Dictionar
if err != OK: if err != OK:
is_loading = false is_loading = false
pending_action = "" pending_action = ""
last_failed_action = action
request_failed.emit("The simulation request could not be started.") request_failed.emit("The simulation request could not be started.")
@@ -157,30 +151,12 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
if response_code == 401: if response_code == 401:
AuthState.handle_unauthorized(request_access_token) AuthState.handle_unauthorized(request_access_token)
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: 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 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: if server_message.length() > 240:
server_message = server_message.left(240) server_message = server_message.left(240)
var message := "Simulation request failed with status %d." % response_code var message := "Simulation request failed with status %d." % response_code
if not server_message.is_empty(): if not server_message.is_empty():
message = "%s %s" % [message, server_message] message = "%s %s" % [message, server_message]
last_failed_action = action
request_failed.emit(message) request_failed.emit(message)
return return
if response_code == 204: if response_code == 204:
@@ -193,7 +169,6 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
return return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY: if typeof(parsed) != TYPE_DICTIONARY:
last_failed_action = action
request_failed.emit("The simulation API returned an invalid response.") request_failed.emit("The simulation API returned an invalid response.")
return return
if action == "readiness": if action == "readiness":
@@ -215,25 +190,6 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
return return
simulation = _normalize_simulation(parsed) simulation = _normalize_simulation(parsed)
state_changed.emit() 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: func _process_request_queue() -> void:
-8
View File
@@ -130,14 +130,6 @@ func reset_qbank_memory() -> void:
profile_changed.emit() 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: func apply_question_results(results: Dictionary) -> void:
_ensure_daily_current() _ensure_daily_current()
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"])) profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
+4 -15
View File
@@ -38,8 +38,6 @@ var load_state := "idle"
var retry_attempt := 0 var retry_attempt := 0
var next_retry_seconds := 0 var next_retry_seconds := 0
var overall_elo := 1000 var overall_elo := 1000
var rank_tier := "Bronze"
var rank_division := 3
var rated_question_count := 0 var rated_question_count := 0
var category_elos := {} var category_elos := {}
var last_rating_change := {} var last_rating_change := {}
@@ -248,8 +246,6 @@ func clear_questions() -> void:
retry_attempt = 0 retry_attempt = 0
next_retry_seconds = 0 next_retry_seconds = 0
overall_elo = 1000 overall_elo = 1000
rank_tier = "Bronze"
rank_division = 3
rated_question_count = 0 rated_question_count = 0
category_elos = {} category_elos = {}
last_rating_change = {} last_rating_change = {}
@@ -261,10 +257,10 @@ func clear_questions() -> void:
ratings_changed.emit() ratings_changed.emit()
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void: func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void:
if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "": if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "":
return return
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds, met_time_limit) _enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds)
func skip_question(question_id: String) -> void: func skip_question(question_id: String) -> void:
@@ -390,7 +386,7 @@ func _on_retry_timer_timeout() -> void:
load_questions_from_api(false) load_questions_from_api(false)
func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void: func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void:
var account := AuthState.username.strip_edges() var account := AuthState.username.strip_edges()
if account == "": if account == "":
return return
@@ -404,7 +400,6 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
"choice_id": choice_id, "choice_id": choice_id,
"presented_choice_ids": presented_choice_ids.duplicate(), "presented_choice_ids": presented_choice_ids.duplicate(),
"duration_seconds": maxi(0, duration_seconds), "duration_seconds": maxi(0, duration_seconds),
"met_time_limit": met_time_limit,
"retry_count": 0 "retry_count": 0
}) })
_save_attempt_queue() _save_attempt_queue()
@@ -437,8 +432,7 @@ func _process_attempt_queue() -> void:
"questionExternalId": question_id, "questionExternalId": question_id,
"choiceId": str(queued.get("choice_id", "")), "choiceId": str(queued.get("choice_id", "")),
"presentedChoiceIds": queued.get("presented_choice_ids", []), "presentedChoiceIds": queued.get("presented_choice_ids", []),
"durationSeconds": int(queued.get("duration_seconds", 0)), "durationSeconds": int(queued.get("duration_seconds", 0))
"metTimeLimit": bool(queued.get("met_time_limit", true))
}) })
if str(queued.get("kind", "answer")) == "skip": if str(queued.get("kind", "answer")) == "skip":
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()] url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
@@ -513,9 +507,6 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
var rating: Dictionary = parsed.get("rating", {}) var rating: Dictionary = parsed.get("rating", {})
_update_question_elo(question_id, question_elo) _update_question_elo(question_id, question_elo)
_apply_rating_change(rating) _apply_rating_change(rating)
var credits_status: Variant = parsed.get("credits", null)
if typeof(credits_status) == TYPE_DICTIONARY:
CreditsService.apply_status(credits_status)
choice_percentages_updated.emit(question_id, percentages) choice_percentages_updated.emit(question_id, percentages)
question_rating_updated.emit(question_id, question_elo, rating) question_rating_updated.emit(question_id, question_elo, rating)
@@ -621,8 +612,6 @@ func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringA
return return
overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000))) overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000)))
rank_tier = str(parsed.get("tier", rank_tier))
rank_division = int(parsed.get("tierDivision", parsed.get("tier_division", rank_division)))
rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0))) rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0)))
category_elos = parsed.get("categoryElos", parsed.get("category_elos", {})) category_elos = parsed.get("categoryElos", parsed.get("category_elos", {}))
ratings_changed.emit() ratings_changed.emit()
+2 -2
View File
@@ -101,8 +101,8 @@ func refresh_theme_controls() -> void:
return return
var dark := UIThemeService.is_dark_mode() var dark := UIThemeService.is_dark_mode()
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
display_label.add_theme_color_override("font_color", Color.WHITE) display_label.add_theme_color_override("font_color", Brand.text_color(dark))
operation_label.add_theme_color_override("font_color", Brand.muted_color(true)) operation_label.add_theme_color_override("font_color", Brand.muted_color(dark))
for button in keypad_buttons: for button in keypad_buttons:
var accent := Brand.ROYAL_BLUE if str(button.get_meta("calculator_token", "")) in ["+", "−", "×", "÷", "="] else Brand.SLATE var accent := Brand.ROYAL_BLUE if str(button.get_meta("calculator_token", "")) in ["+", "−", "×", "÷", "="] else Brand.SLATE
button.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent)) button.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
+1 -1
View File
@@ -297,7 +297,7 @@ func _build_performance_summary(dark: bool) -> void:
if combined != null: if combined != null:
prediction_value = "%d" % int(combined) prediction_value = "%d" % int(combined)
prediction_detail = "Range %d-%d" % [int(CCSService.prediction.get("rangeLow", 0)), int(CCSService.prediction.get("rangeHigh", 0))] prediction_detail = "Range %d-%d" % [int(CCSService.prediction.get("rangeLow", 0)), int(CCSService.prediction.get("rangeHigh", 0))]
_add_metric(row, "Provisional Combined Estimate", prediction_value, prediction_detail, dark) _add_metric(row, "Combined Prediction", prediction_value, prediction_detail, dark)
func _add_metric(parent: BoxContainer, caption: String, value: String, detail: String, dark: bool) -> void: func _add_metric(parent: BoxContainer, caption: String, value: String, detail: String, dark: bool) -> void:
-174
View File
@@ -1,174 +0,0 @@
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")
const CHEVRON_ICON := preload("res://assets/icons/lucide/chevron-down.svg")
var rank_icon_panel: PanelContainer
var rank_icon_texture: TextureRect
var rank_tier_label: Label
var rank_elo_label: Label
var divider: ColorRect
var icon_panel: PanelContainer
var icon_texture: TextureRect
var value_label: Label
var caption_label: Label
var displayed_value := 0
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()
_on_ratings_changed()
CreditsService.credits_changed.connect(_on_credits_changed)
QuestionBankService.ratings_changed.connect(_on_ratings_changed)
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)
margin.add_theme_constant_override("margin_right", 7)
margin.add_theme_constant_override("margin_top", 5)
margin.add_theme_constant_override("margin_bottom", 5)
add_child(margin)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 5)
margin.add_child(row)
rank_icon_panel = PanelContainer.new()
rank_icon_panel.custom_minimum_size = Vector2(22, 22)
rank_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
rank_icon_texture = TextureRect.new()
rank_icon_texture.texture = SHIELD_ICON
rank_icon_texture.custom_minimum_size = Vector2(13, 13)
rank_icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
rank_icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
rank_icon_panel.add_child(rank_icon_texture)
row.add_child(rank_icon_panel)
var rank_copy := VBoxContainer.new()
rank_copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
rank_copy.add_theme_constant_override("separation", 0)
rank_tier_label = Label.new()
rank_tier_label.add_theme_font_override("font", Brand.font)
rank_tier_label.add_theme_font_size_override("font_size", 13)
rank_copy.add_child(rank_tier_label)
rank_elo_label = Label.new()
rank_elo_label.add_theme_font_override("font", Brand.font)
rank_elo_label.add_theme_font_size_override("font_size", 9)
rank_copy.add_child(rank_elo_label)
row.add_child(rank_copy)
divider = ColorRect.new()
divider.custom_minimum_size = Vector2(1, 22)
divider.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(divider)
icon_panel = PanelContainer.new()
icon_panel.custom_minimum_size = Vector2(22, 22)
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
icon_texture = TextureRect.new()
icon_texture.texture = STAR_ICON
icon_texture.custom_minimum_size = Vector2(13, 13)
icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_panel.add_child(icon_texture)
row.add_child(icon_panel)
var copy := VBoxContainer.new()
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
copy.add_theme_constant_override("separation", 0)
value_label = Label.new()
value_label.add_theme_font_override("font", Brand.font)
value_label.add_theme_font_size_override("font_size", 13)
copy.add_child(value_label)
caption_label = Label.new()
caption_label.text = "Rz Coins"
caption_label.add_theme_font_override("font", Brand.font)
caption_label.add_theme_font_size_override("font_size", 9)
copy.add_child(caption_label)
row.add_child(copy)
var chevron := TextureRect.new()
chevron.texture = CHEVRON_ICON
chevron.custom_minimum_size = Vector2(12, 12)
chevron.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
chevron.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
chevron.mouse_filter = Control.MOUSE_FILTER_IGNORE
chevron.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(chevron)
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))
rank_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 11))
rank_icon_texture.modulate = Brand.ROYAL_BLUE
rank_tier_label.add_theme_color_override("font_color", Brand.text_color(dark))
rank_elo_label.add_theme_color_override("font_color", Brand.ROYAL_BLUE)
divider.color = Color("2a365a") if dark else Brand.LIGHT_BORDER
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 11))
icon_texture.modulate = Brand.AMBER
value_label.add_theme_color_override("font_color", Brand.text_color(dark))
caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
func bump() -> void:
var tween := create_tween()
tween.tween_property(self, "scale", Vector2(1.12, 1.12), 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tween.tween_property(self, "scale", Vector2.ONE, 0.18).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
func _on_ratings_changed() -> void:
var roman := {1: "I", 2: "II", 3: "III"}
var division_label: String = roman.get(QuestionBankService.rank_division, "")
rank_tier_label.text = "%s %s" % [QuestionBankService.rank_tier, division_label]
rank_elo_label.text = "%d ELO" % QuestionBankService.overall_elo
func _on_credits_changed() -> void:
if not CreditsService.loaded:
_initialized = false
displayed_value = 0
value_label.text = "--"
return
if not _initialized:
_initialized = true
displayed_value = CreditsService.credits
value_label.text = _format_thousands(displayed_value)
return
var tween := create_tween()
tween.tween_method(_set_displayed_value, displayed_value, CreditsService.credits, 0.5).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func _set_displayed_value(value: int) -> void:
displayed_value = value
value_label.text = _format_thousands(value)
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
-1
View File
@@ -1 +0,0 @@
uid://doisoxqnk1mf3
+2 -6
View File
@@ -99,13 +99,9 @@ func _draw() -> void:
static func point_value(point: Dictionary, series_mode: String) -> Variant: static func point_value(point: Dictionary, series_mode: String) -> Variant:
if series_mode == "accuracy": if series_mode == "accuracy":
if int(point.get("total", 0)) > 0: return float(point.get("accuracy", 0)) if int(point.get("total", 0)) > 0 else null
return float(point.get("accuracy", 0))
return null
var value: Variant = point.get("questionBankRating", null) var value: Variant = point.get("questionBankRating", null)
if value == null: return null if value == null else float(value)
return null
return float(value)
static func axis_bounds(series_points: Array, series_mode: String) -> Vector2: static func axis_bounds(series_points: Array, series_mode: String) -> Vector2:
-13
View File
@@ -1,13 +0,0 @@
extends Control
var ring_color := Color.WHITE
func set_ring_color(next_color: Color) -> void:
ring_color = next_color
queue_redraw()
func _draw() -> void:
var radius: float = minf(size.x, size.y) * 0.5 - 1.0
draw_arc(size * 0.5, radius, 0.0, TAU, 48, ring_color, 1.2, true)
-1
View File
@@ -1 +0,0 @@
uid://cnnscljyarxaa
+51 -178
View File
@@ -3,15 +3,12 @@ extends Control
const Brand := preload("res://scripts/ui/medmind_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd")
const TrendChart := preload("res://scripts/ui/dashboard_chart.gd") const TrendChart := preload("res://scripts/ui/dashboard_chart.gd")
const ActivityChart := preload("res://scripts/ui/dashboard_activity_chart.gd") const ActivityChart := preload("res://scripts/ui/dashboard_activity_chart.gd")
const DashboardSparkline := preload("res://scripts/ui/dashboard_sparkline.gd")
const DashboardSegmentBar := preload("res://scripts/ui/dashboard_segment_bar.gd")
const DashboardInfoBadge := preload("res://scripts/ui/dashboard_info_badge.gd")
var main: Control var main: Control
var scroll: ScrollContainer var scroll: ScrollContainer
var content: VBoxContainer var content: VBoxContainer
var status_label: Label var status_label: Label
var stat_card_grid: GridContainer var metric_grid: GridContainer
var analytics_grid: GridContainer var analytics_grid: GridContainer
var lower_grid: GridContainer var lower_grid: GridContainer
var trend_chart: Control var trend_chart: Control
@@ -25,11 +22,6 @@ var recent_list: VBoxContainer
var weekly_summary: VBoxContainer var weekly_summary: VBoxContainer
var metric_values: Dictionary = {} var metric_values: Dictionary = {}
var metric_details: Dictionary = {} var metric_details: Dictionary = {}
var metric_trends: Dictionary = {}
var qbank_sparkline: Control
var ccs_sparkline: Control
var readiness_segment_bar: Control
var accuracy_progress_bar: ProgressBar
var pending_review_launch := false var pending_review_launch := false
var review_queue_service: Variant var review_queue_service: Variant
var trend_mode := "rating" var trend_mode := "rating"
@@ -62,7 +54,6 @@ func _build() -> void:
child.free() child.free()
metric_values.clear() metric_values.clear()
metric_details.clear() metric_details.clear()
metric_trends.clear()
scroll = ScrollContainer.new() scroll = ScrollContainer.new()
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
@@ -75,35 +66,15 @@ func _build() -> void:
status_label = _label("", 14, Brand.muted_color(UIThemeService.is_dark_mode())) status_label = _label("", 14, Brand.muted_color(UIThemeService.is_dark_mode()))
status_label.visible = false status_label.visible = false
content.add_child(status_label) content.add_child(status_label)
stat_card_grid = GridContainer.new() metric_grid = GridContainer.new()
stat_card_grid.columns = 4 metric_grid.columns = 4
stat_card_grid.add_theme_constant_override("h_separation", 12) metric_grid.add_theme_constant_override("h_separation", 0)
stat_card_grid.add_theme_constant_override("v_separation", 12) metric_grid.add_theme_constant_override("v_separation", 8)
content.add_child(stat_card_grid) content.add_child(_card(metric_grid, 12))
_add_metric("qbank", "Question Bank Rating", "chart-no-axes-column-increasing", Brand.ROYAL_BLUE)
qbank_sparkline = DashboardSparkline.new() _add_metric("ccs", "CCS Rating", "stethoscope", Brand.PURPLE)
qbank_sparkline.custom_minimum_size = Vector2(0, 34) _add_metric("accuracy", "First-Attempt Accuracy", "activity", Brand.ROYAL_BLUE)
_add_stat_card("qbank", "Question Bank Rating", "book-open", Brand.ROYAL_BLUE, qbank_sparkline, _add_metric("time", "Study Time", "clock-3", Brand.PURPLE)
"Your Elo-style rating from Question Bank answers, weighted by each question's difficulty.")
ccs_sparkline = DashboardSparkline.new()
ccs_sparkline.custom_minimum_size = Vector2(0, 34)
_add_stat_card("ccs", "CCS Rating", "stethoscope", Brand.SUCCESS, ccs_sparkline,
"Your Elo-style rating from timed CCS case performance, weighted by each case's difficulty.")
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,
"Heuristic pass probability from your most recent completed exam simulation.")
accuracy_progress_bar = ProgressBar.new()
accuracy_progress_bar.show_percentage = false
accuracy_progress_bar.min_value = 0.0
accuracy_progress_bar.max_value = 100.0
accuracy_progress_bar.custom_minimum_size = Vector2(0, 10)
_style_progress(accuracy_progress_bar, Brand.AMBER)
_add_stat_card("accuracy", "Average Score", "star", Brand.AMBER, accuracy_progress_bar,
"Your first-attempt accuracy across your most recent 100 answered questions.")
analytics_grid = GridContainer.new() analytics_grid = GridContainer.new()
analytics_grid.columns = 2 analytics_grid.columns = 2
@@ -192,15 +163,10 @@ func _rebuild(_is_dark: bool = UIThemeService.is_dark_mode()) -> void:
func _render() -> void: func _render() -> void:
var data: Dictionary = DashboardService.data var data: Dictionary = DashboardService.data
status_label.visible = false status_label.visible = false
var trend: Array = data.get("performanceTrend", []) _set_metric("qbank", str(int(data.get("questionBankRating", 1000))), "%d rated questions" % int(data.get("ratedQuestionCount", 0)))
_render_rating_card("qbank", qbank_sparkline, Brand.ROYAL_BLUE, data.get("questionBankRating", 1000), _set_metric("ccs", str(int(data.get("ccsRating", 1000))), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)))
data.get("questionBankRatingDeltaThisWeek"), data.get("questionBankPercentile"), "%d rated questions" % int(data.get("ratedQuestionCount", 0)), _set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)), "Last %d questions" % int(data.get("accuracySampleSize", 0)))
_trend_series(trend, "questionBankRating")) _set_metric("time", _format_duration(int(data.get("studyTimeSecondsThisWeek", 0))), "This week")
_render_rating_card("ccs", ccs_sparkline, Brand.SUCCESS, data.get("ccsRating", 1000),
data.get("ccsRatingDeltaThisWeek"), data.get("ccsPercentile"), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)),
_trend_series(trend, "ccsRating"))
_render_readiness_card(data)
_render_accuracy_card(data)
trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode()) trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
_render_systems(data.get("systems", [])) _render_systems(data.get("systems", []))
_render_focus(data.get("systems", [])) _render_focus(data.get("systems", []))
@@ -211,71 +177,6 @@ func _render() -> void:
_render_recent(data.get("recentActivity", [])) _render_recent(data.get("recentActivity", []))
func _trend_series(trend: Array, field: String) -> Array:
var series: Array = []
for point in trend:
var value = point.get(field)
if value != null:
series.append(float(value))
return series
func _render_rating_card(key: String, sparkline: Control, accent: Color, rating_value: Variant, delta_value: Variant, percentile_value: Variant, fallback_caption: String, series: Array) -> void:
var dark := UIThemeService.is_dark_mode()
var rating := int(rating_value)
var trend_text := ""
var trend_color := Brand.muted_color(dark)
var caption := fallback_caption
if delta_value != null:
var delta := int(delta_value)
var arrow := "↗" if delta >= 0 else "↘"
var sign_prefix := "+" if delta >= 0 else ""
trend_text = "%s %s%d this week" % [arrow, sign_prefix, delta]
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
if percentile_value != null:
caption = "Top %d%% of users" % int(percentile_value)
_set_stat_card(key, str(rating), trend_text, trend_color, caption)
if series.size() < 2:
sparkline.set_values([float(rating)], accent, Vector2(rating - 10.0, rating + 10.0))
else:
var low: float = series.min()
var high: float = series.max()
var spread := maxf(10.0, (high - low) * 0.5)
sparkline.set_values(series, accent, Vector2(low - spread, high + spread))
func _render_readiness_card(data: Dictionary) -> void:
var dark := UIThemeService.is_dark_mode()
var label_value = data.get("examReadinessLabel")
if label_value == null:
_set_stat_card("readiness", "--", "", Brand.muted_color(dark), "Complete a simulation to see this")
readiness_segment_bar.set_progress(0, 4, Brand.muted_color(dark), dark)
return
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 := "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)
func _render_accuracy_card(data: Dictionary) -> void:
var dark := UIThemeService.is_dark_mode()
var accuracy := int(data.get("firstAttemptAccuracy", 0))
var delta_value = data.get("accuracyDeltaVsPreviousSample")
var trend_text := ""
var trend_color := Brand.muted_color(dark)
if delta_value != null:
var delta := int(delta_value)
var arrow := "↗" if delta >= 0 else "↘"
var sign_prefix := "+" if delta >= 0 else ""
trend_text = "%s %s%d%% vs last 100" % [arrow, sign_prefix, delta]
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
_set_stat_card("accuracy", "%d%%" % accuracy, trend_text, trend_color, "Last %d questions" % int(data.get("accuracySampleSize", 0)))
accuracy_progress_bar.value = accuracy
func _render_systems(systems_value: Array) -> void: func _render_systems(systems_value: Array) -> void:
_clear(system_list) _clear(system_list)
var systems := systems_value.duplicate(true) var systems := systems_value.duplicate(true)
@@ -367,16 +268,14 @@ func _render_review(data: Dictionary) -> void:
missed_action.disabled = missed_count <= 0 or pending_review_launch missed_action.disabled = missed_count <= 0 or pending_review_launch
missed_action.pressed.connect(_start_review_queue) missed_action.pressed.connect(_start_review_queue)
review_list.add_child(missed_action) review_list.add_child(missed_action)
if Brand.is_uat_environment(): var dynamic_due := int(data.get("dynamicReviewsDue", 0))
var dynamic_due := int(data.get("dynamicReviewsDue", 0)) review_list.add_child(_review_action(
review_list.add_child(_review_action( "%d dynamic concepts due" % dynamic_due if dynamic_due > 0 else "Dynamic concepts up to date",
"%d dynamic concepts due" % dynamic_due if dynamic_due > 0 else "Dynamic concepts up to date", "activity", Brand.PURPLE if dynamic_due > 0 else Brand.ROYAL_BLUE, "dynamic_questions"))
"activity", Brand.PURPLE if dynamic_due > 0 else Brand.ROYAL_BLUE, "dynamic_questions")) var flashcards_due := int(data.get("flashcardsDue", 0))
if Brand.is_uat_environment(): review_list.add_child(_review_action(
var flashcards_due := int(data.get("flashcardsDue", 0)) "%d flashcards due" % flashcards_due if flashcards_due > 0 else "Flashcards up to date",
review_list.add_child(_review_action( "clipboard-plus", Brand.TEAL if flashcards_due > 0 else Brand.ROYAL_BLUE, "flashcards"))
"%d flashcards due" % flashcards_due if flashcards_due > 0 else "Flashcards up to date",
"clipboard-plus", Brand.TEAL if flashcards_due > 0 else Brand.ROYAL_BLUE, "flashcards"))
review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks")) review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks"))
review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes")) review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes"))
@@ -420,70 +319,38 @@ func _render_recent(items: Array) -> void:
recent_list.add_child(row) recent_list.add_child(row)
func _info_badge(dark: bool, tooltip: String) -> Control: func _add_metric(key: String, title: String, icon_name: String, accent: Color) -> void:
var badge := DashboardInfoBadge.new() var cell := HBoxContainer.new()
badge.custom_minimum_size = Vector2(16, 16) cell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
badge.size_flags_vertical = Control.SIZE_SHRINK_BEGIN cell.custom_minimum_size = Vector2(230, 96)
badge.mouse_filter = Control.MOUSE_FILTER_STOP cell.add_theme_constant_override("separation", 12)
badge.tooltip_text = tooltip
badge.set_ring_color(Brand.muted_color(dark))
var glyph := _label("i", 10, Brand.muted_color(dark), true)
glyph.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
glyph.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE
badge.add_child(glyph)
return badge
func _add_stat_card(key: String, title: String, icon_name: String, accent: Color, bottom_visual: Control, info_tooltip: String) -> void:
var dark := UIThemeService.is_dark_mode()
var card_content := VBoxContainer.new()
card_content.custom_minimum_size = Vector2(220, 0)
card_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card_content.add_theme_constant_override("separation", 8)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 10)
card_content.add_child(header)
var icon_panel := PanelContainer.new() var icon_panel := PanelContainer.new()
icon_panel.custom_minimum_size = Vector2(40, 40) icon_panel.custom_minimum_size = Vector2(48, 48)
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 20, true)) icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(UIThemeService.is_dark_mode(), accent, 24))
var icon := TextureRect.new() var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name) icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
icon.custom_minimum_size = Vector2(18, 18) icon.custom_minimum_size = Vector2(22, 22)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_panel.add_child(icon) icon_panel.add_child(icon)
header.add_child(icon_panel) cell.add_child(icon_panel)
var title_label := _label(title, 14, Brand.text_color(dark)) var copy := VBoxContainer.new()
title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART copy.add_child(_label(title, 15, Brand.text_color(UIThemeService.is_dark_mode())))
header.add_child(title_label) var value := _label("--", 29, Brand.text_color(UIThemeService.is_dark_mode()), true)
header.add_child(_info_badge(dark, info_tooltip)) copy.add_child(value)
var detail := _label("Loading", 13, Brand.muted_color(UIThemeService.is_dark_mode()))
var value := _label("--", 28, Brand.text_color(dark), true) copy.add_child(detail)
card_content.add_child(value)
var trend := _label("", 13, Brand.muted_color(dark))
card_content.add_child(trend)
card_content.add_child(bottom_visual)
var caption := _label("Loading", 12, Brand.muted_color(dark))
card_content.add_child(caption)
metric_values[key] = value metric_values[key] = value
metric_trends[key] = trend metric_details[key] = detail
metric_details[key] = caption cell.add_child(copy)
stat_card_grid.add_child(_card(card_content, 16)) metric_grid.add_child(cell)
func _set_stat_card(key: String, value: String, trend_text: String, trend_color: Color, caption: String) -> void: func _set_metric(key: String, value: String, detail: String) -> void:
(metric_values.get(key) as Label).text = value (metric_values.get(key) as Label).text = value
var trend_label: Label = metric_trends.get(key) (metric_details.get(key) as Label).text = detail
if trend_label != null:
trend_label.text = trend_text
trend_label.add_theme_color_override("font_color", trend_color)
(metric_details.get(key) as Label).text = caption
func _review_action(text: String, icon_name: String, accent: Color, route: String) -> Button: func _review_action(text: String, icon_name: String, accent: Color, route: String) -> Button:
@@ -665,10 +532,10 @@ func _show_error(message: String) -> void:
func _update_responsive() -> void: func _update_responsive() -> void:
if stat_card_grid == null: if metric_grid == null:
return return
var compact := size.x < 1050.0 var compact := size.x < 1050.0
stat_card_grid.columns = 2 if compact else 4 metric_grid.columns = 2 if compact else 4
analytics_grid.columns = 1 if compact else 2 analytics_grid.columns = 1 if compact else 2
lower_grid.columns = 1 if compact else 2 lower_grid.columns = 1 if compact else 2
if trend_chart != null: if trend_chart != null:
@@ -681,6 +548,12 @@ func _clear(container: Container) -> void:
child.free() child.free()
func _format_duration(total_seconds: int) -> String:
var hours := int(floor(float(total_seconds) / 3600.0))
var minutes := int(floor(float(total_seconds % 3600) / 60.0))
return "%dh %02dm" % [hours, minutes]
func _relative_time(iso_value: String) -> String: func _relative_time(iso_value: String) -> String:
var cleaned := iso_value.trim_suffix("Z") var cleaned := iso_value.trim_suffix("Z")
var occurred := Time.get_unix_time_from_datetime_string(cleaned) var occurred := Time.get_unix_time_from_datetime_string(cleaned)
-34
View File
@@ -1,34 +0,0 @@
extends Control
const Brand := preload("res://scripts/ui/medmind_style.gd")
const GAP := 6.0
const CORNER_RADIUS := 4
var filled_segments := 0
var total_segments := 4
var accent := Color.WHITE
var dark := true
func set_progress(next_filled: int, next_total: int, next_accent: Color, dark_mode: bool) -> void:
filled_segments = next_filled
total_segments = maxi(1, next_total)
accent = next_accent
dark = dark_mode
queue_redraw()
func _draw() -> void:
var segment_width: float = (size.x - GAP * float(total_segments - 1)) / float(total_segments)
if segment_width <= 0.0:
return
var filled_box := StyleBoxFlat.new()
filled_box.bg_color = accent
filled_box.set_corner_radius_all(CORNER_RADIUS)
var track_box := StyleBoxFlat.new()
track_box.bg_color = Brand.muted_color(dark).lerp(Brand.background_color(dark), 0.5)
track_box.set_corner_radius_all(CORNER_RADIUS)
for index in range(total_segments):
var x: float = index * (segment_width + GAP)
var rect := Rect2(x, 0.0, segment_width, size.y)
draw_style_box(filled_box if index < filled_segments else track_box, rect)
-1
View File
@@ -1 +0,0 @@
uid://duq4hsl7b72g2
-29
View File
@@ -1,29 +0,0 @@
extends Control
var values: PackedFloat32Array = []
var accent := Color.WHITE
var value_range := Vector2(0.0, 100.0)
func set_values(next_values: Array, next_accent: Color, next_range := Vector2(0.0, 100.0)) -> void:
values = PackedFloat32Array(next_values)
accent = next_accent
value_range = next_range
queue_redraw()
func _draw() -> void:
if values.size() < 2:
if values.size() == 1:
draw_circle(Vector2(size.x - 4.0, size.y * 0.5), 3.0, accent)
return
var span: float = maxf(0.0001, value_range.y - value_range.x)
var points := PackedVector2Array()
var count: int = values.size()
for index in range(count):
var x: float = lerpf(0.0, size.x, float(index) / float(count - 1))
var ratio: float = clampf((values[index] - value_range.x) / span, 0.0, 1.0)
var y: float = lerpf(size.y, 0.0, ratio)
points.append(Vector2(x, y))
draw_polyline(points, accent, 2.0, true)
draw_circle(points[-1], 3.0, accent)
-1
View File
@@ -1 +0,0 @@
uid://4rtqkgrljvw5
+1 -1
View File
@@ -79,7 +79,7 @@ func _build_ui() -> void:
mission.add_theme_constant_override("separation", 18) mission.add_theme_constant_override("separation", 18)
mission_panel.add_child(mission) mission_panel.add_child(mission)
mission.add_child(_heading("Our mission", 26)) mission.add_child(_heading("Our mission", 26))
mission.add_child(_body("RanaZe exists to make rigorous, clinically grounded Step 3 prep accessible to every test taker — not another subscription funnel built to profit off people who already have too little time and money to spare. We run at cost: your support covers hosting and content development, nothing more. No investors, no upsells — just an independent tool built to stay in your corner through Step 3.")) mission.add_child(_body("RanaZe exists to make rigorous, clinically grounded medical education more accessible. We are building an independent Step 3 study experience that helps learners develop clinical judgment through original questions, realistic CCS cases, transparent explanations, and continuous improvement driven by learner feedback."))
mission.add_child(HSeparator.new()) mission.add_child(HSeparator.new())
mission.add_child(_heading("What your support makes possible", 20)) mission.add_child(_heading("What your support makes possible", 20))
for item_text in [ for item_text in [
+35 -149
View File
@@ -25,9 +25,7 @@ var reset_view: VBoxContainer
var steam_view: VBoxContainer var steam_view: VBoxContainer
var login_input: LineEdit var login_input: LineEdit
var password_input: LineEdit var password_input: LineEdit
var password_reveal_entries: Array[Dictionary] = [] var password_reveal_button: Button
var legal_consent_texts: Array[RichTextLabel] = []
var legal_consent_squares: Array[Panel] = []
var registration_username_input: LineEdit var registration_username_input: LineEdit
var registration_email_input: LineEdit var registration_email_input: LineEdit
var registration_password_input: LineEdit var registration_password_input: LineEdit
@@ -42,7 +40,6 @@ var steam_action: OptionButton
var steam_username_input: LineEdit var steam_username_input: LineEdit
var steam_email_input: LineEdit var steam_email_input: LineEdit
var steam_password_input: LineEdit var steam_password_input: LineEdit
var steam_confirm_input: LineEdit
var steam_legal_check: CheckBox var steam_legal_check: CheckBox
var external_buttons: Dictionary = {} var external_buttons: Dictionary = {}
var panel_gutter: MarginContainer var panel_gutter: MarginContainer
@@ -65,13 +62,6 @@ var compact_layout := false
var layout_updating := false var layout_updating := false
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
var focus_owner := get_viewport().gui_get_focus_owner()
if focus_owner != null:
focus_owner.release_focus()
func _ready() -> void: func _ready() -> void:
auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth") auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth")
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@@ -134,8 +124,8 @@ func _build_ui() -> void:
page_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) page_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
page_margin.add_theme_constant_override("margin_left", 52) page_margin.add_theme_constant_override("margin_left", 52)
page_margin.add_theme_constant_override("margin_right", 52) page_margin.add_theme_constant_override("margin_right", 52)
page_margin.add_theme_constant_override("margin_top", 28) page_margin.add_theme_constant_override("margin_top", 56)
page_margin.add_theme_constant_override("margin_bottom", 28) page_margin.add_theme_constant_override("margin_bottom", 56)
add_child(page_margin) add_child(page_margin)
var columns := HBoxContainer.new() var columns := HBoxContainer.new()
@@ -158,7 +148,6 @@ func _build_ui() -> void:
large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
brand_row.add_child(large_icon) brand_row.add_child(large_icon)
var wordmark := VBoxContainer.new() var wordmark := VBoxContainer.new()
wordmark.size_flags_vertical = Control.SIZE_SHRINK_CENTER
wordmark.add_theme_constant_override("separation", 4) wordmark.add_theme_constant_override("separation", 4)
brand_row.add_child(wordmark) brand_row.add_child(wordmark)
var name_row := HBoxContainer.new() var name_row := HBoxContainer.new()
@@ -200,34 +189,23 @@ func _build_ui() -> void:
panel_center.size_flags_vertical = Control.SIZE_EXPAND_FILL panel_center.size_flags_vertical = Control.SIZE_EXPAND_FILL
columns.add_child(panel_center) columns.add_child(panel_center)
auth_panel = PanelContainer.new() auth_panel = PanelContainer.new()
auth_panel.custom_minimum_size = Vector2(710, 1000) auth_panel.custom_minimum_size = Vector2(710, 820)
panel_center.add_child(auth_panel) panel_center.add_child(auth_panel)
var scroll := ScrollContainer.new() var scroll := ScrollContainer.new()
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
auth_panel.add_child(scroll) auth_panel.add_child(scroll)
panel_gutter = MarginContainer.new() panel_gutter = MarginContainer.new()
panel_gutter.size_flags_horizontal = Control.SIZE_EXPAND_FILL panel_gutter.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel_gutter.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel_gutter.add_theme_constant_override("margin_right", 28) panel_gutter.add_theme_constant_override("margin_right", 28)
scroll.add_child(panel_gutter) scroll.add_child(panel_gutter)
panel_content = VBoxContainer.new() panel_content = VBoxContainer.new()
panel_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL panel_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel_content.add_theme_constant_override("separation", 24) panel_content.add_theme_constant_override("separation", 24)
panel_gutter.add_child(panel_content) panel_gutter.add_child(panel_content)
# main_column holds everything except the legal footer and is the part
# that should center vertically when a view (e.g. login) is shorter than
# the panel - the footer stays pinned to the very bottom either way.
var main_column := VBoxContainer.new()
main_column.size_flags_vertical = Control.SIZE_EXPAND_FILL
main_column.alignment = BoxContainer.ALIGNMENT_CENTER
main_column.add_theme_constant_override("separation", 16)
panel_content.add_child(main_column)
var panel_header := HBoxContainer.new() var panel_header := HBoxContainer.new()
panel_header.add_theme_constant_override("separation", 18) panel_header.add_theme_constant_override("separation", 18)
main_column.add_child(panel_header) panel_content.add_child(panel_header)
var small_icon := TextureRect.new() var small_icon := TextureRect.new()
small_icon.texture = Brand.ranaze_mark_texture() small_icon.texture = Brand.ranaze_mark_texture()
small_icon.custom_minimum_size = Vector2(72, 72) small_icon.custom_minimum_size = Vector2(72, 72)
@@ -251,14 +229,14 @@ func _build_ui() -> void:
reset_view = _build_reset_view() reset_view = _build_reset_view()
steam_view = _build_steam_view() steam_view = _build_steam_view()
for view in [login_view, register_view, reset_view, steam_view]: for view in [login_view, register_view, reset_view, steam_view]:
main_column.add_child(view) panel_content.add_child(view)
message_label = Label.new() message_label = Label.new()
message_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART message_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message_label.add_theme_font_size_override("font_size", 14) message_label.add_theme_font_size_override("font_size", 14)
message_label.add_theme_font_override("font", Brand.font) message_label.add_theme_font_override("font", Brand.font)
main_column.add_child(message_label) panel_content.add_child(message_label)
var legal_footer := HBoxContainer.new() var legal_footer := HBoxContainer.new()
legal_footer.alignment = BoxContainer.ALIGNMENT_CENTER legal_footer.alignment = BoxContainer.ALIGNMENT_CENTER
legal_footer.add_theme_constant_override("separation", 12) legal_footer.add_theme_constant_override("separation", 12)
@@ -306,8 +284,7 @@ func _build_register_view() -> VBoxContainer:
registration_email_input = _add_field(view, "Email", "[email protected]") registration_email_input = _add_field(view, "Email", "[email protected]")
registration_password_input = _add_field(view, "Password", "At least 8 characters", true) registration_password_input = _add_field(view, "Password", "At least 8 characters", true)
registration_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true) registration_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true)
_add_password_reveal(registration_password_input) _add_show_password(view, [registration_password_input, registration_confirm_input])
_add_password_reveal(registration_confirm_input)
registration_legal_check = _add_legal_consent(view) registration_legal_check = _add_legal_consent(view)
_add_primary_button(view, "Create Account", _on_register_pressed) _add_primary_button(view, "Create Account", _on_register_pressed)
_add_back_button(view, "Already have an account? Sign in", "login") _add_back_button(view, "Already have an account? Sign in", "login")
@@ -325,8 +302,7 @@ func _build_reset_view() -> VBoxContainer:
reset_code_input.max_length = 6 reset_code_input.max_length = 6
reset_password_input = _add_field(reset_confirm_group, "New password", "At least 8 characters", true) reset_password_input = _add_field(reset_confirm_group, "New password", "At least 8 characters", true)
reset_confirm_input = _add_field(reset_confirm_group, "Confirm new password", "Re-enter your new password", true) reset_confirm_input = _add_field(reset_confirm_group, "Confirm new password", "Re-enter your new password", true)
_add_password_reveal(reset_password_input) _add_show_password(reset_confirm_group, [reset_password_input, reset_confirm_input])
_add_password_reveal(reset_confirm_input)
_add_primary_button(reset_confirm_group, "Update Password", _confirm_reset) _add_primary_button(reset_confirm_group, "Update Password", _confirm_reset)
_add_back_button(view, "Back to sign in", "login") _add_back_button(view, "Back to sign in", "login")
reset_confirm_group.visible = false reset_confirm_group.visible = false
@@ -347,9 +323,7 @@ func _build_steam_view() -> VBoxContainer:
steam_username_input = _add_field(view, "Username or email", "Choose a username") steam_username_input = _add_field(view, "Username or email", "Choose a username")
steam_email_input = _add_field(view, "Email", "[email protected]") steam_email_input = _add_field(view, "Email", "[email protected]")
steam_password_input = _add_field(view, "Password", "At least 8 characters", true) steam_password_input = _add_field(view, "Password", "At least 8 characters", true)
steam_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true) _add_show_password(view, [steam_password_input])
_add_password_reveal(steam_password_input)
_add_password_reveal(steam_confirm_input)
steam_legal_check = _add_legal_consent(view) steam_legal_check = _add_legal_consent(view)
_add_primary_button(view, "Continue", _complete_steam) _add_primary_button(view, "Continue", _complete_steam)
_add_back_button(view, "Cancel external sign in", "login") _add_back_button(view, "Cancel external sign in", "login")
@@ -357,84 +331,17 @@ func _build_steam_view() -> VBoxContainer:
func _add_legal_consent(parent: VBoxContainer) -> CheckBox: func _add_legal_consent(parent: VBoxContainer) -> CheckBox:
# A bare CheckBox's own word-wrap left-aligns every wrapped line flush
# with the checkbox glyph, so a second line reads as un-indented. Split
# the toggle from its text into a row instead - the RichTextLabel's own
# wrapping then stays confined to its column, and "Terms of Service"/
# "Privacy Policy" render as inline links rather than a duplicate row
# of link buttons underneath.
#
# The actual toggle state lives on a real CheckBox (kept invisible and
# out of the row's layout) because other code reads .button_pressed -
# but Button/CheckBox reserves vertical space for a text baseline even
# with an empty string, which was making the visible square non-square
# no matter what custom_minimum_size said. Drawing the visible square
# as a plain Panel sidesteps that entirely.
var check := CheckBox.new() var check := CheckBox.new()
check.visible = false check.text = "I agree to the Terms of Service and acknowledge the Privacy Policy."
check.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
parent.add_child(check) parent.add_child(check)
var links := HBoxContainer.new()
var margin := MarginContainer.new() links.add_theme_constant_override("separation", 12)
margin.add_theme_constant_override("margin_top", 8) parent.add_child(links)
margin.add_theme_constant_override("margin_bottom", 8) _add_legal_links(links)
parent.add_child(margin)
var center := CenterContainer.new()
margin.add_child(center)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
center.add_child(row)
var square := Panel.new()
square.custom_minimum_size = Vector2(20, 20)
square.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
square.size_flags_vertical = Control.SIZE_SHRINK_CENTER
square.mouse_filter = Control.MOUSE_FILTER_STOP
square.set_meta("legal_consent_checkbox", check)
square.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
check.button_pressed = not check.button_pressed
)
row.add_child(square)
legal_consent_squares.append(square)
check.toggled.connect(func(_pressed: bool): _refresh_legal_consent_square(square))
var consent_text := RichTextLabel.new()
consent_text.bbcode_enabled = true
consent_text.fit_content = true
consent_text.scroll_active = false
consent_text.meta_underlined = false
consent_text.custom_minimum_size = Vector2(480, 0)
consent_text.size_flags_vertical = Control.SIZE_SHRINK_CENTER
consent_text.add_theme_font_override("normal_font", Brand.font)
consent_text.add_theme_font_size_override("normal_font_size", 13)
consent_text.meta_clicked.connect(func(meta: Variant): _open_legal_document(str(meta)))
legal_consent_texts.append(consent_text)
row.add_child(consent_text)
return check return check
func _refresh_legal_consent_text(consent_text: RichTextLabel, dark: bool) -> void:
var text_hex := Brand.text_color(dark).to_html(false)
var link_hex := (Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE).to_html(false)
consent_text.text = (
"[color=#%s]I agree to the [/color][url=terms][color=#%s]Terms of Service[/color][/url][color=#%s] and acknowledge the [/color][url=privacy][color=#%s]Privacy Policy[/color][/url][color=#%s].[/color]"
% [text_hex, link_hex, text_hex, link_hex, text_hex]
)
func _refresh_legal_consent_square(square: Panel) -> void:
var dark := UIThemeService.is_dark_mode()
var accent := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
var checked := square.get_meta("legal_consent_checkbox", null) as CheckBox
var is_checked := checked != null and checked.button_pressed
var box := StyleBoxFlat.new()
box.bg_color = accent if is_checked else Color.TRANSPARENT
box.border_color = accent
box.set_border_width_all(2)
box.set_corner_radius_all(5)
square.add_theme_stylebox_override("panel", box)
func _add_legal_links(parent: HBoxContainer) -> void: func _add_legal_links(parent: HBoxContainer) -> void:
for spec in [["Terms of Service", "terms"], ["Privacy Policy", "privacy"]]: for spec in [["Terms of Service", "terms"], ["Privacy Policy", "privacy"]]:
var link := Button.new() var link := Button.new()
@@ -450,7 +357,7 @@ func _open_legal_document(kind: String) -> void:
func _new_form() -> VBoxContainer: func _new_form() -> VBoxContainer:
var form := VBoxContainer.new() var form := VBoxContainer.new()
form.add_theme_constant_override("separation", 10) form.add_theme_constant_override("separation", 16)
return form return form
@@ -481,7 +388,7 @@ func _add_field(parent: VBoxContainer, label_text: String, placeholder: String,
input.secret = secret input.secret = secret
input.caret_blink = true input.caret_blink = true
input.caret_blink_interval = 0.5 input.caret_blink_interval = 0.5
input.custom_minimum_size = Vector2(0, 56) input.custom_minimum_size = Vector2(0, 68)
input.add_theme_font_size_override("font_size", 17) input.add_theme_font_size_override("font_size", 17)
input.add_theme_font_override("font", Brand.font) input.add_theme_font_override("font", Brand.font)
parent.add_child(input) parent.add_child(input)
@@ -489,9 +396,19 @@ func _add_field(parent: VBoxContainer, label_text: String, placeholder: String,
return input return input
func _add_show_password(parent: VBoxContainer, targets: Array) -> void:
var toggle := CheckButton.new()
toggle.text = "Show password"
toggle.toggled.connect(func(should_reveal: bool):
for target in targets:
target.secret = not should_reveal
)
parent.add_child(toggle)
func _add_password_reveal(input: LineEdit) -> void: func _add_password_reveal(input: LineEdit) -> void:
var reveal := Button.new() var reveal := Button.new()
password_reveal_entries.append({"input": input, "button": reveal}) password_reveal_button = reveal
reveal.text = "" reveal.text = ""
reveal.tooltip_text = "Show password" reveal.tooltip_text = "Show password"
reveal.flat = true reveal.flat = true
@@ -570,9 +487,6 @@ func _update_responsive_layout() -> void:
var layout_width := size.x var layout_width := size.x
if layout_width <= 0.0: if layout_width <= 0.0:
layout_width = get_viewport_rect().size.x layout_width = get_viewport_rect().size.x
var layout_height := size.y
if layout_height <= 0.0:
layout_height = get_viewport_rect().size.y
var compact_desktop := layout_width < 900.0 var compact_desktop := layout_width < 900.0
var mobile_web := false var mobile_web := false
if OS.has_feature("web"): if OS.has_feature("web"):
@@ -591,16 +505,7 @@ func _update_responsive_layout() -> void:
theme_button.custom_minimum_size = Vector2(44, 44) theme_button.custom_minimum_size = Vector2(44, 44)
theme_button.position = Vector2(-58, 14) theme_button.position = Vector2(-58, 14)
theme_button.add_theme_font_size_override("font_size", 20) theme_button.add_theme_font_size_override("font_size", 20)
# 1000/1010 comfortably fits every view (login/register/reset/steam) auth_panel.custom_minimum_size = Vector2(640, 790) if compact_desktop else Vector2(710, 820)
# without the inner ScrollContainer kicking in, on any window tall
# enough to hold it - but clamp to whatever height is actually
# available so a short window shrinks the panel (and lets the
# ScrollContainer take over) instead of the panel overflowing off
# the top/bottom of the screen.
var available_panel_height := layout_height - 56.0
var target_height := 1010.0 if compact_desktop else 1000.0
var panel_height := clampf(available_panel_height, 400.0, target_height)
auth_panel.custom_minimum_size = Vector2(640 if compact_desktop else 710, panel_height)
panel_gutter.add_theme_constant_override("margin_right", 28) panel_gutter.add_theme_constant_override("margin_right", 28)
_apply_compact_scale(panel_content, 1.0) _apply_compact_scale(panel_content, 1.0)
_apply_theme() _apply_theme()
@@ -639,13 +544,7 @@ func _apply_theme() -> void:
label.add_theme_color_override("font_color", Brand.text_color(dark)) label.add_theme_color_override("font_color", Brand.text_color(dark))
for input in fields: for input in fields:
_style_auth_field(input, dark) _style_auth_field(input, dark)
for consent_text in legal_consent_texts:
_refresh_legal_consent_text(consent_text, dark)
for square in legal_consent_squares:
_refresh_legal_consent_square(square)
for button in find_children("*", "Button", true, false): for button in find_children("*", "Button", true, false):
if button is CheckBox:
continue
var typed := button as Button var typed := button as Button
typed.add_theme_font_override("font", Brand.font) typed.add_theme_font_override("font", Brand.font)
typed.add_theme_color_override("font_color", Brand.text_color(dark)) typed.add_theme_color_override("font_color", Brand.text_color(dark))
@@ -666,8 +565,8 @@ func _apply_theme() -> void:
theme_button.add_theme_stylebox_override("normal", theme_normal) theme_button.add_theme_stylebox_override("normal", theme_normal)
theme_button.add_theme_stylebox_override("hover", theme_normal) theme_button.add_theme_stylebox_override("hover", theme_normal)
theme_button.add_theme_color_override("font_color", Brand.AMBER) theme_button.add_theme_color_override("font_color", Brand.AMBER)
for entry in password_reveal_entries: if password_reveal_button != null:
_refresh_password_reveal(entry["input"], entry["button"]) _refresh_password_reveal(password_input, password_reveal_button)
subheading.add_theme_color_override("font_color", Brand.muted_color(dark)) subheading.add_theme_color_override("font_color", Brand.muted_color(dark))
message_label.add_theme_color_override("font_color", Brand.muted_color(dark)) message_label.add_theme_color_override("font_color", Brand.muted_color(dark))
if brand_tagline != null: if brand_tagline != null:
@@ -858,8 +757,7 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body:
"requires_account": "requires_account":
poll_timer.stop() poll_timer.stop()
_close_steam_popup() _close_steam_popup()
var verified_email_value: Variant = data.get("email") external_verified_email = str(data.get("email", ""))
external_verified_email = "" if verified_email_value == null else str(verified_email_value)
steam_email_input.text = external_verified_email steam_email_input.text = external_verified_email
steam_email_input.editable = external_verified_email.is_empty() steam_email_input.editable = external_verified_email.is_empty()
_set_mode("steam") _set_mode("steam")
@@ -867,10 +765,7 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body:
func _on_steam_action_changed(index: int) -> void: func _on_steam_action_changed(index: int) -> void:
_set_field_visible(steam_email_input, index == 0) steam_email_input.visible = index == 0
_set_field_visible(steam_confirm_input, index == 0)
if index != 0:
steam_confirm_input.text = ""
steam_legal_check.visible = index == 0 steam_legal_check.visible = index == 0
steam_username_input.placeholder_text = "Choose a username" if index == 0 else "Existing email or username" steam_username_input.placeholder_text = "Choose a username" if index == 0 else "Existing email or username"
@@ -878,7 +773,7 @@ func _on_steam_action_changed(index: int) -> void:
func _complete_steam() -> void: func _complete_steam() -> void:
var action := "create" if steam_action.selected == 0 else "link" var action := "create" if steam_action.selected == 0 else "link"
if action == "create": if action == "create":
var validation := _validate_account(steam_username_input.text.strip_edges(), steam_email_input.text.strip_edges(), steam_password_input.text, steam_confirm_input.text) var validation := _validate_account(steam_username_input.text.strip_edges(), steam_email_input.text.strip_edges(), steam_password_input.text, steam_password_input.text)
if not validation.is_empty(): return _show_error(validation) if not validation.is_empty(): return _show_error(validation)
if not steam_legal_check.button_pressed: if not steam_legal_check.button_pressed:
return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.") return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.")
@@ -891,15 +786,6 @@ func _complete_steam() -> void:
}, "Connecting account...") }, "Connecting account...")
func _set_field_visible(input: LineEdit, visible_value: bool) -> void:
input.visible = visible_value
var index := input.get_index()
if index > 0:
var label := input.get_parent().get_child(index - 1)
if label is Label:
label.visible = visible_value
func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_set_busy(false) _set_busy(false)
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
+16 -48
View File
@@ -3,7 +3,6 @@ extends Control
const Style := preload("res://scripts/ui/ui_style.gd") const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd")
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd") const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
var screens := {} var screens := {}
var _account_profile_synced := false var _account_profile_synced := false
@@ -12,6 +11,7 @@ var header_title: Label
var environment_badge: Label var environment_badge: Label
var app_menu: PanelContainer var app_menu: PanelContainer
var menu_exit_button: Button var menu_exit_button: Button
var sound_toggle: CheckButton
var theme_button: Button var theme_button: Button
var feedback_button: Button var feedback_button: Button
var feedback_dialog: Control var feedback_dialog: Control
@@ -30,7 +30,6 @@ var drawer_open := false
var menu_button: Button var menu_button: Button
var account_button: Button var account_button: Button
var account_chevron_button: Button var account_chevron_button: Button
var credits_badge: PanelContainer
var account_menu: PanelContainer var account_menu: PanelContainer
var nav_buttons: Dictionary = {} var nav_buttons: Dictionary = {}
var current_nav_key := "home" var current_nav_key := "home"
@@ -155,7 +154,7 @@ func _build_ui() -> void:
environment_badge = Label.new() environment_badge = Label.new()
environment_badge.text = "UAT" environment_badge.text = "UAT"
environment_badge.tooltip_text = "User acceptance testing environment" environment_badge.tooltip_text = "User acceptance testing environment"
environment_badge.visible = Brand.is_uat_environment() environment_badge.visible = str(ProjectSettings.get_setting("step3/deployment_environment", "development")) == "uat"
environment_badge.add_theme_font_override("font", Brand.font) environment_badge.add_theme_font_override("font", Brand.font)
environment_badge.add_theme_font_size_override("font_size", 13) environment_badge.add_theme_font_size_override("font_size", 13)
environment_badge.add_theme_color_override("font_color", Color("052e16")) environment_badge.add_theme_color_override("font_color", Color("052e16"))
@@ -206,10 +205,6 @@ func _build_ui() -> void:
_style_account_chevron(account_chevron_button) _style_account_chevron(account_chevron_button)
header.add_child(account_chevron_button) header.add_child(account_chevron_button)
credits_badge = CreditsBadge.new()
credits_badge.store_requested.connect(func(): show_screen("rz_store"))
header.add_child(credits_badge)
var screens_container := MarginContainer.new() var screens_container := MarginContainer.new()
screens_margin = screens_container screens_margin = screens_container
screens_host = screens_container screens_host = screens_container
@@ -356,11 +351,6 @@ func _build_ui() -> void:
donations.main = self donations.main = self
screens_container.add_child(donations) screens_container.add_child(donations)
screens["donations"] = 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 = preload("res://scripts/ui/app_feedback_dialog.gd").new()
feedback_dialog.name = "AppFeedbackDialog" feedback_dialog.name = "AppFeedbackDialog"
@@ -426,10 +416,10 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
_add_nav_button(container, "home", "Dashboard", "chart-no-axes-column-increasing", true) _add_nav_button(container, "home", "Dashboard", "chart-no-axes-column-increasing", true)
_add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true) _add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true)
_add_nav_button(container, "dynamic_questions", "Dynamic Questions", "activity", true, Brand.is_uat_environment()) _add_nav_button(container, "dynamic_questions", "Dynamic Questions", "activity", true)
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true, Brand.is_uat_environment()) _add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true)
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true) _add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
_add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true, Brand.is_uat_environment()) _add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true)
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true) _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
_add_nav_button(container, "notes", "Notes", "notebook-pen", true) _add_nav_button(container, "notes", "Notes", "notebook-pen", true)
@@ -439,12 +429,11 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
container.add_child(HSeparator.new()) container.add_child(HSeparator.new())
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true) _add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
_add_nav_button(container, "performance", "Profile", "user-round", true) _add_nav_button(container, "performance", "Profile", "user-round", true)
_add_nav_button(container, "rz_store", "RZ Shop", "star", true)
_add_nav_button(container, "settings", "Settings", "settings", true) _add_nav_button(container, "settings", "Settings", "settings", true)
_add_nav_button(container, "logout", "Log out", "log-out", true) _add_nav_button(container, "logout", "Log out", "log-out", true)
func _add_nav_button(container: VBoxContainer, key: String, label_text: String, icon_name: String, enabled: bool, visible_flag: bool = true) -> void: func _add_nav_button(container: VBoxContainer, key: String, label_text: String, icon_name: String, enabled: bool) -> void:
var button := Button.new() var button := Button.new()
button.text = label_text button.text = label_text
button.name = "Nav%s" % key.to_pascal_case() button.name = "Nav%s" % key.to_pascal_case()
@@ -458,7 +447,6 @@ func _add_nav_button(container: VBoxContainer, key: String, label_text: String,
button.add_theme_font_size_override("font_size", 18) button.add_theme_font_size_override("font_size", 18)
button.disabled = not enabled button.disabled = not enabled
button.tooltip_text = "Coming Soon" if not enabled else label_text button.tooltip_text = "Coming Soon" if not enabled else label_text
button.visible = visible_flag
button.pressed.connect(_on_navigation_pressed.bind(key)) button.pressed.connect(_on_navigation_pressed.bind(key))
container.add_child(button) container.add_child(button)
if not nav_buttons.has(key): if not nav_buttons.has(key):
@@ -483,7 +471,6 @@ func _on_navigation_pressed(key: String) -> void:
"notes": show_screen("notes") "notes": show_screen("notes")
"bookmarks": show_screen("bookmarks") "bookmarks": show_screen("bookmarks")
"donations": show_screen("donations") "donations": show_screen("donations")
"rz_store": show_screen("rz_store")
"logout": _logout() "logout": _logout()
@@ -495,7 +482,6 @@ func _update_header_title(screen_name: String) -> void:
"selection": "Question Bank", "selection": "Question Bank",
"dynamic_questions": "Dynamic Questions", "dynamic_questions": "Dynamic Questions",
"simulation_history": "Exam Simulations", "simulation_history": "Exam Simulations",
"simulation_results": "Exam Simulations",
"ccs_hub": "CCS Cases", "ccs_hub": "CCS Cases",
"flashcards_hub": "Flashcards", "flashcards_hub": "Flashcards",
"flashcards_study": "Flashcards", "flashcards_study": "Flashcards",
@@ -783,7 +769,6 @@ func _on_auth_changed() -> void:
if not _account_profile_synced: if not _account_profile_synced:
_account_profile_synced = true _account_profile_synced = true
AccountProfileService.refresh() AccountProfileService.refresh()
CreditsService.refresh()
else: else:
_account_profile_synced = false _account_profile_synced = false
if AuthState.is_logged_in and AuthState.requires_legal_acceptance and current_screen_name not in ["login", "legal_consent"]: if AuthState.is_logged_in and AuthState.requires_legal_acceptance and current_screen_name not in ["login", "legal_consent"]:
@@ -812,8 +797,6 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
_style_account_avatar(account_button) _style_account_avatar(account_button)
if account_chevron_button != null: if account_chevron_button != null:
_style_account_chevron(account_chevron_button) _style_account_chevron(account_chevron_button)
if credits_badge != null:
credits_badge.refresh_theme_controls()
if sidebar != null: if sidebar != null:
sidebar.add_theme_stylebox_override("panel", _sidebar_box()) sidebar.add_theme_stylebox_override("panel", _sidebar_box())
if header_panel != null: if header_panel != null:
@@ -871,7 +854,7 @@ func _build_app_menu() -> void:
app_menu.visible = false app_menu.visible = false
app_menu.top_level = true app_menu.top_level = true
app_menu.mouse_filter = Control.MOUSE_FILTER_STOP app_menu.mouse_filter = Control.MOUSE_FILTER_STOP
app_menu.custom_minimum_size = Vector2(340, 290) app_menu.custom_minimum_size = Vector2(340, 230)
app_menu.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.CYAN)) app_menu.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.CYAN))
add_child(app_menu) add_child(app_menu)
@@ -893,27 +876,19 @@ func _build_app_menu() -> void:
resume.pressed.connect(_close_app_menu) resume.pressed.connect(_close_app_menu)
content.add_child(resume) content.add_child(resume)
var profile_button := Button.new() sound_toggle = CheckButton.new()
profile_button.text = "Profile" sound_toggle.text = "UI Sounds"
profile_button.custom_minimum_size = Vector2(0, 48) sound_toggle.button_pressed = UISoundService.are_sounds_enabled()
Style.style_chip(profile_button, Style.CYAN) sound_toggle.custom_minimum_size = Vector2(0, 44)
UISoundService.set_button_sound(profile_button, "open") UISoundService.set_button_sound(sound_toggle, "none")
profile_button.pressed.connect(_navigate_from_pause.bind("profile")) sound_toggle.toggled.connect(UISoundService.set_sounds_enabled)
content.add_child(profile_button) content.add_child(sound_toggle)
var settings_button := Button.new()
settings_button.text = "Settings"
settings_button.custom_minimum_size = Vector2(0, 48)
Style.style_chip(settings_button, Style.CYAN)
UISoundService.set_button_sound(settings_button, "open")
settings_button.pressed.connect(_navigate_from_pause.bind("settings"))
content.add_child(settings_button)
menu_exit_button = Button.new() menu_exit_button = Button.new()
menu_exit_button.text = "Exit Run" menu_exit_button.text = "Exit Run"
menu_exit_button.custom_minimum_size = Vector2(0, 48) menu_exit_button.custom_minimum_size = Vector2(0, 48)
UISoundService.set_button_sound(menu_exit_button, "back") UISoundService.set_button_sound(menu_exit_button, "back")
menu_exit_button.pressed.connect(exit_current_run) menu_exit_button.pressed.connect(_exit_run)
content.add_child(menu_exit_button) content.add_child(menu_exit_button)
var logout := Button.new() var logout := Button.new()
@@ -953,14 +928,7 @@ func _close_app_menu() -> void:
BlockSessionService.resume_current_question_timer() BlockSessionService.resume_current_question_timer()
func _navigate_from_pause(target_screen: String) -> void: func _exit_run() -> void:
var return_screen := current_screen_name
show_screen(target_screen, {"return_screen": return_screen})
if return_screen == "player":
BlockSessionService.pause_current_question_timer()
func exit_current_run() -> void:
_close_app_menu() _close_app_menu()
if current_screen_name == "ccs_player": if current_screen_name == "ccs_player":
var ccs_player = screens.get("ccs_player") var ccs_player = screens.get("ccs_player")
-4
View File
@@ -26,10 +26,6 @@ static func ranaze_mark_texture() -> Texture2D:
return preload("res://assets/branding/ranaze-mark.png") return preload("res://assets/branding/ranaze-mark.png")
static func is_uat_environment() -> bool:
return str(ProjectSettings.get_setting("step3/deployment_environment", "development")) == "uat"
static func text_color(dark: bool) -> Color: static func text_color(dark: bool) -> Color:
return DARK_TEXT if dark else LIGHT_TEXT return DARK_TEXT if dark else LIGHT_TEXT
-2
View File
@@ -29,6 +29,4 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
key = "bookmarks" key = "bookmarks"
elif screen_name == "donations": elif screen_name == "donations":
key = "donations" key = "donations"
elif screen_name == "rz_store":
key = "rz_store"
return key return key
+16 -43
View File
@@ -19,8 +19,6 @@ var outcome_consent: CheckBox
var outcome_status: Label var outcome_status: Label
var outcome_delete: Button var outcome_delete: Button
var connected_accounts_list: VBoxContainer var connected_accounts_list: VBoxContainer
var return_screen := ""
var back_to_session_button: Button
func _ready() -> void: func _ready() -> void:
@@ -40,10 +38,7 @@ func _ready() -> void:
_build() _build()
func on_show(payload: Variant = null) -> void: func on_show(_payload: Variant = null) -> void:
var wrapper: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
return_screen = str(wrapper.get("return_screen", ""))
_refresh_back_to_session_visibility()
_render() _render()
AccountProfileService.refresh() AccountProfileService.refresh()
DashboardService.refresh() DashboardService.refresh()
@@ -68,11 +63,6 @@ func _build() -> void:
status_label = _label("", 13, Brand.muted_color(_dark())) status_label = _label("", 13, Brand.muted_color(_dark()))
status_label.visible = false status_label.visible = false
page.add_child(status_label) page.add_child(status_label)
back_to_session_button = _button("Back to session", "", true)
back_to_session_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
UISoundService.set_button_sound(back_to_session_button, "back")
back_to_session_button.pressed.connect(_return_to_session)
page.add_child(back_to_session_button)
columns = GridContainer.new() columns = GridContainer.new()
columns.columns = 2 columns.columns = 2
columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
@@ -97,7 +87,6 @@ func _build() -> void:
right.add_child(_outcome_card()) right.add_child(_outcome_card())
right.add_child(_connected_accounts_card()) right.add_child(_connected_accounts_card())
right.add_child(_account_actions_card()) right.add_child(_account_actions_card())
_refresh_back_to_session_visibility()
_update_responsive() _update_responsive()
_render() _render()
@@ -258,19 +247,19 @@ func _render() -> void:
return return
var account: Dictionary = AccountProfileService.account var account: Dictionary = AccountProfileService.account
var prefs: Dictionary = AccountProfileService.preferences var prefs: Dictionary = AccountProfileService.preferences
var username := string_field(account, "username", AuthState.username) var username := str(account.get("username", AuthState.username))
var display_name := string_field(account, "displayName").strip_edges() var display_name := str(account.get("displayName", "")).strip_edges()
profile_name.text = username if display_name.is_empty() else display_name profile_name.text = username if display_name.is_empty() else display_name
var email := string_field(account, "email").strip_edges() var email := str(account.get("email", "")).strip_edges()
profile_identity.text = "@%s" % username if email.is_empty() else "@%s | %s" % [username, email] profile_identity.text = "@%s" % username if email.is_empty() else "@%s | %s" % [username, email]
var role := string_field(account, "role", AuthState.role).capitalize() var role := str(account.get("role", AuthState.role)).capitalize()
var created := _date_only(string_field(account, "createdUtc")) var created := _date_only(str(account.get("createdUtc", "")))
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created] profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created]
_render_connected_accounts(account.get("connectedProviders", []), account.get("availableProviders", [])) _render_connected_accounts(account.get("connectedProviders", []), account.get("availableProviders", []))
(identity_values["exam"] as Label).text = _date_only(string_field(prefs, "targetExamDate"), "Not scheduled") (identity_values["exam"] as Label).text = _date_only(str(prefs.get("targetExamDate", "")), "Not scheduled")
(identity_values["stage"] as Label).text = _friendly(string_field(prefs, "preparationStage", "pre_dedicated")) (identity_values["stage"] as Label).text = _friendly(str(prefs.get("preparationStage", "pre_dedicated")))
(identity_values["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10)) (identity_values["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10))
(identity_values["mode"] as Label).text = _friendly(string_field(prefs, "preferredStudyMode", "mixed")) (identity_values["mode"] as Label).text = _friendly(str(prefs.get("preferredStudyMode", "mixed")))
var data: Dictionary = DashboardService.data var data: Dictionary = DashboardService.data
_set_metric("qbank", str(int(data.get("questionBankRating", QuestionBankService.overall_elo)))) _set_metric("qbank", str(int(data.get("questionBankRating", QuestionBankService.overall_elo))))
_set_metric("ccs", str(int(data.get("ccsRating", CCSService.rating.get("elo", 1000))))) _set_metric("ccs", str(int(data.get("ccsRating", CCSService.rating.get("elo", 1000)))))
@@ -353,7 +342,7 @@ func _render_outcome() -> void:
outcome_delete.visible = false outcome_delete.visible = false
return return
outcome_score.value = int(outcome.get("officialScore", 200)) outcome_score.value = int(outcome.get("officialScore", 200))
outcome_date.text = _date_only(string_field(outcome, "examDate")) outcome_date.text = _date_only(str(outcome.get("examDate", "")))
outcome_consent.button_pressed = true outcome_consent.button_pressed = true
outcome_status.text = "Saved score %d for %s." % [int(outcome_score.value), outcome_date.text] outcome_status.text = "Saved score %d for %s." % [int(outcome_score.value), outcome_date.text]
outcome_delete.visible = true outcome_delete.visible = true
@@ -363,12 +352,12 @@ func _open_profile_editor() -> void:
var dialog := _dialog("Edit profile") var dialog := _dialog("Edit profile")
var display := LineEdit.new() var display := LineEdit.new()
display.placeholder_text = "Display name" display.placeholder_text = "Display name"
display.text = string_field(AccountProfileService.account, "displayName") display.text = str(AccountProfileService.account.get("displayName", ""))
_style_field(display) _style_field(display)
dialog.add_field(display) dialog.add_field(display)
var email := LineEdit.new() var email := LineEdit.new()
email.placeholder_text = "Email address" email.placeholder_text = "Email address"
email.text = string_field(AccountProfileService.account, "email") email.text = str(AccountProfileService.account.get("email", ""))
_style_field(email) _style_field(email)
dialog.add_field(email) dialog.add_field(email)
dialog.confirmed.connect(func(): AccountProfileService.update_account(display.text, email.text)) dialog.confirmed.connect(func(): AccountProfileService.update_account(display.text, email.text))
@@ -381,18 +370,18 @@ func _open_study_editor() -> void:
var dialog := _dialog("Edit study identity") var dialog := _dialog("Edit study identity")
var exam := LineEdit.new() var exam := LineEdit.new()
exam.placeholder_text = "Target exam date: YYYY-MM-DD" exam.placeholder_text = "Target exam date: YYYY-MM-DD"
exam.text = _date_only(string_field(prefs, "targetExamDate")) exam.text = _date_only(str(prefs.get("targetExamDate", "")))
_style_field(exam) _style_field(exam)
dialog.add_field(exam) dialog.add_field(exam)
var stage := OptionButton.new() var stage := OptionButton.new()
_add_options(stage, [["Pre-dedicated", "pre_dedicated"], ["Dedicated", "dedicated"], ["Final review", "final_review"]], string_field(prefs, "preparationStage", "pre_dedicated")) _add_options(stage, [["Pre-dedicated", "pre_dedicated"], ["Dedicated", "dedicated"], ["Final review", "final_review"]], str(prefs.get("preparationStage", "pre_dedicated")))
dialog.add_field(stage) dialog.add_field(stage)
var goal := OptionButton.new() var goal := OptionButton.new()
for hours in [5, 10, 15, 20, 25]: goal.add_item("%d hours per week" % hours, hours) for hours in [5, 10, 15, 20, 25]: goal.add_item("%d hours per week" % hours, hours)
_select_id(goal, int(prefs.get("weeklyGoalHours", 10))) _select_id(goal, int(prefs.get("weeklyGoalHours", 10)))
dialog.add_field(goal) dialog.add_field(goal)
var mode := OptionButton.new() var mode := OptionButton.new()
_add_options(mode, [["Mixed", "mixed"], ["Question Bank", "question_bank"], ["CCS", "ccs"], ["Exam simulation", "exam_simulation"]], string_field(prefs, "preferredStudyMode", "mixed")) _add_options(mode, [["Mixed", "mixed"], ["Question Bank", "question_bank"], ["CCS", "ccs"], ["Exam simulation", "exam_simulation"]], str(prefs.get("preferredStudyMode", "mixed")))
dialog.add_field(mode) dialog.add_field(mode)
dialog.confirmed.connect(func(): dialog.confirmed.connect(func():
AccountProfileService.update_preferences(exam.text, str(stage.get_item_metadata(stage.selected)), goal.get_selected_id(), str(mode.get_item_metadata(mode.selected)))) AccountProfileService.update_preferences(exam.text, str(stage.get_item_metadata(stage.selected)), goal.get_selected_id(), str(mode.get_item_metadata(mode.selected))))
@@ -521,17 +510,6 @@ func _button(text: String, icon_name: String = "", primary: bool = false) -> But
return button return button
func _refresh_back_to_session_visibility() -> void:
if back_to_session_button != null:
back_to_session_button.visible = not return_screen.is_empty()
func _return_to_session() -> void:
if return_screen.is_empty():
return
main.show_screen(return_screen)
func _action_button(text: String, icon_name: String, color: Color = Color.TRANSPARENT) -> Button: func _action_button(text: String, icon_name: String, color: Color = Color.TRANSPARENT) -> Button:
var button := _button(text, icon_name, false) var button := _button(text, icon_name, false)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT button.alignment = HORIZONTAL_ALIGNMENT_LEFT
@@ -603,13 +581,8 @@ func _clear(container: Container) -> void:
child.free() child.free()
static func string_field(source: Dictionary, key: String, fallback: String = "") -> String:
var value = source.get(key)
return fallback if value == null else str(value)
func _date_only(value: String, fallback: String = "") -> String: func _date_only(value: String, fallback: String = "") -> String:
if value.is_empty(): return fallback if value.is_empty() or value == "<null>": return fallback
return value.left(10) return value.left(10)
File diff suppressed because it is too large Load Diff
-428
View File
@@ -1,428 +0,0 @@
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))
-1
View File
@@ -1 +0,0 @@
uid://col1hauyr27rn
-549
View File
@@ -1,549 +0,0 @@
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
-1
View File
@@ -1 +0,0 @@
uid://cldpnvs2k6r3t
+1 -73
View File
@@ -4,17 +4,12 @@ const Brand := preload("res://scripts/ui/medmind_style.gd")
const QUESTION_TEXT_SCALE_MIN := 0.85 const QUESTION_TEXT_SCALE_MIN := 0.85
const QUESTION_TEXT_SCALE_MAX := 1.35 const QUESTION_TEXT_SCALE_MAX := 1.35
const QUESTION_TEXT_SCALE_STEP := 0.1 const QUESTION_TEXT_SCALE_STEP := 0.1
const CORE_BLOCK_SIZE_STEP := 5
var main: Control var main: Control
var theme_toggle: CheckButton var theme_toggle: CheckButton
var sound_toggle: CheckButton var sound_toggle: CheckButton
var followups_toggle: CheckButton
var question_scale_value_label: Label var question_scale_value_label: Label
var ccs_scale_value_label: Label var ccs_scale_value_label: Label
var core_block_size_value_label: Label
var return_screen := ""
var back_to_session_button: Button
func _ready() -> void: func _ready() -> void:
@@ -41,17 +36,6 @@ func _ready() -> void:
subtitle.add_theme_color_override("font_color", Brand.SLATE) subtitle.add_theme_color_override("font_color", Brand.SLATE)
add_child(subtitle) add_child(subtitle)
back_to_session_button = Button.new()
back_to_session_button.text = "Back to session"
back_to_session_button.custom_minimum_size = Vector2(0, 44)
back_to_session_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
back_to_session_button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
back_to_session_button.add_theme_color_override("font_color", Color.WHITE)
back_to_session_button.visible = false
UISoundService.set_button_sound(back_to_session_button, "back")
back_to_session_button.pressed.connect(_return_to_session)
add_child(back_to_session_button)
var panel := PanelContainer.new() var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(520, 0) panel.custom_minimum_size = Vector2(520, 0)
panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
@@ -73,16 +57,8 @@ func _ready() -> void:
sound_toggle.toggled.connect(UISoundService.set_sounds_enabled) sound_toggle.toggled.connect(UISoundService.set_sounds_enabled)
content.add_child(sound_toggle) content.add_child(sound_toggle)
followups_toggle = CheckButton.new()
followups_toggle.text = "Follow-up questions"
followups_toggle.tooltip_text = "Optional reinforcement questions shown after you answer"
followups_toggle.custom_minimum_size = Vector2(0, 52)
followups_toggle.toggled.connect(BlockSessionService.set_show_review_followups)
content.add_child(followups_toggle)
content.add_child(_build_text_scale_row("Question bank text size", "question")) content.add_child(_build_text_scale_row("Question bank text size", "question"))
content.add_child(_build_text_scale_row("CCS case text size", "ccs")) content.add_child(_build_text_scale_row("CCS case text size", "ccs"))
content.add_child(_build_core_block_size_row())
var legal_panel := PanelContainer.new() var legal_panel := PanelContainer.new()
legal_panel.custom_minimum_size = Vector2(520, 0) legal_panel.custom_minimum_size = Vector2(520, 0)
@@ -111,13 +87,9 @@ func _ready() -> void:
_refresh_text_scale_labels() _refresh_text_scale_labels()
func on_show(payload: Variant = null) -> void: func on_show(_payload: Variant = null) -> void:
var wrapper: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
return_screen = str(wrapper.get("return_screen", ""))
back_to_session_button.visible = not return_screen.is_empty()
theme_toggle.set_pressed_no_signal(UIThemeService.is_dark_mode()) theme_toggle.set_pressed_no_signal(UIThemeService.is_dark_mode())
sound_toggle.set_pressed_no_signal(UISoundService.are_sounds_enabled()) sound_toggle.set_pressed_no_signal(UISoundService.are_sounds_enabled())
followups_toggle.set_pressed_no_signal(BlockSessionService.show_review_followups)
_refresh_text_scale_labels() _refresh_text_scale_labels()
@@ -159,42 +131,6 @@ func _build_text_scale_row(label_text: String, kind: String) -> HBoxContainer:
return row return row
func _build_core_block_size_row() -> HBoxContainer:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(0, 52)
row.add_theme_constant_override("separation", 12)
var label := Label.new()
label.text = "Question block size"
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
var smaller := Button.new()
smaller.text = "-"
smaller.tooltip_text = "Fewer questions per block"
smaller.custom_minimum_size = Vector2(44, 44)
smaller.pressed.connect(_adjust_core_block_size.bind(-CORE_BLOCK_SIZE_STEP))
row.add_child(smaller)
core_block_size_value_label = Label.new()
core_block_size_value_label.custom_minimum_size = Vector2(56, 0)
core_block_size_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
row.add_child(core_block_size_value_label)
var larger := Button.new()
larger.text = "+"
larger.tooltip_text = "More questions per block"
larger.custom_minimum_size = Vector2(44, 44)
larger.pressed.connect(_adjust_core_block_size.bind(CORE_BLOCK_SIZE_STEP))
row.add_child(larger)
return row
func _adjust_core_block_size(delta: int) -> void:
BlockSessionService.set_core_block_size(BlockSessionService.core_block_size + delta)
_refresh_text_scale_labels()
func _adjust_question_text_scale(delta: float) -> void: func _adjust_question_text_scale(delta: float) -> void:
var current: float = AccountProfileService.preferences.get("questionTextScale", 1.0) var current: float = AccountProfileService.preferences.get("questionTextScale", 1.0)
var next_scale: float = clamp(current + delta, QUESTION_TEXT_SCALE_MIN, QUESTION_TEXT_SCALE_MAX) var next_scale: float = clamp(current + delta, QUESTION_TEXT_SCALE_MIN, QUESTION_TEXT_SCALE_MAX)
@@ -213,8 +149,6 @@ func _refresh_text_scale_labels() -> void:
question_scale_value_label.text = "%d%%" % round(question_scale * 100) question_scale_value_label.text = "%d%%" % round(question_scale * 100)
if ccs_scale_value_label != null: if ccs_scale_value_label != null:
ccs_scale_value_label.text = "%d%%" % round(CCSService.text_scale * 100) ccs_scale_value_label.text = "%d%%" % round(CCSService.text_scale * 100)
if core_block_size_value_label != null:
core_block_size_value_label.text = str(BlockSessionService.core_block_size)
func _on_theme_toggled(enabled: bool) -> void: func _on_theme_toggled(enabled: bool) -> void:
@@ -225,12 +159,6 @@ func _open_legal_document(kind: String) -> void:
main.open_legal_document(kind) main.open_legal_document(kind)
func _return_to_session() -> void:
if return_screen.is_empty():
return
main.show_screen(return_screen)
func _on_theme_changed(is_dark: bool) -> void: func _on_theme_changed(is_dark: bool) -> void:
theme_toggle.set_pressed_no_signal(is_dark) theme_toggle.set_pressed_no_signal(is_dark)
var panel := theme_toggle.get_parent().get_parent() as PanelContainer var panel := theme_toggle.get_parent().get_parent() as PanelContainer
-7
View File
@@ -137,8 +137,6 @@ func _connect_services() -> void:
ExamSimulationService.state_changed.connect(_on_simulation_loaded) ExamSimulationService.state_changed.connect(_on_simulation_loaded)
if not ExamSimulationService.request_failed.is_connected(_on_request_failed): if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
ExamSimulationService.request_failed.connect(_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: func on_show(_payload: Variant = null) -> void:
@@ -540,11 +538,6 @@ func _on_request_failed(message: String) -> void:
_render_active_section() _render_active_section()
func _on_exam_token_required() -> void:
pending_launch = false
main.show_screen("rz_store")
func _update_responsive_layout() -> void: func _update_responsive_layout() -> void:
var use_compact := size.x < 940 var use_compact := size.x < 940
if compact_layout == use_compact: if compact_layout == use_compact:
+58 -250
View File
@@ -1,283 +1,91 @@
extends VBoxContainer extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd") const Style := preload("res://scripts/ui/ui_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 main: Control
var content: VBoxContainer var score_label: Label
var summary_label: Label
var category_list: VBoxContainer
var current_simulation := {} var current_simulation := {}
var return_screen := "selection" var return_screen := "selection"
var done_button: Button
func _ready() -> void: func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT) set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 0) add_theme_constant_override("separation", 12)
var eyebrow := Label.new()
eyebrow.text = "STEP 3 DAY 1 SIMULATION COMPLETE"
Style.set_label(eyebrow, 14, Style.CYAN)
add_child(eyebrow)
score_label = Label.new()
Style.set_label(score_label, 36, Style.LIME)
add_child(score_label)
summary_label = Label.new()
summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(summary_label, 17, Style.TEXT)
add_child(summary_label)
var scroll := ScrollContainer.new() var scroll := ScrollContainer.new()
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll) add_child(scroll)
content = VBoxContainer.new() var content := VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content.add_theme_constant_override("separation", 20) content.add_theme_constant_override("separation", 10)
scroll.add_child(content) scroll.add_child(content)
UIThemeService.theme_changed.connect(func(_dark: bool): _render()) 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)
func on_show(payload: Variant = null) -> void: func on_show(payload: Variant = null) -> void:
var wrapper: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {} 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 var simulation: Dictionary = wrapper.get("simulation", wrapper) if not wrapper.is_empty() else ExamSimulationService.simulation
return_screen = str(wrapper.get("return_screen", "selection")) 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 current_simulation = simulation
_render() var score: Dictionary = simulation.get("score", {})
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 predicted := int(score.get("predictedScore", 0))
var low := int(score.get("rangeLow", 0)) var low := int(score.get("rangeLow", 0))
var high := int(score.get("rangeHigh", 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)) var probability := int(round(float(score.get("passProbability", 0.0)) * 100.0))
var seen := int(score.get("seenCount", 0)) score_label.text = "Predicted Step 3 score: %d (%d-%d)" % [predicted, low, high]
row.add_child(_stat_card(dark, CORRECT_ICON, Brand.ROYAL_BLUE, "%d / %d" % [correct, total], "Correct")) 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." % [
row.add_child(_stat_card(dark, PROBABILITY_ICON, Brand.SUCCESS, "%d%%" % probability, "Heuristic probability of reaching 200+")) int(score.get("correct", 0)), int(score.get("total", 0)), probability, int(score.get("seenCount", 0))]
row.add_child(_stat_card(dark, SEEN_ICON, Brand.SKY_BLUE, str(seen), "Previously seen questions")) _clear(category_list)
return row 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)
func _stat_card(dark: bool, icon: Texture2D, accent: Color, value: String, caption: String) -> PanelContainer: func _return_from_results() -> void:
var inner := HBoxContainer.new() if return_screen == "simulation_history":
inner.add_theme_constant_override("separation", 12) main.show_screen("simulation_history", "preserve")
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: else:
column.add_child(grid) main.show_screen("selection", "step3")
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: func _clear(container: Control) -> void:
for child in container.get_children(): for child in container.get_children():
container.remove_child(child) child.queue_free()
child.free()
+12 -13
View File
@@ -187,19 +187,18 @@ func _build_mode_view() -> void:
retry_button = question_card.retry retry_button = question_card.retry
retry_button.pressed.connect(_retry_question_bank) retry_button.pressed.connect(_retry_question_bank)
if Brand.is_uat_environment(): var ccs_card := _add_mode_card(
var ccs_card := _add_mode_card( "CCS Cases",
"CCS Cases", "Interactive case simulations",
"Interactive case simulations", "Available",
"Available", "Available",
"Available", "Practice real-world clinical scenarios and\nimprove your management skills.",
"Practice real-world clinical scenarios and\nimprove your management skills.", "stethoscope",
"stethoscope", true,
true, Brand.TEAL)
Brand.TEAL) ccs_button = ccs_card.action
ccs_button = ccs_card.action ccs_status_label = ccs_card.status
ccs_status_label = ccs_card.status ccs_button.pressed.connect(_select_ccs)
ccs_button.pressed.connect(_select_ccs)
func _build_question_bank_launch_view() -> void: func _build_question_bank_launch_view() -> void:
-17
View File
@@ -9,10 +9,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestionsApi", "api\Questio
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestionsApi.Tests", "api\QuestionsApi.Tests\QuestionsApi.Tests.csproj", "{1897C6D6-452D-4142-BCCC-FA1A46E7C869}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestionsApi.Tests", "api\QuestionsApi.Tests\QuestionsApi.Tests.csproj", "{1897C6D6-452D-4142-BCCC-FA1A46E7C869}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Difficulty4GapSeeder", "tools\Difficulty4GapSeeder\Difficulty4GapSeeder.csproj", "{EC397984-398E-49E6-B6F0-42269A629585}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -47,18 +43,6 @@ Global
{1897C6D6-452D-4142-BCCC-FA1A46E7C869}.Release|x64.Build.0 = Release|Any CPU {1897C6D6-452D-4142-BCCC-FA1A46E7C869}.Release|x64.Build.0 = Release|Any CPU
{1897C6D6-452D-4142-BCCC-FA1A46E7C869}.Release|x86.ActiveCfg = Release|Any CPU {1897C6D6-452D-4142-BCCC-FA1A46E7C869}.Release|x86.ActiveCfg = Release|Any CPU
{1897C6D6-452D-4142-BCCC-FA1A46E7C869}.Release|x86.Build.0 = Release|Any CPU {1897C6D6-452D-4142-BCCC-FA1A46E7C869}.Release|x86.Build.0 = Release|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|x64.ActiveCfg = Debug|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|x64.Build.0 = Debug|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|x86.ActiveCfg = Debug|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|x86.Build.0 = Debug|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Release|Any CPU.Build.0 = Release|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Release|x64.ActiveCfg = Release|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Release|x64.Build.0 = Release|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Release|x86.ActiveCfg = Release|Any CPU
{EC397984-398E-49E6-B6F0-42269A629585}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -66,6 +50,5 @@ Global
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC} = {D82C56D6-B2CD-F611-9E31-04638F7EC30E} {0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC} = {D82C56D6-B2CD-F611-9E31-04638F7EC30E}
{1897C6D6-452D-4142-BCCC-FA1A46E7C869} = {D82C56D6-B2CD-F611-9E31-04638F7EC30E} {1897C6D6-452D-4142-BCCC-FA1A46E7C869} = {D82C56D6-B2CD-F611-9E31-04638F7EC30E}
{EC397984-398E-49E6-B6F0-42269A629585} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

Some files were not shown because too many files have changed in this diff Show More