Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da141670d1 |
@@ -4,10 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'tools/**'
|
||||
- 'tests/**'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -4,10 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'tools/**'
|
||||
- 'tests/**'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Promote ready UAT questions to PRD
|
||||
name: Promote UAT content to PRD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: step3-question-promotion
|
||||
group: step3-content-promotion
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -45,26 +45,26 @@ jobs:
|
||||
test "$CONFIRMATION" = "PROMOTE_TO_PRD"
|
||||
fi
|
||||
|
||||
- name: Configure question promotion credentials
|
||||
- name: Configure promotion credentials
|
||||
env:
|
||||
SOURCE_CONNECTION: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
|
||||
TARGET_CONNECTION: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }}
|
||||
run: |
|
||||
test -n "$SOURCE_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=target-connection="$TARGET_CONNECTION" \
|
||||
--dry-run=client -o yaml | kubectl apply -n step3-prd -f -
|
||||
|
||||
- name: Run ready-question promotion
|
||||
- name: Run promotion
|
||||
env:
|
||||
MODE: ${{ inputs.mode }}
|
||||
CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
IMAGE="$(kubectl get deployment step3-api -n step3-prd -o jsonpath='{.spec.template.spec.containers[0].image}')"
|
||||
REVISION="${IMAGE##*:}"
|
||||
POD="question-promotion-${GITHUB_RUN_NUMBER}"
|
||||
POD="content-promotion-${GITHUB_RUN_NUMBER}"
|
||||
APPLY_ARGS=""
|
||||
if [ "$MODE" = "apply" ]; then
|
||||
APPLY_ARGS="--apply --confirm $CONFIRMATION"
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
name: ${POD}
|
||||
namespace: step3-prd
|
||||
labels:
|
||||
app: step3-question-promotion
|
||||
app: step3-content-promotion
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- >-
|
||||
dotnet QuestionsApi.dll promote-questions
|
||||
dotnet QuestionsApi.dll promote-content
|
||||
--source-database ranaze_step3_uat
|
||||
--target-database ranaze_step3_prd
|
||||
--operator "${GITHUB_ACTOR:-gitea-operator}"
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
- name: SOURCE_MONGODB_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-question-promotion
|
||||
name: step3-content-promotion
|
||||
key: source-connection
|
||||
- name: TARGET_MONGODB_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-question-promotion
|
||||
name: step3-content-promotion
|
||||
key: target-connection
|
||||
EOF
|
||||
for attempt in $(seq 1 120); do
|
||||
@@ -115,5 +115,6 @@ jobs:
|
||||
- name: Remove promotion credentials and pod
|
||||
if: always()
|
||||
run: |
|
||||
kubectl delete pod "question-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
|
||||
kubectl delete secret step3-question-promotion -n step3-prd --ignore-not-found
|
||||
kubectl delete pod "content-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
|
||||
kubectl delete secret step3-content-promotion -n step3-prd --ignore-not-found
|
||||
|
||||
|
||||
@@ -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.
|
||||
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`.
|
||||
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:
|
||||
|
||||
```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.
|
||||
|
||||
## 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
|
||||
|
||||
## Rz Coin Store attempt-cycle migration
|
||||
|
||||
The QBank reset preserves every attempt and increments a user's eligibility cycle. After deploying
|
||||
the store-capable API to an environment, inspect and then apply the one-time index migration before
|
||||
using QBank reset tokens:
|
||||
|
||||
```bash
|
||||
kubectl exec deployment/step3-api -n step3-uat -- \
|
||||
dotnet QuestionsApi.dll migrate-question-attempt-cycles
|
||||
kubectl exec deployment/step3-api -n step3-uat -- \
|
||||
dotnet QuestionsApi.dll migrate-question-attempt-cycles --apply --confirm MIGRATE_ATTEMPT_CYCLES
|
||||
```
|
||||
|
||||
Repeat with namespace `step3-prd` after the tested revision is promoted to `production`. The dry run
|
||||
reports missing cycle fields and both index states. Apply backfills existing attempts to cycle zero,
|
||||
creates the three-field unique index, and only then removes the legacy two-field unique index.
|
||||
|
||||
Check both internal and public readiness after every rollout:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -148,45 +148,6 @@ public sealed class ChoicePoolTests
|
||||
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]
|
||||
[InlineData("http://example.org/guideline", "HTTPS")]
|
||||
[InlineData("https://example.org/guideline", null)]
|
||||
|
||||
@@ -31,167 +31,4 @@ public sealed class ContentPromotionTests
|
||||
ContentPromotionCommand.HashDocuments([first, second]),
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -100,8 +100,7 @@ public sealed class DashboardTests
|
||||
HistoryStartedUtc = baselineDate
|
||||
};
|
||||
|
||||
var result = DashboardStore.BuildPerformanceTrend(
|
||||
attempts, rating, Array.Empty<CcsAttempt>(), new CcsUserRating(), today);
|
||||
var result = DashboardStore.BuildPerformanceTrend(attempts, rating, today);
|
||||
|
||||
Assert.Equal(30, result.Count);
|
||||
Assert.Null(result[^4].QuestionBankRating);
|
||||
@@ -114,39 +113,6 @@ public sealed class DashboardTests
|
||||
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(
|
||||
string questionId,
|
||||
bool correct,
|
||||
@@ -168,66 +134,4 @@ public sealed class DashboardTests
|
||||
ExternalId = 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 }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,104 +44,8 @@ public sealed class ExamSimulationTests
|
||||
Assert.Equal(0, failing.Correct);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 50)]
|
||||
[InlineData(20, 100)]
|
||||
[InlineData(60, 200)]
|
||||
[InlineData(100, 300)]
|
||||
public void NeutralDifficultyUsesTransparentScoreAnchors(int correctCount, int expectedScore)
|
||||
{
|
||||
var items = BuildItems(100);
|
||||
var questions = BuildQuestions(100);
|
||||
var responses = items.Take(correctCount).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var score = ExamSimulationScoring.Calculate(items, responses, questions);
|
||||
|
||||
Assert.Equal(expectedScore, score.PredictedScore);
|
||||
Assert.Equal(correctCount / 100.0, score.RawAccuracy, 4);
|
||||
Assert.Equal(correctCount / 100.0, score.DifficultyAdjustedAccuracy, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorrectHarderQuestionsProduceAHigherAdjustedPrediction()
|
||||
{
|
||||
var questions = BuildQuestions(10);
|
||||
var hardCorrect = BuildItems(10);
|
||||
var easyCorrect = BuildItems(10);
|
||||
for (var index = 0; index < 10; index++)
|
||||
{
|
||||
hardCorrect[index].QuestionDifficulty = index < 5 ? 5 : 1;
|
||||
easyCorrect[index].QuestionDifficulty = index < 5 ? 1 : 5;
|
||||
}
|
||||
var responses = hardCorrect.Take(5).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var hardScore = ExamSimulationScoring.Calculate(hardCorrect, responses, questions);
|
||||
var easyScore = ExamSimulationScoring.Calculate(easyCorrect, responses, questions);
|
||||
|
||||
Assert.Equal(hardScore.RawAccuracy, easyScore.RawAccuracy);
|
||||
Assert.True(hardScore.DifficultyAdjustedAccuracy > easyScore.DifficultyAdjustedAccuracy);
|
||||
Assert.True(hardScore.PredictedScore > easyScore.PredictedScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmpiricalQuestionEloDoesNotAffectPrediction()
|
||||
{
|
||||
var questions = BuildQuestions(20);
|
||||
var lowElos = BuildItems(20);
|
||||
var highElos = BuildItems(20);
|
||||
foreach (var item in lowElos) item.QuestionElo = 100;
|
||||
foreach (var item in highElos) item.QuestionElo = 1900;
|
||||
var responses = lowElos.Take(10).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var lowEloScore = ExamSimulationScoring.Calculate(lowElos, responses, questions);
|
||||
var highEloScore = ExamSimulationScoring.Calculate(highElos, responses, questions);
|
||||
|
||||
Assert.Equal(lowEloScore.PredictedScore, highEloScore.PredictedScore);
|
||||
Assert.Equal(lowEloScore.PassProbability, highEloScore.PassProbability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkippedAndUnansweredQuestionsCountAsIncorrect()
|
||||
{
|
||||
var items = BuildItems(10);
|
||||
var questions = BuildQuestions(10);
|
||||
var responses = new Dictionary<string, ExamSimulationResponse>
|
||||
{
|
||||
[items[0].QuestionExternalId] = new() { QuestionExternalId = items[0].QuestionExternalId, ChoiceId = "A" },
|
||||
[items[1].QuestionExternalId] = new() { QuestionExternalId = items[1].QuestionExternalId, ChoiceId = "" }
|
||||
};
|
||||
|
||||
var score = ExamSimulationScoring.Calculate(items, responses, questions);
|
||||
|
||||
Assert.Equal(1, score.Correct);
|
||||
Assert.Equal(10, score.Total);
|
||||
Assert.Equal(0.1, score.RawAccuracy, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LowAccuracyFullLengthSimulationIsNotInflated()
|
||||
{
|
||||
var items = BuildItems(232);
|
||||
var questions = BuildQuestions(232);
|
||||
var responses = items.Take(49).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var score = ExamSimulationScoring.Calculate(items, responses, questions);
|
||||
|
||||
Assert.Equal(103, score.PredictedScore);
|
||||
Assert.True(score.PassProbability < 0.001);
|
||||
Assert.True(score.RangeHigh < 130);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeenItemsAreReportedButDoNotChangeScoring()
|
||||
public void SeenItemsAreReportedAndCarryLessScoringWeight()
|
||||
{
|
||||
var questions = BuildQuestions(20);
|
||||
var unseen = BuildItems(20);
|
||||
@@ -155,9 +59,7 @@ public sealed class ExamSimulationTests
|
||||
|
||||
Assert.Equal(0, unseenScore.SeenCount);
|
||||
Assert.Equal(20, seenScore.SeenCount);
|
||||
Assert.Equal(unseenScore.PredictedScore, seenScore.PredictedScore);
|
||||
Assert.Null(unseenScore.AbilityElo);
|
||||
Assert.Null(seenScore.AbilityElo);
|
||||
Assert.True(unseenScore.AbilityElo > seenScore.AbilityElo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -209,64 +111,11 @@ public sealed class ExamSimulationTests
|
||||
Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExamSimulationStates.BetweenBlocks, true)]
|
||||
[InlineData(ExamSimulationStates.Completed, true)]
|
||||
[InlineData(ExamSimulationStates.BlockActive, false)]
|
||||
[InlineData(ExamSimulationStates.Abandoned, false)]
|
||||
public void RepeatedBlockSubmissionIsIdempotentOnlyAfterSubmission(string state, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ExamSimulationStore.IsAlreadySubmittedState(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmittedSimulationResponsesAreLocked()
|
||||
{
|
||||
Assert.True(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = true }));
|
||||
Assert.False(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = false }));
|
||||
Assert.True(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = null }));
|
||||
Assert.False(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "", IsSubmitted = null }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmittedResponseRetriesMustPreserveAnswerContent()
|
||||
{
|
||||
var response = new ExamSimulationResponse
|
||||
{
|
||||
ChoiceId = "B",
|
||||
EliminatedChoiceIds = ["A"],
|
||||
Flagged = true,
|
||||
IsSubmitted = true
|
||||
};
|
||||
var same = new SaveSimulationResponseRequest
|
||||
{
|
||||
ChoiceId = "B",
|
||||
EliminatedChoiceIds = ["A"],
|
||||
Flagged = true,
|
||||
IsSubmitted = true
|
||||
};
|
||||
var changed = new SaveSimulationResponseRequest
|
||||
{
|
||||
ChoiceId = "C",
|
||||
EliminatedChoiceIds = ["A"],
|
||||
Flagged = true,
|
||||
IsSubmitted = true
|
||||
};
|
||||
|
||||
Assert.True(ExamSimulationStore.ResponseContentMatches(response, same, ["A", "B", "C"]));
|
||||
Assert.False(ExamSimulationStore.ResponseContentMatches(response, changed, ["A", "B", "C"]));
|
||||
}
|
||||
|
||||
private static List<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
|
||||
Enumerable.Range(0, count).Select(index => new ExamSimulationItem
|
||||
{
|
||||
QuestionExternalId = $"question-{index}",
|
||||
QuestionElo = 1000,
|
||||
QuestionDifficulty = 3,
|
||||
WasSeen = wasSeen,
|
||||
BlueprintCategory = "Cardiovascular"
|
||||
}).ToList();
|
||||
@@ -274,5 +123,5 @@ public sealed class ExamSimulationTests
|
||||
private static Dictionary<string, Question> BuildQuestions(int count) =>
|
||||
Enumerable.Range(0, count).ToDictionary(
|
||||
index => $"question-{index}",
|
||||
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A", 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]
|
||||
[Route("api/exam-simulations")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
|
||||
{
|
||||
[HttpGet("readiness")]
|
||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||
@@ -17,33 +17,12 @@ public class ExamSimulationsController(ExamSimulationStore simulations, CreditSt
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var userId = UserId();
|
||||
if (await simulations.FindActiveAsync(userId) is not null)
|
||||
return Conflict(new { code = "active_exam", message = "An active Step 3 simulation already exists." });
|
||||
var operationId = $"exam-create:{Guid.NewGuid():N}";
|
||||
var bypass = User.IsInRole("SUPER");
|
||||
if (!bypass && !await credits.TryConsumeExamTokenAsync(userId, operationId))
|
||||
return Conflict(new
|
||||
{
|
||||
code = "exam_token_required",
|
||||
message = "An Exam Simulation Token is required.",
|
||||
credits = await credits.GetStatusAsync(userId)
|
||||
});
|
||||
try
|
||||
{
|
||||
var simulation = await simulations.CreateAsync(userId);
|
||||
var simulation = await simulations.CreateAsync(UserId());
|
||||
return Ok(await simulations.ToResponseAsync(simulation));
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
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;
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
[HttpGet("preview-results")]
|
||||
|
||||
@@ -19,7 +19,6 @@ public class QuestionsController : ControllerBase
|
||||
private readonly ServiceMetrics _metrics;
|
||||
private readonly QuestionBookmarkStore _bookmarks;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
private readonly CreditStore _credits;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -29,8 +28,7 @@ public class QuestionsController : ControllerBase
|
||||
AnswerStatisticsStore statistics,
|
||||
ServiceMetrics metrics,
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue,
|
||||
CreditStore credits)
|
||||
ReviewQueueStore reviewQueue)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -40,7 +38,6 @@ public class QuestionsController : ControllerBase
|
||||
_metrics = metrics;
|
||||
_bookmarks = bookmarks;
|
||||
_reviewQueue = reviewQueue;
|
||||
_credits = credits;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -157,8 +154,7 @@ public class QuestionsController : ControllerBase
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||
false,
|
||||
presentedChoiceIds,
|
||||
req.DurationSeconds,
|
||||
await _credits.GetBankCycleAsync(userId));
|
||||
req.DurationSeconds);
|
||||
if (record.Inserted)
|
||||
{
|
||||
_metrics.AttemptCreated();
|
||||
@@ -176,8 +172,6 @@ public class QuestionsController : ControllerBase
|
||||
_metrics.AttemptDuplicate();
|
||||
}
|
||||
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 shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
|
||||
@@ -210,8 +204,7 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating,
|
||||
Credits = await _credits.GetStatusAsync(userId)
|
||||
Rating = rating
|
||||
});
|
||||
}
|
||||
|
||||
@@ -277,8 +270,7 @@ public class QuestionsController : ControllerBase
|
||||
userId,
|
||||
"",
|
||||
false,
|
||||
true,
|
||||
bankCycle: await _credits.GetBankCycleAsync(userId));
|
||||
true);
|
||||
if (record.Inserted)
|
||||
_metrics.AttemptCreated();
|
||||
else
|
||||
|
||||
@@ -349,8 +349,6 @@ public class CcsUserRating
|
||||
public string UserId { get; set; } = "";
|
||||
public int Elo { get; set; } = 1000;
|
||||
public int RatedCaseCount { get; set; }
|
||||
public int? HistoryBaselineElo { get; set; }
|
||||
public DateTime? HistoryStartedUtc { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 FirstAttemptAccuracy { 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 StudyTimeSecondsThisWeek { get; set; }
|
||||
public int CurrentStreak { get; set; }
|
||||
@@ -40,7 +33,6 @@ public sealed class DashboardTrendPoint
|
||||
public int Total { get; set; }
|
||||
public int Accuracy { get; set; }
|
||||
public int? QuestionBankRating { get; set; }
|
||||
public int? CcsRating { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardSystemPerformance
|
||||
|
||||
@@ -41,7 +41,6 @@ public class ExamSimulationItem
|
||||
public int Position { get; set; }
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public int QuestionDifficulty { get; set; }
|
||||
public bool WasSeen { get; set; }
|
||||
public string BlueprintCategory { get; set; } = "Other";
|
||||
}
|
||||
@@ -53,7 +52,6 @@ public class ExamSimulationResponse
|
||||
public List<string> EliminatedChoiceIds { get; set; } = [];
|
||||
public bool Flagged { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public bool? IsSubmitted { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -66,9 +64,7 @@ public class ExamSimulationScore
|
||||
public int Correct { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int SeenCount { get; set; }
|
||||
public double RawAccuracy { get; set; }
|
||||
public double DifficultyAdjustedAccuracy { get; set; }
|
||||
public double? AbilityElo { get; set; }
|
||||
public double AbilityElo { get; set; }
|
||||
public Dictionary<string, ExamSimulationCategoryScore> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -85,7 +81,6 @@ public class SaveSimulationResponseRequest
|
||||
public List<string> EliminatedChoiceIds { get; set; } = [];
|
||||
public bool Flagged { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public bool IsSubmitted { get; set; }
|
||||
}
|
||||
|
||||
public class ExamSimulationQuestionResponse
|
||||
|
||||
@@ -11,7 +11,6 @@ public class QuestionAttempt
|
||||
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public int BankCycle { get; set; }
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public bool TracksPresentation { get; set; }
|
||||
|
||||
@@ -49,5 +49,4 @@ public class FirstQuestionAttemptRequest
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { 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 int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
|
||||
@@ -47,6 +47,4 @@ public class RatingProfileResponse
|
||||
public int OverallElo { get; set; }
|
||||
public int RatedQuestionCount { get; set; }
|
||||
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
||||
public string Tier { get; set; } = "";
|
||||
public int TierDivision { get; set; }
|
||||
}
|
||||
|
||||
@@ -17,12 +17,6 @@ if (args.FirstOrDefault() == "promote-content")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-questions")
|
||||
{
|
||||
Environment.ExitCode = await QuestionPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-dynamic-questions")
|
||||
{
|
||||
Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
@@ -55,17 +49,6 @@ if (args.FirstOrDefault() == "flag-media-questions")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "migrate-question-attempt-cycles")
|
||||
{
|
||||
var migrationConfiguration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
Environment.ExitCode = await QuestionAttemptCycleMigrationCommand.RunAsync(
|
||||
migrationConfiguration, args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
@@ -77,8 +60,6 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<CreditStore>();
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
||||
@@ -125,7 +106,6 @@ builder.Services.AddSingleton<DonationStore>();
|
||||
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
builder.Services.AddHostedService<ExamSimulationScoringMigrationService>();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
|
||||
@@ -31,12 +31,6 @@ public sealed class CcsRatingStore
|
||||
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)
|
||||
{
|
||||
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);
|
||||
user = await EnsureHistoryBaselineAsync(user, DateTime.UtcNow);
|
||||
var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId);
|
||||
var expected = ExpectedScore(user.Elo, caseRating.Elo);
|
||||
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)
|
||||
{
|
||||
while (true)
|
||||
|
||||
@@ -25,11 +25,8 @@ public static class ContentPromotionCommand
|
||||
await source.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();
|
||||
var questions = sourceQuestions
|
||||
.Select(QuestionPromotionCommand.PrepareForProduction)
|
||||
.ToList();
|
||||
var mediaIds = CollectMediaIds(questions);
|
||||
var media = mediaIds.Count == 0
|
||||
? []
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -19,8 +19,6 @@ public sealed class DashboardStore
|
||||
private readonly FlashcardStudyService _flashcards;
|
||||
private readonly FlashcardScheduleStore _flashcardSchedules;
|
||||
private readonly RatingStore _ratingStore;
|
||||
private readonly CcsRatingStore _ccsRatingStore;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
|
||||
public static readonly string[] Step3Systems =
|
||||
[
|
||||
@@ -47,8 +45,7 @@ public sealed class DashboardStore
|
||||
DynamicQuestionStore dynamicQuestions,
|
||||
FlashcardStudyService flashcards,
|
||||
FlashcardScheduleStore flashcardSchedules,
|
||||
RatingStore ratingStore,
|
||||
CcsRatingStore ccsRatingStore)
|
||||
RatingStore ratingStore)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||
@@ -57,14 +54,12 @@ public sealed class DashboardStore
|
||||
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
|
||||
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
|
||||
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||
_questions = questions;
|
||||
_reviewQueue = reviewQueue;
|
||||
_dynamicQuestions = dynamicQuestions;
|
||||
_flashcards = flashcards;
|
||||
_flashcardSchedules = flashcardSchedules;
|
||||
_ratingStore = ratingStore;
|
||||
_ccsRatingStore = ccsRatingStore;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
@@ -74,29 +69,24 @@ public sealed class DashboardStore
|
||||
var weekStart = today.AddDays(-6);
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
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 ccsAllAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
|
||||
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue);
|
||||
var simulationsTask = _simulations.Find(value => value.UserId == userId && !value.IsSample && 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 simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
|
||||
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
|
||||
var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart);
|
||||
var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart);
|
||||
var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc);
|
||||
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, ccsAllAttemptsTask, completedCcsCountTask,
|
||||
simulationsTask, completedSimulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
|
||||
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
|
||||
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
|
||||
flashcardReviewsTask, dynamicReviewsTask);
|
||||
|
||||
var attempts = attemptsTask.Result;
|
||||
var answeredAttempts = attempts.Where(value => !value.IsSkip).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 ccsAttempts = ccsAttemptsTask.Result;
|
||||
var simulations = simulationsTask.Result;
|
||||
@@ -104,39 +94,16 @@ public sealed class DashboardStore
|
||||
await _reviewQueue.EnsureUserQueueAsync(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
|
||||
{
|
||||
QuestionBankRating = qbankElo,
|
||||
QuestionBankRating = ratingTask.Result.OverallElo,
|
||||
RatedQuestionCount = ratingTask.Result.RatedQuestionCount,
|
||||
CcsRating = ccsElo,
|
||||
RatedCcsCaseCount = ccsRatingTask.Result.RatedCaseCount,
|
||||
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
|
||||
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
|
||||
QuestionsCompleted = answeredAttempts.Count,
|
||||
CcsCasesCompleted = checked((int)completedCcsCountTask.Result),
|
||||
AccuracySampleSize = accuracySampleSize,
|
||||
FirstAttemptAccuracy = firstAttemptAccuracy,
|
||||
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),
|
||||
AccuracySampleSize = recentAnswered.Count,
|
||||
FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
|
||||
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
|
||||
BookmarkCount = checked((int)bookmarkCountTask.Result),
|
||||
NoteCount = CountNotes(notepadTask.Result?.Content),
|
||||
@@ -147,7 +114,7 @@ public sealed class DashboardStore
|
||||
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
|
||||
};
|
||||
|
||||
response.PerformanceTrend = performanceTrend;
|
||||
response.PerformanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, today);
|
||||
response.Systems = BuildSystemPerformance(answeredAttempts, questionMap);
|
||||
|
||||
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
|
||||
@@ -181,22 +148,6 @@ public sealed class DashboardStore
|
||||
public static int Percentage(int correct, int total) =>
|
||||
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)
|
||||
{
|
||||
var dates = activeDates.Select(value => value.Date).ToHashSet();
|
||||
@@ -214,12 +165,9 @@ public sealed class DashboardStore
|
||||
public static List<DashboardTrendPoint> BuildPerformanceTrend(
|
||||
IEnumerable<QuestionAttempt> attempts,
|
||||
UserRating rating,
|
||||
IEnumerable<CcsAttempt> ccsAttempts,
|
||||
CcsUserRating ccsRating,
|
||||
DateTime todayUtc)
|
||||
{
|
||||
var attemptList = attempts.ToList();
|
||||
var ccsAttemptList = ccsAttempts.ToList();
|
||||
var trendStart = todayUtc.Date.AddDays(-29);
|
||||
var ratingEvents = new List<(DateTime AtUtc, int Elo)>();
|
||||
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
|
||||
@@ -229,18 +177,8 @@ public sealed class DashboardStore
|
||||
.Select(value => (value.RatingAppliedUtc!.Value, value.UserOverallEloAfter!.Value)));
|
||||
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? currentCcsRating = null;
|
||||
var eventIndex = 0;
|
||||
var ccsEventIndex = 0;
|
||||
var result = new List<DashboardTrendPoint>();
|
||||
foreach (var offset in Enumerable.Range(0, 30))
|
||||
{
|
||||
@@ -251,11 +189,6 @@ public sealed class DashboardStore
|
||||
currentRating = ratingEvents[eventIndex].Elo;
|
||||
eventIndex++;
|
||||
}
|
||||
while (ccsEventIndex < ccsRatingEvents.Count && ccsRatingEvents[ccsEventIndex].AtUtc < nextDate)
|
||||
{
|
||||
currentCcsRating = ccsRatingEvents[ccsEventIndex].Elo;
|
||||
ccsEventIndex++;
|
||||
}
|
||||
|
||||
var daily = attemptList
|
||||
.Where(value => !value.IsSkip && value.CreatedUtc >= date && value.CreatedUtc < nextDate)
|
||||
@@ -267,8 +200,7 @@ public sealed class DashboardStore
|
||||
Correct = correct,
|
||||
Total = daily.Count,
|
||||
Accuracy = Percentage(correct, daily.Count),
|
||||
QuestionBankRating = currentRating,
|
||||
CcsRating = currentCcsRating
|
||||
QuestionBankRating = currentRating
|
||||
});
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -4,35 +4,33 @@ namespace QuestionsApi.Services;
|
||||
|
||||
public static class ExamSimulationScoring
|
||||
{
|
||||
public const string ModelVersion = "provisional-2";
|
||||
private const double PassingAccuracy = 0.60;
|
||||
private const double ScoreIntercept = 50.0;
|
||||
private const double ScoreScale = 250.0;
|
||||
public const string ModelVersion = "provisional-1";
|
||||
private const double PriorMean = 1000.0;
|
||||
private const double PriorSd = 400.0;
|
||||
|
||||
public static ExamSimulationScore Calculate(
|
||||
IReadOnlyList<ExamSimulationItem> items,
|
||||
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
|
||||
IReadOnlyDictionary<string, Question> questions)
|
||||
{
|
||||
var ability = EstimateAbility(items, responses, questions);
|
||||
var predicted = Math.Clamp((int)Math.Round(227.0 + (ability - 1000.0) * 15.0 / 200.0), 1, 300);
|
||||
var information = 1.0 / (PriorSd * PriorSd);
|
||||
var correct = 0;
|
||||
var seen = 0;
|
||||
var weightedCorrect = 0.0;
|
||||
var weightSum = 0.0;
|
||||
var squaredWeightSum = 0.0;
|
||||
var categories = new Dictionary<string, ExamSimulationCategoryScore>();
|
||||
|
||||
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);
|
||||
var isCorrect = question is not null && response?.ChoiceId == question.CorrectChoiceId;
|
||||
var isCorrect = response?.ChoiceId == question.CorrectChoiceId;
|
||||
if (isCorrect) correct++;
|
||||
if (item.WasSeen) seen++;
|
||||
var difficulty = ResolveDifficulty(item, question);
|
||||
var weight = DifficultyWeight(difficulty);
|
||||
weightSum += weight;
|
||||
squaredWeightSum += weight * weight;
|
||||
if (isCorrect) weightedCorrect += weight;
|
||||
var weight = item.WasSeen ? 0.5 : 1.0;
|
||||
var probability = Expected(ability, item.QuestionElo);
|
||||
information += weight * probability * (1.0 - probability) * Math.Pow(Math.Log(10.0) / 400.0, 2);
|
||||
if (!categories.TryGetValue(item.BlueprintCategory, out var category))
|
||||
{
|
||||
category = new ExamSimulationCategoryScore();
|
||||
@@ -42,53 +40,51 @@ public static class ExamSimulationScoring
|
||||
if (isCorrect) category.Correct++;
|
||||
}
|
||||
|
||||
var rawAccuracy = items.Count == 0 ? 0.0 : (double)correct / items.Count;
|
||||
var adjustedAccuracy = weightSum == 0.0 ? 0.0 : weightedCorrect / weightSum;
|
||||
var predicted = ToScore(adjustedAccuracy);
|
||||
var effectiveSampleSize = squaredWeightSum == 0.0 ? 0.0 : weightSum * weightSum / squaredWeightSum;
|
||||
var accuracySe = effectiveSampleSize == 0.0
|
||||
? 0.0
|
||||
: Math.Sqrt(adjustedAccuracy * (1.0 - adjustedAccuracy) / effectiveSampleSize);
|
||||
var accuracyMargin = 1.96 * accuracySe;
|
||||
var rangeLow = ToScore(Math.Clamp(adjustedAccuracy - accuracyMargin, 0.0, 1.0));
|
||||
var rangeHigh = ToScore(Math.Clamp(adjustedAccuracy + accuracyMargin, 0.0, 1.0));
|
||||
var passProbability = accuracySe == 0.0
|
||||
? adjustedAccuracy >= PassingAccuracy ? 1.0 : 0.0
|
||||
: NormalCdf((adjustedAccuracy - PassingAccuracy) / accuracySe);
|
||||
var abilitySe = Math.Sqrt(1.0 / information);
|
||||
var scoreSe = abilitySe * 15.0 / 200.0;
|
||||
var combinedSe = Math.Sqrt(7.0 * 7.0 + 15.0 * 15.0 + scoreSe * scoreSe);
|
||||
var range = (int)Math.Ceiling(combinedSe);
|
||||
var passProbability = NormalCdf((predicted - 200.0) / combinedSe);
|
||||
return new ExamSimulationScore
|
||||
{
|
||||
PredictedScore = predicted,
|
||||
RangeLow = rangeLow,
|
||||
RangeHigh = rangeHigh,
|
||||
RangeLow = Math.Clamp(predicted - range, 1, 300),
|
||||
RangeHigh = Math.Clamp(predicted + range, 1, 300),
|
||||
PassProbability = Math.Round(passProbability, 4),
|
||||
Correct = correct,
|
||||
Total = items.Count,
|
||||
SeenCount = seen,
|
||||
RawAccuracy = Math.Round(rawAccuracy, 4),
|
||||
DifficultyAdjustedAccuracy = Math.Round(adjustedAccuracy, 4),
|
||||
AbilityElo = null,
|
||||
AbilityElo = Math.Round(ability, 1),
|
||||
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;
|
||||
return question?.Difficulty is >= 1 and <= 5 ? question.Difficulty : 3;
|
||||
var low = 100.0;
|
||||
var high = 1900.0;
|
||||
for (var iteration = 0; iteration < 80; iteration++)
|
||||
{
|
||||
var ability = (low + high) / 2.0;
|
||||
var derivative = -(ability - PriorMean) / (PriorSd * PriorSd);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!questions.TryGetValue(item.QuestionExternalId, out var question)) continue;
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var actual = response?.ChoiceId == question.CorrectChoiceId ? 1.0 : 0.0;
|
||||
var weight = item.WasSeen ? 0.5 : 1.0;
|
||||
derivative += weight * (actual - Expected(ability, item.QuestionElo)) * Math.Log(10.0) / 400.0;
|
||||
}
|
||||
if (derivative > 0) low = ability; else high = ability;
|
||||
}
|
||||
return (low + high) / 2.0;
|
||||
}
|
||||
|
||||
private static double DifficultyWeight(int difficulty) => difficulty switch
|
||||
{
|
||||
1 => 0.8,
|
||||
2 => 0.9,
|
||||
3 => 1.0,
|
||||
4 => 1.1,
|
||||
5 => 1.2,
|
||||
_ => 1.0
|
||||
};
|
||||
|
||||
private static int ToScore(double accuracy) =>
|
||||
Math.Clamp((int)Math.Round(ScoreIntercept + ScoreScale * accuracy), 1, 300);
|
||||
private static double Expected(double ability, int difficulty) =>
|
||||
1.0 / (1.0 + Math.Pow(10.0, (difficulty - ability) / 400.0));
|
||||
|
||||
private static double NormalCdf(double value) => 0.5 * (1.0 + Erf(value / Math.Sqrt(2.0)));
|
||||
|
||||
|
||||
@@ -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 QuestionStore _questions;
|
||||
private readonly QuestionAttemptStore _attempts;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly AnswerStatisticsStore _statistics;
|
||||
private readonly RatingStore _ratings;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
@@ -23,7 +22,6 @@ public class ExamSimulationStore
|
||||
MongoDatabaseProvider mongo,
|
||||
QuestionStore questions,
|
||||
QuestionAttemptStore attempts,
|
||||
CreditStore credits,
|
||||
AnswerStatisticsStore statistics,
|
||||
RatingStore ratings,
|
||||
ServiceMetrics metrics,
|
||||
@@ -33,7 +31,6 @@ public class ExamSimulationStore
|
||||
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
_credits = credits;
|
||||
_statistics = statistics;
|
||||
_ratings = ratings;
|
||||
_metrics = metrics;
|
||||
@@ -71,8 +68,7 @@ public class ExamSimulationStore
|
||||
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
|
||||
if (bank.Count < RequiredQuestions)
|
||||
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required.");
|
||||
var bankCycle = await _credits.GetBankCycleAsync(userId);
|
||||
var seen = (await _attempts.GetUserQuestionIdsAsync(userId, bankCycle)).ToHashSet(StringComparer.Ordinal);
|
||||
var seen = (await _attempts.GetUserQuestionIdsAsync(userId)).ToHashSet(StringComparer.Ordinal);
|
||||
var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
|
||||
var targets = BuildTargets();
|
||||
var selected = SelectForm(bank, seen, targets);
|
||||
@@ -91,7 +87,6 @@ public class ExamSimulationStore
|
||||
Position = offset,
|
||||
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
|
||||
QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000),
|
||||
QuestionDifficulty = question.Difficulty,
|
||||
WasSeen = seen.Contains(question.ExternalId),
|
||||
BlueprintCategory = BlueprintCategory(question)
|
||||
});
|
||||
@@ -129,7 +124,6 @@ public class ExamSimulationStore
|
||||
Position = PositionWithinBlock(index),
|
||||
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
|
||||
QuestionElo = 1000,
|
||||
QuestionDifficulty = question.Difficulty,
|
||||
WasSeen = false,
|
||||
BlueprintCategory = BlueprintCategory(question)
|
||||
}).ToList();
|
||||
@@ -175,22 +169,15 @@ public class ExamSimulationStore
|
||||
(item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive))
|
||||
.SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId)
|
||||
{
|
||||
var simulation = await _simulations
|
||||
.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
|
||||
.SortByDescending(item => item.CompletedUtc)
|
||||
.FirstOrDefaultAsync();
|
||||
return simulation is null ? null : (await UpgradeScoringModelAsync(simulation)).Score;
|
||||
}
|
||||
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId) =>
|
||||
(await _simulations.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
|
||||
.SortByDescending(item => item.CompletedUtc).FirstOrDefaultAsync())?.Score;
|
||||
|
||||
public async Task<ExamSimulation?> GetOwnedAsync(string id, string userId)
|
||||
{
|
||||
var simulation = await _simulations.Find(item => item.Id == id && item.UserId == userId).FirstOrDefaultAsync();
|
||||
if (simulation is not null && simulation.State == ExamSimulationStates.BlockActive && simulation.BlockDeadlineUtc <= DateTime.UtcNow)
|
||||
simulation = await SubmitBlockAsync(simulation, true);
|
||||
if (simulation is not null)
|
||||
simulation = await UpgradeScoringModelAsync(simulation);
|
||||
return simulation;
|
||||
}
|
||||
|
||||
@@ -204,7 +191,6 @@ public class ExamSimulationStore
|
||||
var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync();
|
||||
var hasMore = simulations.Count > limit;
|
||||
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
|
||||
await UpgradeScoringModelsAsync(simulations);
|
||||
return new CompletedExamSimulationPage
|
||||
{
|
||||
Items = simulations.Select(item => new CompletedExamSimulationSummary
|
||||
@@ -271,7 +257,6 @@ public class ExamSimulationStore
|
||||
var simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync();
|
||||
var hasMore = simulations.Count > limit;
|
||||
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
|
||||
await UpgradeScoringModelsAsync(simulations.Where(item => item.State == ExamSimulationStates.Completed));
|
||||
var last = simulations.LastOrDefault();
|
||||
return new ExamSimulationHistoryPage
|
||||
{
|
||||
@@ -348,53 +333,23 @@ public class ExamSimulationStore
|
||||
if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "")
|
||||
throw new ArgumentException("Choice is not presented for this question.");
|
||||
var response = simulation.Responses.FirstOrDefault(candidate => candidate.QuestionExternalId == request.QuestionExternalId);
|
||||
if (response is not null && IsResponseLocked(response))
|
||||
{
|
||||
if (ResponseContentMatches(response, request, item.PresentedChoiceIds))
|
||||
return simulation;
|
||||
throw new InvalidOperationException("A submitted simulation answer cannot be changed.");
|
||||
}
|
||||
if (response is null)
|
||||
{
|
||||
response = new ExamSimulationResponse
|
||||
{
|
||||
QuestionExternalId = request.QuestionExternalId,
|
||||
IsSubmitted = false
|
||||
};
|
||||
response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId };
|
||||
simulation.Responses.Add(response);
|
||||
}
|
||||
response.ChoiceId = request.ChoiceId;
|
||||
response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList();
|
||||
response.Flagged = request.Flagged;
|
||||
response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800);
|
||||
response.IsSubmitted = request.IsSubmitted;
|
||||
response.UpdatedUtc = DateTime.UtcNow;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
await ReplaceAsync(simulation);
|
||||
return simulation;
|
||||
}
|
||||
|
||||
public static bool IsResponseLocked(ExamSimulationResponse response) =>
|
||||
response.IsSubmitted == true ||
|
||||
(response.IsSubmitted is null && !string.IsNullOrEmpty(response.ChoiceId));
|
||||
|
||||
public static bool ResponseContentMatches(
|
||||
ExamSimulationResponse response,
|
||||
SaveSimulationResponseRequest request,
|
||||
IReadOnlyCollection<string> presentedChoiceIds)
|
||||
{
|
||||
var eliminated = request.EliminatedChoiceIds
|
||||
.Where(presentedChoiceIds.Contains)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
return response.ChoiceId == request.ChoiceId &&
|
||||
response.Flagged == request.Flagged &&
|
||||
response.EliminatedChoiceIds.ToHashSet(StringComparer.Ordinal).SetEquals(eliminated);
|
||||
}
|
||||
|
||||
public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
|
||||
{
|
||||
if (IsAlreadySubmittedState(simulation.State))
|
||||
return simulation;
|
||||
if (simulation.State != ExamSimulationStates.BlockActive)
|
||||
throw new InvalidOperationException("There is no active block to submit.");
|
||||
if (!expired && simulation.BlockDeadlineUtc is not null)
|
||||
@@ -411,9 +366,6 @@ public class ExamSimulationStore
|
||||
return simulation;
|
||||
}
|
||||
|
||||
public static bool IsAlreadySubmittedState(string state) =>
|
||||
state is ExamSimulationStates.BetweenBlocks or ExamSimulationStates.Completed;
|
||||
|
||||
public async Task AbandonAsync(ExamSimulation simulation)
|
||||
{
|
||||
simulation.State = ExamSimulationStates.Abandoned;
|
||||
@@ -481,9 +433,7 @@ public class ExamSimulationStore
|
||||
{
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
|
||||
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
|
||||
SnapshotDifficulties(simulation, questionMap);
|
||||
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
|
||||
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
|
||||
simulation.State = ExamSimulationStates.Completed;
|
||||
simulation.CompletedUtc = DateTime.UtcNow;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
@@ -493,10 +443,7 @@ public class ExamSimulationStore
|
||||
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var choiceId = response?.ChoiceId ?? "";
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
item.QuestionExternalId, simulation.UserId, choiceId,
|
||||
choiceId == question.CorrectChoiceId, choiceId == "",
|
||||
item.PresentedChoiceIds, bankCycle: await _credits.GetBankCycleAsync(simulation.UserId));
|
||||
var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds);
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (!record.Inserted) continue;
|
||||
_metrics.AttemptCreated();
|
||||
@@ -521,71 +468,6 @@ public class ExamSimulationStore
|
||||
private Task ReplaceAsync(ExamSimulation simulation) =>
|
||||
_simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, simulation);
|
||||
|
||||
public async Task<ExamSimulationBackfillResult> BackfillScoringModelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<ExamSimulation>.Filter.Eq(item => item.State, ExamSimulationStates.Completed) &
|
||||
Builders<ExamSimulation>.Filter.Ne(item => item.ModelVersion, ExamSimulationScoring.ModelVersion);
|
||||
var simulations = await _simulations.Find(filter).ToListAsync(cancellationToken);
|
||||
var missingQuestionIds = new List<string>();
|
||||
var updated = 0;
|
||||
foreach (var simulation in simulations)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
|
||||
missingQuestionIds.AddRange(simulation.Items
|
||||
.Where(item => !questionMap.ContainsKey(item.QuestionExternalId))
|
||||
.Select(item => $"{simulation.Id}:{item.QuestionExternalId}"));
|
||||
SnapshotDifficulties(simulation, questionMap);
|
||||
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
|
||||
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
|
||||
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
await ReplaceAsync(simulation);
|
||||
updated++;
|
||||
}
|
||||
return new ExamSimulationBackfillResult(updated, missingQuestionIds);
|
||||
}
|
||||
|
||||
private async Task UpgradeScoringModelsAsync(IEnumerable<ExamSimulation> simulations)
|
||||
{
|
||||
foreach (var simulation in simulations)
|
||||
await UpgradeScoringModelAsync(simulation);
|
||||
}
|
||||
|
||||
private async Task<ExamSimulation> UpgradeScoringModelAsync(ExamSimulation simulation)
|
||||
{
|
||||
var needsDifficultySnapshot = simulation.Items.Any(item => item.QuestionDifficulty is < 1 or > 5);
|
||||
var needsScoreUpgrade = simulation.State == ExamSimulationStates.Completed &&
|
||||
simulation.ModelVersion != ExamSimulationScoring.ModelVersion;
|
||||
if (!needsDifficultySnapshot && !needsScoreUpgrade)
|
||||
return simulation;
|
||||
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
|
||||
SnapshotDifficulties(simulation, questionMap);
|
||||
if (simulation.State == ExamSimulationStates.Completed)
|
||||
{
|
||||
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
|
||||
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
|
||||
}
|
||||
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
await ReplaceAsync(simulation);
|
||||
return simulation;
|
||||
}
|
||||
|
||||
private static void SnapshotDifficulties(
|
||||
ExamSimulation simulation,
|
||||
IReadOnlyDictionary<string, Question> questions)
|
||||
{
|
||||
foreach (var item in simulation.Items)
|
||||
{
|
||||
questions.TryGetValue(item.QuestionExternalId, out var question);
|
||||
item.QuestionDifficulty = ExamSimulationScoring.ResolveDifficulty(item, question);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExamSimulationBackfillResult(int UpdatedCount, IReadOnlyList<string> MissingQuestionIds);
|
||||
|
||||
private static int BlockIndexForPosition(int position)
|
||||
{
|
||||
var consumed = 0;
|
||||
|
||||
@@ -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
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
.Ascending(a => a.UserId)
|
||||
.Ascending(a => a.BankCycle);
|
||||
.Ascending(a => a.UserId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||
firstAttemptIndex,
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
|
||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
@@ -39,14 +38,12 @@ public class QuestionAttemptStore
|
||||
bool isCorrect,
|
||||
bool isSkip = false,
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||
int durationSeconds = 0,
|
||||
int bankCycle = 0)
|
||||
int durationSeconds = 0)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
QuestionExternalId = questionExternalId,
|
||||
UserId = userId,
|
||||
BankCycle = bankCycle,
|
||||
ChoiceId = choiceId,
|
||||
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||
@@ -66,8 +63,7 @@ public class QuestionAttemptStore
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
var existing = await _col
|
||||
.Find(a => a.QuestionExternalId == questionExternalId &&
|
||||
a.UserId == userId && a.BankCycle == bankCycle)
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
|
||||
.FirstAsync();
|
||||
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
||||
}
|
||||
@@ -79,17 +75,11 @@ public class QuestionAttemptStore
|
||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null)
|
||||
{
|
||||
var filter = Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId);
|
||||
if (bankCycle.HasValue)
|
||||
filter &= Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value);
|
||||
return
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
|
||||
_col.Distinct(
|
||||
attempt => attempt.QuestionExternalId,
|
||||
filter)
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
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.Discipline)) return "discipline required";
|
||||
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);
|
||||
if (referenceValidation is not null) return referenceValidation;
|
||||
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)
|
||||
{
|
||||
if (references is null)
|
||||
|
||||
@@ -27,43 +27,14 @@ public class RatingStore
|
||||
public async Task<RatingProfileResponse> GetProfileAsync(string userId)
|
||||
{
|
||||
var rating = await GetOrCreateUserRatingAsync(userId);
|
||||
var (tierName, tierDivision) = GetTier(rating.OverallElo);
|
||||
return new RatingProfileResponse
|
||||
{
|
||||
OverallElo = rating.OverallElo,
|
||||
RatedQuestionCount = rating.RatedQuestionCount,
|
||||
CategoryElos = rating.CategoryElos,
|
||||
Tier = tierName,
|
||||
TierDivision = tierDivision
|
||||
CategoryElos = rating.CategoryElos
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var rating = await GetOrCreateUserRatingAsync(userId);
|
||||
|
||||
@@ -19,8 +19,7 @@ public sealed class UserProfileStore
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserCreditAccounts"
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB |
@@ -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 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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 |
@@ -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
|
||||
|
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
|
||||
|
Before Width: | Height: | Size: 1.0 MiB |
@@ -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
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -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
|
||||
|
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
|
||||
|
Before Width: | Height: | Size: 81 KiB |
@@ -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
|
||||
|
Before Width: | Height: | Size: 98 KiB |
@@ -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
|
||||
|
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
|
||||
|
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
|
||||
@@ -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.
|
||||
@@ -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/canvas_resize_policy=2
|
||||
html/focus_canvas_on_start=true
|
||||
html/experimental_virtual_keyboard=true
|
||||
html/experimental_virtual_keyboard=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}'"
|
||||
|
||||
@@ -13,13 +13,10 @@ config_version=5
|
||||
config/name="RanaZe: Step 3 Trainer"
|
||||
config/version="pre-alpha"
|
||||
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/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]
|
||||
|
||||
@@ -39,7 +36,6 @@ NotesService="*res://scripts/autoload/notes_service.gd"
|
||||
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
||||
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
||||
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
||||
CreditsService="*res://scripts/autoload/credits_service.gd"
|
||||
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
|
||||
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
||||
DonationService="*res://scripts/autoload/donation_service.gd"
|
||||
@@ -57,14 +53,14 @@ window/stretch/aspect="expand"
|
||||
|
||||
[rendering]
|
||||
|
||||
rendering_device/driver.windows="d3d12"
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
rendering_device/driver.windows="d3d12"
|
||||
textures/vram_compression/import_etc2_astc=true
|
||||
|
||||
[step3]
|
||||
|
||||
deployment_environment="uat"
|
||||
deployment_environment="development"
|
||||
questions_api_url="https://us3q.ranaze.com/api/questions/search"
|
||||
question_reports_api_url="https://us3q.ranaze.com/api/question-reports"
|
||||
app_feedback_api_url="https://us3q.ranaze.com/api/app-feedback"
|
||||
|
||||
@@ -28,61 +28,10 @@ var simulation_block_index := 0
|
||||
var simulation_total_blocks := 12
|
||||
var simulation_deadline_unix := 0.0
|
||||
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_MAX_TARGET_SECONDS := 150.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:
|
||||
@@ -256,26 +205,18 @@ func restore_simulation_responses(saved_responses: Array) -> void:
|
||||
"is_correct": false,
|
||||
"eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(),
|
||||
"flagged": bool(response.get("flagged", false)),
|
||||
"elapsed_seconds": int(response.get("elapsedSeconds", 0)),
|
||||
"is_submitted": _simulation_response_is_submitted(response)
|
||||
"elapsed_seconds": int(response.get("elapsedSeconds", 0))
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
func _simulation_response_is_submitted(response: Dictionary) -> bool:
|
||||
var submitted: Variant = response.get("isSubmitted", null)
|
||||
if submitted == null:
|
||||
return not str(response.get("choiceId", "")).is_empty()
|
||||
return bool(submitted)
|
||||
|
||||
|
||||
func get_current_question() -> Dictionary:
|
||||
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
|
||||
return {}
|
||||
return active_questions[current_index]
|
||||
|
||||
|
||||
func answer_current(choice_id: String, eliminated: Array, flagged: bool, is_submitted := false) -> Dictionary:
|
||||
func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
|
||||
var question := get_current_question()
|
||||
if question.is_empty():
|
||||
return {}
|
||||
@@ -292,7 +233,6 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool, is_subm
|
||||
answers[current_index] = answer
|
||||
if session_kind == "simulation":
|
||||
answer["is_correct"] = false
|
||||
answer["is_submitted"] = is_submitted
|
||||
answers[current_index] = answer
|
||||
session_changed.emit()
|
||||
return answer
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
uid://bi323du7o7dn2
|
||||
@@ -8,7 +8,6 @@ signal state_changed
|
||||
signal request_failed(message: String)
|
||||
signal outcome_changed
|
||||
signal history_changed
|
||||
signal exam_token_required
|
||||
|
||||
var api_url := DEFAULT_API_URL
|
||||
var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes"
|
||||
@@ -23,8 +22,6 @@ var is_loading := false
|
||||
var pending_action := ""
|
||||
var request_queue: Array = []
|
||||
var request_access_token := ""
|
||||
var last_failed_action := ""
|
||||
var submit_recovery_attempts := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -70,7 +67,7 @@ func start_next_block() -> void:
|
||||
_send("start", HTTPClient.METHOD_POST, "/%s/blocks/start" % str(simulation.get("id", "")))
|
||||
|
||||
|
||||
func save_response(question_id: String, choice_id: String, eliminated: Array, flagged: bool, elapsed_seconds: int, 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():
|
||||
return
|
||||
_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,
|
||||
"eliminatedChoiceIds": eliminated,
|
||||
"flagged": flagged,
|
||||
"elapsedSeconds": elapsed_seconds,
|
||||
"isSubmitted": is_submitted
|
||||
"elapsedSeconds": elapsed_seconds
|
||||
})
|
||||
|
||||
|
||||
func submit_block() -> void:
|
||||
if simulation.is_empty():
|
||||
return
|
||||
submit_recovery_attempts = 0
|
||||
_send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", "")))
|
||||
|
||||
|
||||
@@ -145,7 +140,6 @@ func _send_absolute(action: String, method: int, url: String, payload: Dictionar
|
||||
if err != OK:
|
||||
is_loading = false
|
||||
pending_action = ""
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation request could not be started.")
|
||||
|
||||
|
||||
@@ -157,30 +151,12 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_access_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
if _is_stale_submit_failure(action):
|
||||
return
|
||||
if _should_reconcile_submit_failure(action, response_code):
|
||||
submit_recovery_attempts += 1
|
||||
_send(
|
||||
"submit_reconcile",
|
||||
HTTPClient.METHOD_GET,
|
||||
"/%s" % str(simulation.get("id", "")).uri_encode())
|
||||
return
|
||||
var server_message := body.get_string_from_utf8().strip_edges()
|
||||
var error_parsed: Variant = JSON.parse_string(server_message)
|
||||
if typeof(error_parsed) == TYPE_DICTIONARY:
|
||||
var credit_status: Variant = error_parsed.get("credits", {})
|
||||
if typeof(credit_status) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_status(credit_status)
|
||||
if str(error_parsed.get("code", "")) == "exam_token_required":
|
||||
exam_token_required.emit()
|
||||
server_message = str(error_parsed.get("message", server_message))
|
||||
if server_message.length() > 240:
|
||||
server_message = server_message.left(240)
|
||||
var message := "Simulation request failed with status %d." % response_code
|
||||
if not server_message.is_empty():
|
||||
message = "%s %s" % [message, server_message]
|
||||
last_failed_action = action
|
||||
request_failed.emit(message)
|
||||
return
|
||||
if response_code == 204:
|
||||
@@ -193,7 +169,6 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation API returned an invalid response.")
|
||||
return
|
||||
if action == "readiness":
|
||||
@@ -215,25 +190,6 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
return
|
||||
simulation = _normalize_simulation(parsed)
|
||||
state_changed.emit()
|
||||
if action == "submit":
|
||||
submit_recovery_attempts = 0
|
||||
elif action == "submit_reconcile":
|
||||
if str(simulation.get("state", "")) == "block_active":
|
||||
_send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", "")))
|
||||
else:
|
||||
submit_recovery_attempts = 0
|
||||
|
||||
|
||||
func _is_stale_submit_failure(action: String) -> bool:
|
||||
return action == "submit" and str(simulation.get("state", "")) in ["between_blocks", "completed"]
|
||||
|
||||
|
||||
func _should_reconcile_submit_failure(action: String, response_code: int) -> bool:
|
||||
return (
|
||||
action == "submit" and
|
||||
response_code == 409 and
|
||||
submit_recovery_attempts == 0 and
|
||||
str(simulation.get("state", "")) == "block_active")
|
||||
|
||||
|
||||
func _process_request_queue() -> void:
|
||||
|
||||
@@ -130,14 +130,6 @@ func reset_qbank_memory() -> void:
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func reset_qbank_eligibility() -> void:
|
||||
_ensure_tracking_fields()
|
||||
profile["completed_question_ids"] = []
|
||||
profile["correct_question_ids"] = []
|
||||
save_profile()
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func apply_question_results(results: Dictionary) -> void:
|
||||
_ensure_daily_current()
|
||||
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
|
||||
|
||||
@@ -38,8 +38,6 @@ var load_state := "idle"
|
||||
var retry_attempt := 0
|
||||
var next_retry_seconds := 0
|
||||
var overall_elo := 1000
|
||||
var rank_tier := "Bronze"
|
||||
var rank_division := 3
|
||||
var rated_question_count := 0
|
||||
var category_elos := {}
|
||||
var last_rating_change := {}
|
||||
@@ -248,8 +246,6 @@ func clear_questions() -> void:
|
||||
retry_attempt = 0
|
||||
next_retry_seconds = 0
|
||||
overall_elo = 1000
|
||||
rank_tier = "Bronze"
|
||||
rank_division = 3
|
||||
rated_question_count = 0
|
||||
category_elos = {}
|
||||
last_rating_change = {}
|
||||
@@ -261,10 +257,10 @@ func clear_questions() -> void:
|
||||
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 == "":
|
||||
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:
|
||||
@@ -390,7 +386,7 @@ func _on_retry_timer_timeout() -> void:
|
||||
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()
|
||||
if account == "":
|
||||
return
|
||||
@@ -404,7 +400,6 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
|
||||
"choice_id": choice_id,
|
||||
"presented_choice_ids": presented_choice_ids.duplicate(),
|
||||
"duration_seconds": maxi(0, duration_seconds),
|
||||
"met_time_limit": met_time_limit,
|
||||
"retry_count": 0
|
||||
})
|
||||
_save_attempt_queue()
|
||||
@@ -437,8 +432,7 @@ func _process_attempt_queue() -> void:
|
||||
"questionExternalId": question_id,
|
||||
"choiceId": str(queued.get("choice_id", "")),
|
||||
"presentedChoiceIds": queued.get("presented_choice_ids", []),
|
||||
"durationSeconds": int(queued.get("duration_seconds", 0)),
|
||||
"metTimeLimit": bool(queued.get("met_time_limit", true))
|
||||
"durationSeconds": int(queued.get("duration_seconds", 0))
|
||||
})
|
||||
if str(queued.get("kind", "answer")) == "skip":
|
||||
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", {})
|
||||
_update_question_elo(question_id, question_elo)
|
||||
_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)
|
||||
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
|
||||
|
||||
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)))
|
||||
category_elos = parsed.get("categoryElos", parsed.get("category_elos", {}))
|
||||
ratings_changed.emit()
|
||||
|
||||
@@ -101,8 +101,8 @@ func refresh_theme_controls() -> void:
|
||||
return
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||||
display_label.add_theme_color_override("font_color", Color.WHITE)
|
||||
operation_label.add_theme_color_override("font_color", Brand.muted_color(true))
|
||||
display_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
operation_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
for button in keypad_buttons:
|
||||
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))
|
||||
|
||||
@@ -297,7 +297,7 @@ func _build_performance_summary(dark: bool) -> void:
|
||||
if combined != null:
|
||||
prediction_value = "%d" % int(combined)
|
||||
prediction_detail = "Range %d-%d" % [int(CCSService.prediction.get("rangeLow", 0)), int(CCSService.prediction.get("rangeHigh", 0))]
|
||||
_add_metric(row, "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:
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
uid://doisoxqnk1mf3
|
||||
@@ -99,13 +99,9 @@ func _draw() -> void:
|
||||
|
||||
static func point_value(point: Dictionary, series_mode: String) -> Variant:
|
||||
if series_mode == "accuracy":
|
||||
if int(point.get("total", 0)) > 0:
|
||||
return float(point.get("accuracy", 0))
|
||||
return null
|
||||
return float(point.get("accuracy", 0)) if int(point.get("total", 0)) > 0 else null
|
||||
var value: Variant = point.get("questionBankRating", null)
|
||||
if value == null:
|
||||
return null
|
||||
return float(value)
|
||||
return null if value == null else float(value)
|
||||
|
||||
|
||||
static func axis_bounds(series_points: Array, series_mode: String) -> Vector2:
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
uid://cnnscljyarxaa
|
||||
@@ -3,15 +3,12 @@ extends Control
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const TrendChart := preload("res://scripts/ui/dashboard_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 scroll: ScrollContainer
|
||||
var content: VBoxContainer
|
||||
var status_label: Label
|
||||
var stat_card_grid: GridContainer
|
||||
var metric_grid: GridContainer
|
||||
var analytics_grid: GridContainer
|
||||
var lower_grid: GridContainer
|
||||
var trend_chart: Control
|
||||
@@ -25,11 +22,6 @@ var recent_list: VBoxContainer
|
||||
var weekly_summary: VBoxContainer
|
||||
var metric_values: 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 review_queue_service: Variant
|
||||
var trend_mode := "rating"
|
||||
@@ -62,7 +54,6 @@ func _build() -> void:
|
||||
child.free()
|
||||
metric_values.clear()
|
||||
metric_details.clear()
|
||||
metric_trends.clear()
|
||||
scroll = ScrollContainer.new()
|
||||
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
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.visible = false
|
||||
content.add_child(status_label)
|
||||
stat_card_grid = GridContainer.new()
|
||||
stat_card_grid.columns = 4
|
||||
stat_card_grid.add_theme_constant_override("h_separation", 12)
|
||||
stat_card_grid.add_theme_constant_override("v_separation", 12)
|
||||
content.add_child(stat_card_grid)
|
||||
|
||||
qbank_sparkline = DashboardSparkline.new()
|
||||
qbank_sparkline.custom_minimum_size = Vector2(0, 34)
|
||||
_add_stat_card("qbank", "Question Bank Rating", "book-open", Brand.ROYAL_BLUE, qbank_sparkline,
|
||||
"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.")
|
||||
metric_grid = GridContainer.new()
|
||||
metric_grid.columns = 4
|
||||
metric_grid.add_theme_constant_override("h_separation", 0)
|
||||
metric_grid.add_theme_constant_override("v_separation", 8)
|
||||
content.add_child(_card(metric_grid, 12))
|
||||
_add_metric("qbank", "Question Bank Rating", "chart-no-axes-column-increasing", Brand.ROYAL_BLUE)
|
||||
_add_metric("ccs", "CCS Rating", "stethoscope", Brand.PURPLE)
|
||||
_add_metric("accuracy", "First-Attempt Accuracy", "activity", Brand.ROYAL_BLUE)
|
||||
_add_metric("time", "Study Time", "clock-3", Brand.PURPLE)
|
||||
|
||||
analytics_grid = GridContainer.new()
|
||||
analytics_grid.columns = 2
|
||||
@@ -192,15 +163,10 @@ func _rebuild(_is_dark: bool = UIThemeService.is_dark_mode()) -> void:
|
||||
func _render() -> void:
|
||||
var data: Dictionary = DashboardService.data
|
||||
status_label.visible = false
|
||||
var trend: Array = data.get("performanceTrend", [])
|
||||
_render_rating_card("qbank", qbank_sparkline, Brand.ROYAL_BLUE, data.get("questionBankRating", 1000),
|
||||
data.get("questionBankRatingDeltaThisWeek"), data.get("questionBankPercentile"), "%d rated questions" % int(data.get("ratedQuestionCount", 0)),
|
||||
_trend_series(trend, "questionBankRating"))
|
||||
_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)
|
||||
_set_metric("qbank", str(int(data.get("questionBankRating", 1000))), "%d rated questions" % int(data.get("ratedQuestionCount", 0)))
|
||||
_set_metric("ccs", str(int(data.get("ccsRating", 1000))), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)))
|
||||
_set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)), "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
||||
_set_metric("time", _format_duration(int(data.get("studyTimeSecondsThisWeek", 0))), "This week")
|
||||
trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
|
||||
_render_systems(data.get("systems", []))
|
||||
_render_focus(data.get("systems", []))
|
||||
@@ -211,71 +177,6 @@ func _render() -> void:
|
||||
_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:
|
||||
_clear(system_list)
|
||||
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.pressed.connect(_start_review_queue)
|
||||
review_list.add_child(missed_action)
|
||||
if Brand.is_uat_environment():
|
||||
var dynamic_due := int(data.get("dynamicReviewsDue", 0))
|
||||
review_list.add_child(_review_action(
|
||||
"%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"))
|
||||
if Brand.is_uat_environment():
|
||||
var flashcards_due := int(data.get("flashcardsDue", 0))
|
||||
review_list.add_child(_review_action(
|
||||
"%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"))
|
||||
var dynamic_due := int(data.get("dynamicReviewsDue", 0))
|
||||
review_list.add_child(_review_action(
|
||||
"%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"))
|
||||
var flashcards_due := int(data.get("flashcardsDue", 0))
|
||||
review_list.add_child(_review_action(
|
||||
"%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 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)
|
||||
|
||||
|
||||
func _info_badge(dark: bool, tooltip: String) -> Control:
|
||||
var badge := DashboardInfoBadge.new()
|
||||
badge.custom_minimum_size = Vector2(16, 16)
|
||||
badge.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
badge.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
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)
|
||||
func _add_metric(key: String, title: String, icon_name: String, accent: Color) -> void:
|
||||
var cell := HBoxContainer.new()
|
||||
cell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
cell.custom_minimum_size = Vector2(230, 96)
|
||||
cell.add_theme_constant_override("separation", 12)
|
||||
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.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()
|
||||
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.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon_panel.add_child(icon)
|
||||
header.add_child(icon_panel)
|
||||
var title_label := _label(title, 14, Brand.text_color(dark))
|
||||
title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
header.add_child(title_label)
|
||||
header.add_child(_info_badge(dark, info_tooltip))
|
||||
|
||||
var value := _label("--", 28, Brand.text_color(dark), true)
|
||||
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)
|
||||
|
||||
cell.add_child(icon_panel)
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
copy.add_child(_label(title, 15, Brand.text_color(UIThemeService.is_dark_mode())))
|
||||
var value := _label("--", 29, Brand.text_color(UIThemeService.is_dark_mode()), true)
|
||||
copy.add_child(value)
|
||||
var detail := _label("Loading", 13, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
copy.add_child(detail)
|
||||
metric_values[key] = value
|
||||
metric_trends[key] = trend
|
||||
metric_details[key] = caption
|
||||
stat_card_grid.add_child(_card(card_content, 16))
|
||||
metric_details[key] = detail
|
||||
cell.add_child(copy)
|
||||
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
|
||||
var trend_label: Label = metric_trends.get(key)
|
||||
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
|
||||
(metric_details.get(key) as Label).text = detail
|
||||
|
||||
|
||||
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:
|
||||
if stat_card_grid == null:
|
||||
if metric_grid == null:
|
||||
return
|
||||
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
|
||||
lower_grid.columns = 1 if compact else 2
|
||||
if trend_chart != null:
|
||||
@@ -681,6 +548,12 @@ func _clear(container: Container) -> void:
|
||||
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:
|
||||
var cleaned := iso_value.trim_suffix("Z")
|
||||
var occurred := Time.get_unix_time_from_datetime_string(cleaned)
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
uid://duq4hsl7b72g2
|
||||
@@ -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 +0,0 @@
|
||||
uid://4rtqkgrljvw5
|
||||
@@ -79,7 +79,7 @@ func _build_ui() -> void:
|
||||
mission.add_theme_constant_override("separation", 18)
|
||||
mission_panel.add_child(mission)
|
||||
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(_heading("What your support makes possible", 20))
|
||||
for item_text in [
|
||||
|
||||
@@ -25,9 +25,7 @@ var reset_view: VBoxContainer
|
||||
var steam_view: VBoxContainer
|
||||
var login_input: LineEdit
|
||||
var password_input: LineEdit
|
||||
var password_reveal_entries: Array[Dictionary] = []
|
||||
var legal_consent_texts: Array[RichTextLabel] = []
|
||||
var legal_consent_squares: Array[Panel] = []
|
||||
var password_reveal_button: Button
|
||||
var registration_username_input: LineEdit
|
||||
var registration_email_input: LineEdit
|
||||
var registration_password_input: LineEdit
|
||||
@@ -42,7 +40,6 @@ var steam_action: OptionButton
|
||||
var steam_username_input: LineEdit
|
||||
var steam_email_input: LineEdit
|
||||
var steam_password_input: LineEdit
|
||||
var steam_confirm_input: LineEdit
|
||||
var steam_legal_check: CheckBox
|
||||
var external_buttons: Dictionary = {}
|
||||
var panel_gutter: MarginContainer
|
||||
@@ -65,13 +62,6 @@ var compact_layout := 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:
|
||||
auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth")
|
||||
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.add_theme_constant_override("margin_left", 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_bottom", 28)
|
||||
page_margin.add_theme_constant_override("margin_top", 56)
|
||||
page_margin.add_theme_constant_override("margin_bottom", 56)
|
||||
add_child(page_margin)
|
||||
|
||||
var columns := HBoxContainer.new()
|
||||
@@ -158,7 +148,6 @@ func _build_ui() -> void:
|
||||
large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
brand_row.add_child(large_icon)
|
||||
var wordmark := VBoxContainer.new()
|
||||
wordmark.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
wordmark.add_theme_constant_override("separation", 4)
|
||||
brand_row.add_child(wordmark)
|
||||
var name_row := HBoxContainer.new()
|
||||
@@ -200,34 +189,23 @@ func _build_ui() -> void:
|
||||
panel_center.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
columns.add_child(panel_center)
|
||||
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)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
auth_panel.add_child(scroll)
|
||||
panel_gutter = MarginContainer.new()
|
||||
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)
|
||||
scroll.add_child(panel_gutter)
|
||||
panel_content = VBoxContainer.new()
|
||||
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_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()
|
||||
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()
|
||||
small_icon.texture = Brand.ranaze_mark_texture()
|
||||
small_icon.custom_minimum_size = Vector2(72, 72)
|
||||
@@ -251,14 +229,14 @@ func _build_ui() -> void:
|
||||
reset_view = _build_reset_view()
|
||||
steam_view = _build_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.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
message_label.add_theme_font_size_override("font_size", 14)
|
||||
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()
|
||||
legal_footer.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
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_password_input = _add_field(view, "Password", "At least 8 characters", true)
|
||||
registration_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true)
|
||||
_add_password_reveal(registration_password_input)
|
||||
_add_password_reveal(registration_confirm_input)
|
||||
_add_show_password(view, [registration_password_input, registration_confirm_input])
|
||||
registration_legal_check = _add_legal_consent(view)
|
||||
_add_primary_button(view, "Create Account", _on_register_pressed)
|
||||
_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_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)
|
||||
_add_password_reveal(reset_password_input)
|
||||
_add_password_reveal(reset_confirm_input)
|
||||
_add_show_password(reset_confirm_group, [reset_password_input, reset_confirm_input])
|
||||
_add_primary_button(reset_confirm_group, "Update Password", _confirm_reset)
|
||||
_add_back_button(view, "Back to sign in", "login")
|
||||
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_email_input = _add_field(view, "Email", "[email protected]")
|
||||
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_password_reveal(steam_password_input)
|
||||
_add_password_reveal(steam_confirm_input)
|
||||
_add_show_password(view, [steam_password_input])
|
||||
steam_legal_check = _add_legal_consent(view)
|
||||
_add_primary_button(view, "Continue", _complete_steam)
|
||||
_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:
|
||||
# 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()
|
||||
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)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_top", 8)
|
||||
margin.add_theme_constant_override("margin_bottom", 8)
|
||||
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)
|
||||
var links := HBoxContainer.new()
|
||||
links.add_theme_constant_override("separation", 12)
|
||||
parent.add_child(links)
|
||||
_add_legal_links(links)
|
||||
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:
|
||||
for spec in [["Terms of Service", "terms"], ["Privacy Policy", "privacy"]]:
|
||||
var link := Button.new()
|
||||
@@ -450,7 +357,7 @@ func _open_legal_document(kind: String) -> void:
|
||||
|
||||
func _new_form() -> VBoxContainer:
|
||||
var form := VBoxContainer.new()
|
||||
form.add_theme_constant_override("separation", 10)
|
||||
form.add_theme_constant_override("separation", 16)
|
||||
return form
|
||||
|
||||
|
||||
@@ -481,7 +388,7 @@ func _add_field(parent: VBoxContainer, label_text: String, placeholder: String,
|
||||
input.secret = secret
|
||||
input.caret_blink = true
|
||||
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_override("font", Brand.font)
|
||||
parent.add_child(input)
|
||||
@@ -489,9 +396,19 @@ func _add_field(parent: VBoxContainer, label_text: String, placeholder: String,
|
||||
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:
|
||||
var reveal := Button.new()
|
||||
password_reveal_entries.append({"input": input, "button": reveal})
|
||||
password_reveal_button = reveal
|
||||
reveal.text = ""
|
||||
reveal.tooltip_text = "Show password"
|
||||
reveal.flat = true
|
||||
@@ -570,9 +487,6 @@ func _update_responsive_layout() -> void:
|
||||
var layout_width := size.x
|
||||
if layout_width <= 0.0:
|
||||
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 mobile_web := false
|
||||
if OS.has_feature("web"):
|
||||
@@ -591,16 +505,7 @@ func _update_responsive_layout() -> void:
|
||||
theme_button.custom_minimum_size = Vector2(44, 44)
|
||||
theme_button.position = Vector2(-58, 14)
|
||||
theme_button.add_theme_font_size_override("font_size", 20)
|
||||
# 1000/1010 comfortably fits every view (login/register/reset/steam)
|
||||
# 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)
|
||||
auth_panel.custom_minimum_size = Vector2(640, 790) if compact_desktop else Vector2(710, 820)
|
||||
panel_gutter.add_theme_constant_override("margin_right", 28)
|
||||
_apply_compact_scale(panel_content, 1.0)
|
||||
_apply_theme()
|
||||
@@ -639,13 +544,7 @@ func _apply_theme() -> void:
|
||||
label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
for input in fields:
|
||||
_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):
|
||||
if button is CheckBox:
|
||||
continue
|
||||
var typed := button as Button
|
||||
typed.add_theme_font_override("font", Brand.font)
|
||||
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("hover", theme_normal)
|
||||
theme_button.add_theme_color_override("font_color", Brand.AMBER)
|
||||
for entry in password_reveal_entries:
|
||||
_refresh_password_reveal(entry["input"], entry["button"])
|
||||
if password_reveal_button != null:
|
||||
_refresh_password_reveal(password_input, password_reveal_button)
|
||||
subheading.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:
|
||||
@@ -858,8 +757,7 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body:
|
||||
"requires_account":
|
||||
poll_timer.stop()
|
||||
_close_steam_popup()
|
||||
var verified_email_value: Variant = data.get("email")
|
||||
external_verified_email = "" if verified_email_value == null else str(verified_email_value)
|
||||
external_verified_email = str(data.get("email", ""))
|
||||
steam_email_input.text = external_verified_email
|
||||
steam_email_input.editable = external_verified_email.is_empty()
|
||||
_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:
|
||||
_set_field_visible(steam_email_input, index == 0)
|
||||
_set_field_visible(steam_confirm_input, index == 0)
|
||||
if index != 0:
|
||||
steam_confirm_input.text = ""
|
||||
steam_email_input.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"
|
||||
|
||||
@@ -878,7 +773,7 @@ func _on_steam_action_changed(index: int) -> void:
|
||||
func _complete_steam() -> void:
|
||||
var action := "create" if steam_action.selected == 0 else "link"
|
||||
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 steam_legal_check.button_pressed:
|
||||
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...")
|
||||
|
||||
|
||||
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:
|
||||
_set_busy(false)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
||||
|
||||
@@ -3,7 +3,6 @@ extends Control
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
|
||||
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
|
||||
|
||||
var screens := {}
|
||||
var _account_profile_synced := false
|
||||
@@ -12,6 +11,7 @@ var header_title: Label
|
||||
var environment_badge: Label
|
||||
var app_menu: PanelContainer
|
||||
var menu_exit_button: Button
|
||||
var sound_toggle: CheckButton
|
||||
var theme_button: Button
|
||||
var feedback_button: Button
|
||||
var feedback_dialog: Control
|
||||
@@ -30,7 +30,6 @@ var drawer_open := false
|
||||
var menu_button: Button
|
||||
var account_button: Button
|
||||
var account_chevron_button: Button
|
||||
var credits_badge: PanelContainer
|
||||
var account_menu: PanelContainer
|
||||
var nav_buttons: Dictionary = {}
|
||||
var current_nav_key := "home"
|
||||
@@ -155,7 +154,7 @@ func _build_ui() -> void:
|
||||
environment_badge = Label.new()
|
||||
environment_badge.text = "UAT"
|
||||
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_size_override("font_size", 13)
|
||||
environment_badge.add_theme_color_override("font_color", Color("052e16"))
|
||||
@@ -206,10 +205,6 @@ func _build_ui() -> void:
|
||||
_style_account_chevron(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()
|
||||
screens_margin = screens_container
|
||||
screens_host = screens_container
|
||||
@@ -356,11 +351,6 @@ func _build_ui() -> void:
|
||||
donations.main = self
|
||||
screens_container.add_child(donations)
|
||||
screens["donations"] = donations
|
||||
var rz_store := preload("res://scripts/ui/rz_store_screen.gd").new()
|
||||
rz_store.name = "RzCoinStore"
|
||||
rz_store.main = self
|
||||
screens_container.add_child(rz_store)
|
||||
screens["rz_store"] = rz_store
|
||||
|
||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||
feedback_dialog.name = "AppFeedbackDialog"
|
||||
@@ -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, "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, "ccs", "CCS Cases", "stethoscope", true, Brand.is_uat_environment())
|
||||
_add_nav_button(container, "dynamic_questions", "Dynamic Questions", "activity", true)
|
||||
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", 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, "notes", "Notes", "notebook-pen", true)
|
||||
|
||||
@@ -439,12 +429,11 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
||||
container.add_child(HSeparator.new())
|
||||
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
|
||||
_add_nav_button(container, "performance", "Profile", "user-round", true)
|
||||
_add_nav_button(container, "rz_store", "RZ Shop", "star", true)
|
||||
_add_nav_button(container, "settings", "Settings", "settings", 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()
|
||||
button.text = label_text
|
||||
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.disabled = not enabled
|
||||
button.tooltip_text = "Coming Soon" if not enabled else label_text
|
||||
button.visible = visible_flag
|
||||
button.pressed.connect(_on_navigation_pressed.bind(key))
|
||||
container.add_child(button)
|
||||
if not nav_buttons.has(key):
|
||||
@@ -483,7 +471,6 @@ func _on_navigation_pressed(key: String) -> void:
|
||||
"notes": show_screen("notes")
|
||||
"bookmarks": show_screen("bookmarks")
|
||||
"donations": show_screen("donations")
|
||||
"rz_store": show_screen("rz_store")
|
||||
"logout": _logout()
|
||||
|
||||
|
||||
@@ -495,7 +482,6 @@ func _update_header_title(screen_name: String) -> void:
|
||||
"selection": "Question Bank",
|
||||
"dynamic_questions": "Dynamic Questions",
|
||||
"simulation_history": "Exam Simulations",
|
||||
"simulation_results": "Exam Simulations",
|
||||
"ccs_hub": "CCS Cases",
|
||||
"flashcards_hub": "Flashcards",
|
||||
"flashcards_study": "Flashcards",
|
||||
@@ -783,7 +769,6 @@ func _on_auth_changed() -> void:
|
||||
if not _account_profile_synced:
|
||||
_account_profile_synced = true
|
||||
AccountProfileService.refresh()
|
||||
CreditsService.refresh()
|
||||
else:
|
||||
_account_profile_synced = false
|
||||
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)
|
||||
if account_chevron_button != null:
|
||||
_style_account_chevron(account_chevron_button)
|
||||
if credits_badge != null:
|
||||
credits_badge.refresh_theme_controls()
|
||||
if sidebar != null:
|
||||
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
|
||||
if header_panel != null:
|
||||
@@ -871,7 +854,7 @@ func _build_app_menu() -> void:
|
||||
app_menu.visible = false
|
||||
app_menu.top_level = true
|
||||
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))
|
||||
add_child(app_menu)
|
||||
|
||||
@@ -893,27 +876,19 @@ func _build_app_menu() -> void:
|
||||
resume.pressed.connect(_close_app_menu)
|
||||
content.add_child(resume)
|
||||
|
||||
var profile_button := Button.new()
|
||||
profile_button.text = "Profile"
|
||||
profile_button.custom_minimum_size = Vector2(0, 48)
|
||||
Style.style_chip(profile_button, Style.CYAN)
|
||||
UISoundService.set_button_sound(profile_button, "open")
|
||||
profile_button.pressed.connect(_navigate_from_pause.bind("profile"))
|
||||
content.add_child(profile_button)
|
||||
|
||||
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)
|
||||
sound_toggle = CheckButton.new()
|
||||
sound_toggle.text = "UI Sounds"
|
||||
sound_toggle.button_pressed = UISoundService.are_sounds_enabled()
|
||||
sound_toggle.custom_minimum_size = Vector2(0, 44)
|
||||
UISoundService.set_button_sound(sound_toggle, "none")
|
||||
sound_toggle.toggled.connect(UISoundService.set_sounds_enabled)
|
||||
content.add_child(sound_toggle)
|
||||
|
||||
menu_exit_button = Button.new()
|
||||
menu_exit_button.text = "Exit Run"
|
||||
menu_exit_button.custom_minimum_size = Vector2(0, 48)
|
||||
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)
|
||||
|
||||
var logout := Button.new()
|
||||
@@ -953,14 +928,7 @@ func _close_app_menu() -> void:
|
||||
BlockSessionService.resume_current_question_timer()
|
||||
|
||||
|
||||
func _navigate_from_pause(target_screen: String) -> 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:
|
||||
func _exit_run() -> void:
|
||||
_close_app_menu()
|
||||
if current_screen_name == "ccs_player":
|
||||
var ccs_player = screens.get("ccs_player")
|
||||
|
||||
@@ -26,10 +26,6 @@ static func ranaze_mark_texture() -> Texture2D:
|
||||
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:
|
||||
return DARK_TEXT if dark else LIGHT_TEXT
|
||||
|
||||
|
||||
@@ -29,6 +29,4 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
||||
key = "bookmarks"
|
||||
elif screen_name == "donations":
|
||||
key = "donations"
|
||||
elif screen_name == "rz_store":
|
||||
key = "rz_store"
|
||||
return key
|
||||
|
||||
@@ -19,8 +19,6 @@ var outcome_consent: CheckBox
|
||||
var outcome_status: Label
|
||||
var outcome_delete: Button
|
||||
var connected_accounts_list: VBoxContainer
|
||||
var return_screen := ""
|
||||
var back_to_session_button: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -40,10 +38,7 @@ func _ready() -> void:
|
||||
_build()
|
||||
|
||||
|
||||
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()
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
_render()
|
||||
AccountProfileService.refresh()
|
||||
DashboardService.refresh()
|
||||
@@ -68,11 +63,6 @@ func _build() -> void:
|
||||
status_label = _label("", 13, Brand.muted_color(_dark()))
|
||||
status_label.visible = false
|
||||
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.columns = 2
|
||||
columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
@@ -97,7 +87,6 @@ func _build() -> void:
|
||||
right.add_child(_outcome_card())
|
||||
right.add_child(_connected_accounts_card())
|
||||
right.add_child(_account_actions_card())
|
||||
_refresh_back_to_session_visibility()
|
||||
_update_responsive()
|
||||
_render()
|
||||
|
||||
@@ -258,19 +247,19 @@ func _render() -> void:
|
||||
return
|
||||
var account: Dictionary = AccountProfileService.account
|
||||
var prefs: Dictionary = AccountProfileService.preferences
|
||||
var username := string_field(account, "username", AuthState.username)
|
||||
var display_name := string_field(account, "displayName").strip_edges()
|
||||
var username := str(account.get("username", AuthState.username))
|
||||
var display_name := str(account.get("displayName", "")).strip_edges()
|
||||
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]
|
||||
var role := string_field(account, "role", AuthState.role).capitalize()
|
||||
var created := _date_only(string_field(account, "createdUtc"))
|
||||
var role := str(account.get("role", AuthState.role)).capitalize()
|
||||
var created := _date_only(str(account.get("createdUtc", "")))
|
||||
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created]
|
||||
_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["stage"] as Label).text = _friendly(string_field(prefs, "preparationStage", "pre_dedicated"))
|
||||
(identity_values["exam"] as Label).text = _date_only(str(prefs.get("targetExamDate", "")), "Not scheduled")
|
||||
(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["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
|
||||
_set_metric("qbank", str(int(data.get("questionBankRating", QuestionBankService.overall_elo))))
|
||||
_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
|
||||
return
|
||||
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_status.text = "Saved score %d for %s." % [int(outcome_score.value), outcome_date.text]
|
||||
outcome_delete.visible = true
|
||||
@@ -363,12 +352,12 @@ func _open_profile_editor() -> void:
|
||||
var dialog := _dialog("Edit profile")
|
||||
var display := LineEdit.new()
|
||||
display.placeholder_text = "Display name"
|
||||
display.text = string_field(AccountProfileService.account, "displayName")
|
||||
display.text = str(AccountProfileService.account.get("displayName", ""))
|
||||
_style_field(display)
|
||||
dialog.add_field(display)
|
||||
var email := LineEdit.new()
|
||||
email.placeholder_text = "Email address"
|
||||
email.text = string_field(AccountProfileService.account, "email")
|
||||
email.text = str(AccountProfileService.account.get("email", ""))
|
||||
_style_field(email)
|
||||
dialog.add_field(email)
|
||||
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 exam := LineEdit.new()
|
||||
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)
|
||||
dialog.add_field(exam)
|
||||
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)
|
||||
var goal := OptionButton.new()
|
||||
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)))
|
||||
dialog.add_field(goal)
|
||||
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.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))))
|
||||
@@ -521,17 +510,6 @@ func _button(text: String, icon_name: String = "", primary: bool = false) -> But
|
||||
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:
|
||||
var button := _button(text, icon_name, false)
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
@@ -603,13 +581,8 @@ func _clear(container: Container) -> void:
|
||||
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:
|
||||
if value.is_empty(): return fallback
|
||||
if value.is_empty() or value == "<null>": return fallback
|
||||
return value.left(10)
|
||||
|
||||
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
uid://col1hauyr27rn
|
||||
@@ -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 +0,0 @@
|
||||
uid://cldpnvs2k6r3t
|
||||
@@ -4,17 +4,12 @@ const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const QUESTION_TEXT_SCALE_MIN := 0.85
|
||||
const QUESTION_TEXT_SCALE_MAX := 1.35
|
||||
const QUESTION_TEXT_SCALE_STEP := 0.1
|
||||
const CORE_BLOCK_SIZE_STEP := 5
|
||||
|
||||
var main: Control
|
||||
var theme_toggle: CheckButton
|
||||
var sound_toggle: CheckButton
|
||||
var followups_toggle: CheckButton
|
||||
var question_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:
|
||||
@@ -41,17 +36,6 @@ func _ready() -> void:
|
||||
subtitle.add_theme_color_override("font_color", Brand.SLATE)
|
||||
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()
|
||||
panel.custom_minimum_size = Vector2(520, 0)
|
||||
panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
@@ -73,16 +57,8 @@ func _ready() -> void:
|
||||
sound_toggle.toggled.connect(UISoundService.set_sounds_enabled)
|
||||
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("CCS case text size", "ccs"))
|
||||
content.add_child(_build_core_block_size_row())
|
||||
|
||||
var legal_panel := PanelContainer.new()
|
||||
legal_panel.custom_minimum_size = Vector2(520, 0)
|
||||
@@ -111,13 +87,9 @@ func _ready() -> void:
|
||||
_refresh_text_scale_labels()
|
||||
|
||||
|
||||
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()
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
theme_toggle.set_pressed_no_signal(UIThemeService.is_dark_mode())
|
||||
sound_toggle.set_pressed_no_signal(UISoundService.are_sounds_enabled())
|
||||
followups_toggle.set_pressed_no_signal(BlockSessionService.show_review_followups)
|
||||
_refresh_text_scale_labels()
|
||||
|
||||
|
||||
@@ -159,42 +131,6 @@ func _build_text_scale_row(label_text: String, kind: String) -> HBoxContainer:
|
||||
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:
|
||||
var current: float = AccountProfileService.preferences.get("questionTextScale", 1.0)
|
||||
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)
|
||||
if ccs_scale_value_label != null:
|
||||
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:
|
||||
@@ -225,12 +159,6 @@ func _open_legal_document(kind: String) -> void:
|
||||
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:
|
||||
theme_toggle.set_pressed_no_signal(is_dark)
|
||||
var panel := theme_toggle.get_parent().get_parent() as PanelContainer
|
||||
|
||||
@@ -137,8 +137,6 @@ func _connect_services() -> void:
|
||||
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
|
||||
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
|
||||
ExamSimulationService.request_failed.connect(_on_request_failed)
|
||||
if not ExamSimulationService.exam_token_required.is_connected(_on_exam_token_required):
|
||||
ExamSimulationService.exam_token_required.connect(_on_exam_token_required)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
@@ -540,11 +538,6 @@ func _on_request_failed(message: String) -> void:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_exam_token_required() -> void:
|
||||
pending_launch = false
|
||||
main.show_screen("rz_store")
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 940
|
||||
if compact_layout == use_compact:
|
||||
|
||||
@@ -1,283 +1,91 @@
|
||||
extends VBoxContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const CORRECT_ICON := preload("res://assets/icons/lucide/circle-check.svg")
|
||||
const PROBABILITY_ICON := preload("res://assets/icons/lucide/activity.svg")
|
||||
const SEEN_ICON := preload("res://assets/icons/lucide/eye.svg")
|
||||
const CATEGORY_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-increasing.svg")
|
||||
const REVIEW_ICON := preload("res://assets/icons/lucide/clipboard-list.svg")
|
||||
const CATEGORY_PALETTE := [Brand.ROYAL_BLUE, Brand.SUCCESS, Brand.AMBER, Brand.MAGENTA, Brand.SKY_BLUE]
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
|
||||
var main: Control
|
||||
var content: VBoxContainer
|
||||
var score_label: Label
|
||||
var summary_label: Label
|
||||
var category_list: VBoxContainer
|
||||
var current_simulation := {}
|
||||
var return_screen := "selection"
|
||||
var done_button: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_theme_constant_override("separation", 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()
|
||||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
content = VBoxContainer.new()
|
||||
var content := VBoxContainer.new()
|
||||
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)
|
||||
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:
|
||||
var wrapper: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
|
||||
var simulation: Dictionary = wrapper.get("simulation", wrapper) if not wrapper.is_empty() else ExamSimulationService.simulation
|
||||
return_screen = str(wrapper.get("return_screen", "selection"))
|
||||
done_button.text = "Return to simulation history" if return_screen == "simulation_history" else "Return to study modes"
|
||||
current_simulation = simulation
|
||||
_render()
|
||||
|
||||
|
||||
func _render() -> void:
|
||||
_clear(content)
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var score: Dictionary = current_simulation.get("score", {})
|
||||
content.add_child(_build_hero_card(dark, score))
|
||||
content.add_child(_build_stats_row(dark, score))
|
||||
content.add_child(_build_disclaimer(dark, score))
|
||||
content.add_child(_build_category_card(dark, score))
|
||||
content.add_child(_build_actions())
|
||||
|
||||
|
||||
func _build_hero_card(dark: bool, score: Dictionary) -> PanelContainer:
|
||||
var score: Dictionary = simulation.get("score", {})
|
||||
var predicted := int(score.get("predictedScore", 0))
|
||||
var low := int(score.get("rangeLow", 0))
|
||||
var high := int(score.get("rangeHigh", 0))
|
||||
|
||||
var 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 seen := int(score.get("seenCount", 0))
|
||||
row.add_child(_stat_card(dark, CORRECT_ICON, Brand.ROYAL_BLUE, "%d / %d" % [correct, total], "Correct"))
|
||||
row.add_child(_stat_card(dark, PROBABILITY_ICON, Brand.SUCCESS, "%d%%" % probability, "Heuristic probability of reaching 200+"))
|
||||
row.add_child(_stat_card(dark, SEEN_ICON, Brand.SKY_BLUE, str(seen), "Previously seen questions"))
|
||||
return row
|
||||
score_label.text = "Predicted Step 3 score: %d (%d-%d)" % [predicted, low, high]
|
||||
summary_label.text = "%d/%d correct | Estimated probability of reaching 200: %d%% | Previously seen questions: %d\nThis provisional educational estimate is not an official USMLE or NBME score and excludes CCS performance." % [
|
||||
int(score.get("correct", 0)), int(score.get("total", 0)), probability, int(score.get("seenCount", 0))]
|
||||
_clear(category_list)
|
||||
for category in score.get("categories", {}):
|
||||
var value: Dictionary = score["categories"][category]
|
||||
var label := Label.new()
|
||||
label.text = "%s: %d/%d" % [category, int(value.get("correct", 0)), int(value.get("total", 0))]
|
||||
Style.set_label(label, 16, Style.TEXT)
|
||||
category_list.add_child(label)
|
||||
|
||||
|
||||
func _stat_card(dark: bool, icon: Texture2D, accent: Color, value: String, caption: String) -> PanelContainer:
|
||||
var inner := HBoxContainer.new()
|
||||
inner.add_theme_constant_override("separation", 12)
|
||||
|
||||
var icon_panel := PanelContainer.new()
|
||||
icon_panel.custom_minimum_size = Vector2(44, 44)
|
||||
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 22, true))
|
||||
var icon_rect := TextureRect.new()
|
||||
icon_rect.texture = icon
|
||||
icon_rect.custom_minimum_size = Vector2(20, 20)
|
||||
icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon_rect.modulate = Brand.DARK_TEXT
|
||||
icon_panel.add_child(icon_rect)
|
||||
inner.add_child(icon_panel)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.add_theme_constant_override("separation", 2)
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
copy.add_child(_label(value, 20, Brand.text_color(dark), true))
|
||||
var caption_label := _label(caption, 12, Brand.muted_color(dark))
|
||||
caption_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.add_child(caption_label)
|
||||
inner.add_child(copy)
|
||||
|
||||
return _card(dark, inner)
|
||||
|
||||
|
||||
func _build_disclaimer(dark: bool, score: Dictionary) -> Label:
|
||||
var raw_accuracy := float(score.get("rawAccuracy", 0.0)) * 100.0
|
||||
var adjusted_accuracy := float(score.get("difficultyAdjustedAccuracy", 0.0)) * 100.0
|
||||
var label := _label(
|
||||
"%.1f%% raw accuracy · %.1f%% difficulty-adjusted accuracy\nThis transparent, unvalidated educational heuristic is not an official USMLE or NBME score and excludes CCS performance." % [raw_accuracy, adjusted_accuracy],
|
||||
12, Brand.muted_color(dark))
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
return label
|
||||
|
||||
|
||||
func _build_category_card(dark: bool, score: Dictionary) -> PanelContainer:
|
||||
var column := VBoxContainer.new()
|
||||
column.add_theme_constant_override("separation", 14)
|
||||
|
||||
var heading := HBoxContainer.new()
|
||||
heading.add_theme_constant_override("separation", 8)
|
||||
var heading_icon := TextureRect.new()
|
||||
heading_icon.texture = CATEGORY_ICON
|
||||
heading_icon.custom_minimum_size = Vector2(20, 20)
|
||||
heading_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
heading_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
heading_icon.modulate = Brand.ROYAL_BLUE
|
||||
heading.add_child(heading_icon)
|
||||
heading.add_child(_label("Performance by content area", 18, Brand.text_color(dark), true))
|
||||
column.add_child(heading)
|
||||
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 2
|
||||
grid.add_theme_constant_override("h_separation", 32)
|
||||
grid.add_theme_constant_override("v_separation", 14)
|
||||
var categories: Dictionary = score.get("categories", {})
|
||||
var index := 0
|
||||
for category_name in categories:
|
||||
var value: Dictionary = categories[category_name]
|
||||
grid.add_child(_category_row(dark, str(category_name), value, index))
|
||||
index += 1
|
||||
if categories.is_empty():
|
||||
column.add_child(_label("No content-area breakdown is available for this simulation.", 13, Brand.muted_color(dark)))
|
||||
func _return_from_results() -> void:
|
||||
if return_screen == "simulation_history":
|
||||
main.show_screen("simulation_history", "preserve")
|
||||
else:
|
||||
column.add_child(grid)
|
||||
|
||||
return _card(dark, column)
|
||||
|
||||
|
||||
func _category_row(dark: bool, category_name: String, value: Dictionary, index: int) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var accent: Color = CATEGORY_PALETTE[index % CATEGORY_PALETTE.size()]
|
||||
|
||||
var dot := PanelContainer.new()
|
||||
dot.custom_minimum_size = Vector2(10, 10)
|
||||
dot.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
dot.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 5, true))
|
||||
row.add_child(dot)
|
||||
|
||||
var correct := int(value.get("correct", 0))
|
||||
var total := int(value.get("total", 0))
|
||||
var percent := int(round(100.0 * float(correct) / float(total))) if total > 0 else 0
|
||||
|
||||
var title := _label(category_name, 13, Brand.text_color(dark))
|
||||
title.custom_minimum_size.x = 200
|
||||
title.clip_text = true
|
||||
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
row.add_child(title)
|
||||
|
||||
var bar := ProgressBar.new()
|
||||
bar.show_percentage = false
|
||||
bar.min_value = 0.0
|
||||
bar.max_value = 100.0
|
||||
bar.value = percent
|
||||
bar.custom_minimum_size = Vector2(90, 7)
|
||||
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
_style_progress(bar, accent, dark)
|
||||
row.add_child(bar)
|
||||
|
||||
var detail := _label("%d%% %d/%d" % [percent, correct, total], 12, accent if dark else accent.darkened(0.15))
|
||||
detail.custom_minimum_size.x = 76
|
||||
detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
row.add_child(detail)
|
||||
return row
|
||||
|
||||
|
||||
func _build_actions() -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
|
||||
var review_button := Button.new()
|
||||
review_button.text = "Review Questions"
|
||||
review_button.icon = REVIEW_ICON
|
||||
review_button.custom_minimum_size = Vector2(220, 52)
|
||||
review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
_style_gradient_button(review_button)
|
||||
review_button.pressed.connect(func(): main.show_screen("simulation_review", {"simulation": current_simulation, "return_screen": return_screen}))
|
||||
row.add_child(review_button)
|
||||
return row
|
||||
|
||||
|
||||
func _card(dark: bool, child: Control, accent: Color = Brand.ROYAL_BLUE, active: bool = false) -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var box := Brand.content_card_box(dark, active, accent)
|
||||
box.content_margin_left = 20
|
||||
box.content_margin_right = 20
|
||||
box.content_margin_top = 18
|
||||
box.content_margin_bottom = 18
|
||||
panel.add_theme_stylebox_override("panel", box)
|
||||
panel.add_child(child)
|
||||
return panel
|
||||
|
||||
|
||||
func _label(text: String, font_size: int, color: Color, _bold: bool = false) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", font_size)
|
||||
label.add_theme_color_override("font_color", color)
|
||||
return label
|
||||
|
||||
|
||||
func _style_progress(bar: ProgressBar, accent: Color, dark: bool) -> void:
|
||||
var background := StyleBoxFlat.new()
|
||||
background.bg_color = Color("26324a") if dark else Color("e2e8f0")
|
||||
background.set_corner_radius_all(4)
|
||||
var fill := StyleBoxFlat.new()
|
||||
fill.bg_color = accent
|
||||
fill.set_corner_radius_all(4)
|
||||
bar.add_theme_stylebox_override("background", background)
|
||||
bar.add_theme_stylebox_override("fill", fill)
|
||||
|
||||
|
||||
func _style_gradient_button(button: Button) -> void:
|
||||
Brand.apply_font(button)
|
||||
button.add_theme_font_size_override("font_size", 15)
|
||||
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
|
||||
button.add_theme_stylebox_override("hover", Brand.gradient_button_box())
|
||||
button.add_theme_stylebox_override("pressed", Brand.gradient_button_box())
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
main.show_screen("selection", "step3")
|
||||
|
||||
|
||||
func _clear(container: Control) -> void:
|
||||
for child in container.get_children():
|
||||
container.remove_child(child)
|
||||
child.free()
|
||||
child.queue_free()
|
||||
|
||||
@@ -187,19 +187,18 @@ func _build_mode_view() -> void:
|
||||
retry_button = question_card.retry
|
||||
retry_button.pressed.connect(_retry_question_bank)
|
||||
|
||||
if Brand.is_uat_environment():
|
||||
var ccs_card := _add_mode_card(
|
||||
"CCS Cases",
|
||||
"Interactive case simulations",
|
||||
"Available",
|
||||
"Available",
|
||||
"Practice real-world clinical scenarios and\nimprove your management skills.",
|
||||
"stethoscope",
|
||||
true,
|
||||
Brand.TEAL)
|
||||
ccs_button = ccs_card.action
|
||||
ccs_status_label = ccs_card.status
|
||||
ccs_button.pressed.connect(_select_ccs)
|
||||
var ccs_card := _add_mode_card(
|
||||
"CCS Cases",
|
||||
"Interactive case simulations",
|
||||
"Available",
|
||||
"Available",
|
||||
"Practice real-world clinical scenarios and\nimprove your management skills.",
|
||||
"stethoscope",
|
||||
true,
|
||||
Brand.TEAL)
|
||||
ccs_button = ccs_card.action
|
||||
ccs_status_label = ccs_card.status
|
||||
ccs_button.pressed.connect(_select_ccs)
|
||||
|
||||
|
||||
func _build_question_bank_launch_view() -> void:
|
||||
|
||||
@@ -9,10 +9,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestionsApi", "api\Questio
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestionsApi.Tests", "api\QuestionsApi.Tests\QuestionsApi.Tests.csproj", "{1897C6D6-452D-4142-BCCC-FA1A46E7C869}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -66,6 +50,5 @@ Global
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC} = {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
|
||||
EndGlobal
|
||||
|
||||