Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67afd6bd59 | ||
|
|
012d0cf196 | ||
|
|
fe54f7b417 | ||
|
|
be2d542cb6 | ||
|
|
53b85942d3 | ||
|
|
5488dec2c7 | ||
|
|
9709bab9a9 | ||
|
|
a08d0d9bf9 | ||
|
|
be144b272a | ||
|
|
778da32e73 | ||
|
|
ae6b37eb7d | ||
|
|
d5048c155a | ||
|
|
e5f29f0f38 | ||
|
|
c63227fcf5 | ||
|
|
9f150baecc | ||
|
|
94de696af1 | ||
|
|
98518e8d39 | ||
|
|
fa5df8795f | ||
|
|
9402a5d6de | ||
|
|
a58aa401c8 | ||
|
|
bf56f4192f | ||
|
|
18802f4e8d | ||
|
|
0676c7f95e | ||
|
|
09502944ec | ||
|
|
3fd670d111 | ||
|
|
0bd74fcabf | ||
|
|
1bc7fbfb3c | ||
|
|
bcb411bed8 | ||
|
|
1860e6ede6 | ||
|
|
4909cf0b71 | ||
|
|
a162ce31c6 | ||
|
|
19371b7a93 | ||
|
|
da641a8d1d | ||
|
|
f39bfb42be | ||
|
|
c14ac1d65b | ||
|
|
f082be38e0 | ||
|
|
845907e825 | ||
|
|
89dbe3e31a | ||
|
|
dd203b24f9 | ||
|
|
6f41906101 | ||
|
|
efae7e0b2b | ||
|
|
05a5298ed1 | ||
|
|
b511d27a20 | ||
|
|
22b0df7aed | ||
|
|
63fa073530 | ||
|
|
41731f5804 | ||
|
|
e3e18f6415 | ||
|
|
9f836c8765 | ||
|
|
86ae650272 | ||
|
|
23d573e221 | ||
|
|
548b519d44 | ||
|
|
1ff2d612a4 | ||
|
|
c0252a916d | ||
|
|
81e543fdf5 | ||
|
|
ca218a6ae5 | ||
|
|
7fb5f8fe78 | ||
|
|
ee57dbc3e7 |
@@ -9,7 +9,6 @@ set -euo pipefail
|
||||
: "${INTERNAL_PURGE_SECRET:?INTERNAL_PURGE_SECRET is required}"
|
||||
: "${STRIPE_SECRET_KEY:?STRIPE_SECRET_KEY is required}"
|
||||
: "${STRIPE_WEBHOOK_SECRET:?STRIPE_WEBHOOK_SECRET is required}"
|
||||
: "${STRIPE_ACCESS_WEBHOOK_SECRET:?STRIPE_ACCESS_WEBHOOK_SECRET is required}"
|
||||
|
||||
NODES="${NODES:-192.168.86.72 192.168.86.73 192.168.86.74}"
|
||||
BUILD_TAG="$(git rev-parse HEAD)"
|
||||
@@ -58,7 +57,6 @@ kubectl create secret generic step3-runtime-secrets \
|
||||
--from-literal=purge-secret="${INTERNAL_PURGE_SECRET}" \
|
||||
--from-literal=stripe-secret-key="${STRIPE_SECRET_KEY}" \
|
||||
--from-literal=stripe-webhook-secret="${STRIPE_WEBHOOK_SECRET}" \
|
||||
--from-literal=stripe-access-webhook-secret="${STRIPE_ACCESS_WEBHOOK_SECRET}" \
|
||||
--from-literal=google-client-id="${GOOGLE_CLIENT_ID:-}" \
|
||||
--from-literal=google-client-secret="${GOOGLE_CLIENT_SECRET:-}" \
|
||||
--dry-run=client -o yaml | kubectl apply -n "${KUBE_NAMESPACE}" -f -
|
||||
|
||||
@@ -41,7 +41,6 @@ jobs:
|
||||
INTERNAL_PURGE_SECRET: ${{ secrets.PRD_INTERNAL_PURGE_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.PRD_STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_ACCESS_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_ACCESS_WEBHOOK_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.PRD_GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.PRD_GOOGLE_CLIENT_SECRET }}
|
||||
run: bash .gitea/scripts/deploy-environment.sh
|
||||
|
||||
@@ -42,7 +42,6 @@ jobs:
|
||||
INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.UAT_STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_ACCESS_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_ACCESS_WEBHOOK_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.UAT_GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.UAT_GOOGLE_CLIENT_SECRET }}
|
||||
run: bash .gitea/scripts/deploy-environment.sh
|
||||
|
||||
@@ -18,20 +18,10 @@ The existing `KUBECONFIG` secret is shared. Configure these environment-specific
|
||||
- `UAT_MONGODB_CONNECTION_STRING` and `PRD_MONGODB_CONNECTION_STRING`
|
||||
- `UAT_AUTH_JWT_KEY` and `PRD_AUTH_JWT_KEY` (different values)
|
||||
- `UAT_INTERNAL_PURGE_SECRET` and `PRD_INTERNAL_PURGE_SECRET`
|
||||
- `UAT_STRIPE_SECRET_KEY`, `UAT_STRIPE_WEBHOOK_SECRET`, and
|
||||
`UAT_STRIPE_ACCESS_WEBHOOK_SECRET` (Stripe sandbox)
|
||||
- `PRD_STRIPE_SECRET_KEY`, `PRD_STRIPE_WEBHOOK_SECRET`, and
|
||||
`PRD_STRIPE_ACCESS_WEBHOOK_SECRET` (Stripe live mode)
|
||||
- `UAT_STRIPE_SECRET_KEY` and `UAT_STRIPE_WEBHOOK_SECRET` (Stripe sandbox)
|
||||
- `PRD_STRIPE_SECRET_KEY` and `PRD_STRIPE_WEBHOOK_SECRET` (Stripe live mode)
|
||||
|
||||
Configure four independent Stripe webhook endpoints:
|
||||
|
||||
- UAT donations: `https://us3q.ranaze.com/api/donations/stripe-webhook`
|
||||
- UAT access: `https://us3q.ranaze.com/api/access/stripe-webhook`
|
||||
- PRD donations: `https://s3q.ranaze.com/api/donations/stripe-webhook`
|
||||
- PRD access: `https://s3q.ranaze.com/api/access/stripe-webhook`
|
||||
|
||||
Each endpoint has its own signing secret. UAT uses Stripe test mode and PRD uses
|
||||
Stripe live mode. No Stripe publishable key is required for hosted Checkout.
|
||||
Use independent Stripe webhook endpoints for `https://us3q.ranaze.com/api/donations/stripe-webhook` and `https://s3q.ranaze.com/api/donations/stripe-webhook`. No Stripe publishable key is required for hosted Checkout.
|
||||
|
||||
## Initial UAT database migration
|
||||
|
||||
@@ -77,17 +67,33 @@ The hostname change creates a new browser origin, so testers should expect to si
|
||||
db.Users.updateOne({Username: "SUPER"}, {$set: {Role: "SUPER"}})
|
||||
```
|
||||
|
||||
7. Configure and test both live Stripe webhooks and the Steam callback before
|
||||
announcing PRD.
|
||||
7. Configure and test the live Stripe webhook and Steam callback before announcing PRD.
|
||||
|
||||
## Question 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. 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 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -4,11 +4,6 @@ Environment deployment, database migration, and UAT-to-PRD promotion procedures
|
||||
|
||||
Godot-based USMLE Step 3 study trainer with a bundled ASP.NET Core Questions API.
|
||||
|
||||
The gated process for publishing the first Windows alpha through Steam is in
|
||||
[docs/STEAM_ALPHA_RELEASE_CHECKLIST.md](docs/STEAM_ALPHA_RELEASE_CHECKLIST.md).
|
||||
The free-access limits, earned-credit rules, paid tokens, and checkout setup are
|
||||
documented in [docs/ACCESS_ECONOMY.md](docs/ACCESS_ECONOMY.md).
|
||||
|
||||
## Local API
|
||||
|
||||
Run the Questions API:
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
using QuestionsApi.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class AccessCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void FreeEconomy_HasTheReleaseLimits()
|
||||
{
|
||||
Assert.Equal(20, AccessCatalog.FreeQuestionLimit);
|
||||
Assert.Equal(20, AccessCatalog.CreditEarningLimit);
|
||||
Assert.Equal(100, AccessCatalog.DayPassCreditCost);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("access_day_1", 100, AccessGrant.DayToken)]
|
||||
[InlineData("access_week_1", 500, AccessGrant.WeekToken)]
|
||||
[InlineData("access_month_1", 2000, AccessGrant.MonthToken)]
|
||||
[InlineData("exam_token_1", 199, AccessGrant.ExamToken)]
|
||||
[InlineData("qbank_reset_1", 499, AccessGrant.ResetToken)]
|
||||
public void Products_HaveServerControlledPrices(string sku, long cents, string grant)
|
||||
{
|
||||
var product = AccessCatalog.Products[sku];
|
||||
|
||||
Assert.Equal(cents, product.AmountCents);
|
||||
Assert.Equal("usd", product.Currency);
|
||||
Assert.Equal(grant, product.Grant);
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,45 @@ 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)]
|
||||
|
||||
@@ -49,6 +49,44 @@ public sealed class ContentPromotionTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,104 @@ public sealed class ExamSimulationTests
|
||||
Assert.Equal(0, failing.Correct);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 50)]
|
||||
[InlineData(20, 100)]
|
||||
[InlineData(60, 200)]
|
||||
[InlineData(100, 300)]
|
||||
public void NeutralDifficultyUsesTransparentScoreAnchors(int correctCount, int expectedScore)
|
||||
{
|
||||
var items = BuildItems(100);
|
||||
var questions = BuildQuestions(100);
|
||||
var responses = items.Take(correctCount).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var score = ExamSimulationScoring.Calculate(items, responses, questions);
|
||||
|
||||
Assert.Equal(expectedScore, score.PredictedScore);
|
||||
Assert.Equal(correctCount / 100.0, score.RawAccuracy, 4);
|
||||
Assert.Equal(correctCount / 100.0, score.DifficultyAdjustedAccuracy, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeenItemsAreReportedAndCarryLessScoringWeight()
|
||||
public void CorrectHarderQuestionsProduceAHigherAdjustedPrediction()
|
||||
{
|
||||
var questions = BuildQuestions(10);
|
||||
var hardCorrect = BuildItems(10);
|
||||
var easyCorrect = BuildItems(10);
|
||||
for (var index = 0; index < 10; index++)
|
||||
{
|
||||
hardCorrect[index].QuestionDifficulty = index < 5 ? 5 : 1;
|
||||
easyCorrect[index].QuestionDifficulty = index < 5 ? 1 : 5;
|
||||
}
|
||||
var responses = hardCorrect.Take(5).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var hardScore = ExamSimulationScoring.Calculate(hardCorrect, responses, questions);
|
||||
var easyScore = ExamSimulationScoring.Calculate(easyCorrect, responses, questions);
|
||||
|
||||
Assert.Equal(hardScore.RawAccuracy, easyScore.RawAccuracy);
|
||||
Assert.True(hardScore.DifficultyAdjustedAccuracy > easyScore.DifficultyAdjustedAccuracy);
|
||||
Assert.True(hardScore.PredictedScore > easyScore.PredictedScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmpiricalQuestionEloDoesNotAffectPrediction()
|
||||
{
|
||||
var questions = BuildQuestions(20);
|
||||
var lowElos = BuildItems(20);
|
||||
var highElos = BuildItems(20);
|
||||
foreach (var item in lowElos) item.QuestionElo = 100;
|
||||
foreach (var item in highElos) item.QuestionElo = 1900;
|
||||
var responses = lowElos.Take(10).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var lowEloScore = ExamSimulationScoring.Calculate(lowElos, responses, questions);
|
||||
var highEloScore = ExamSimulationScoring.Calculate(highElos, responses, questions);
|
||||
|
||||
Assert.Equal(lowEloScore.PredictedScore, highEloScore.PredictedScore);
|
||||
Assert.Equal(lowEloScore.PassProbability, highEloScore.PassProbability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkippedAndUnansweredQuestionsCountAsIncorrect()
|
||||
{
|
||||
var items = BuildItems(10);
|
||||
var questions = BuildQuestions(10);
|
||||
var responses = new Dictionary<string, ExamSimulationResponse>
|
||||
{
|
||||
[items[0].QuestionExternalId] = new() { QuestionExternalId = items[0].QuestionExternalId, ChoiceId = "A" },
|
||||
[items[1].QuestionExternalId] = new() { QuestionExternalId = items[1].QuestionExternalId, ChoiceId = "" }
|
||||
};
|
||||
|
||||
var score = ExamSimulationScoring.Calculate(items, responses, questions);
|
||||
|
||||
Assert.Equal(1, score.Correct);
|
||||
Assert.Equal(10, score.Total);
|
||||
Assert.Equal(0.1, score.RawAccuracy, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LowAccuracyFullLengthSimulationIsNotInflated()
|
||||
{
|
||||
var items = BuildItems(232);
|
||||
var questions = BuildQuestions(232);
|
||||
var responses = items.Take(49).ToDictionary(
|
||||
item => item.QuestionExternalId,
|
||||
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
|
||||
|
||||
var score = ExamSimulationScoring.Calculate(items, responses, questions);
|
||||
|
||||
Assert.Equal(103, score.PredictedScore);
|
||||
Assert.True(score.PassProbability < 0.001);
|
||||
Assert.True(score.RangeHigh < 130);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeenItemsAreReportedButDoNotChangeScoring()
|
||||
{
|
||||
var questions = BuildQuestions(20);
|
||||
var unseen = BuildItems(20);
|
||||
@@ -59,7 +155,9 @@ public sealed class ExamSimulationTests
|
||||
|
||||
Assert.Equal(0, unseenScore.SeenCount);
|
||||
Assert.Equal(20, seenScore.SeenCount);
|
||||
Assert.True(unseenScore.AbilityElo > seenScore.AbilityElo);
|
||||
Assert.Equal(unseenScore.PredictedScore, seenScore.PredictedScore);
|
||||
Assert.Null(unseenScore.AbilityElo);
|
||||
Assert.Null(seenScore.AbilityElo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -111,11 +209,64 @@ public sealed class ExamSimulationTests
|
||||
Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExamSimulationStates.BetweenBlocks, true)]
|
||||
[InlineData(ExamSimulationStates.Completed, true)]
|
||||
[InlineData(ExamSimulationStates.BlockActive, false)]
|
||||
[InlineData(ExamSimulationStates.Abandoned, false)]
|
||||
public void RepeatedBlockSubmissionIsIdempotentOnlyAfterSubmission(string state, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, ExamSimulationStore.IsAlreadySubmittedState(state));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmittedSimulationResponsesAreLocked()
|
||||
{
|
||||
Assert.True(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = true }));
|
||||
Assert.False(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = false }));
|
||||
Assert.True(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = null }));
|
||||
Assert.False(ExamSimulationStore.IsResponseLocked(
|
||||
new ExamSimulationResponse { ChoiceId = "", IsSubmitted = null }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubmittedResponseRetriesMustPreserveAnswerContent()
|
||||
{
|
||||
var response = new ExamSimulationResponse
|
||||
{
|
||||
ChoiceId = "B",
|
||||
EliminatedChoiceIds = ["A"],
|
||||
Flagged = true,
|
||||
IsSubmitted = true
|
||||
};
|
||||
var same = new SaveSimulationResponseRequest
|
||||
{
|
||||
ChoiceId = "B",
|
||||
EliminatedChoiceIds = ["A"],
|
||||
Flagged = true,
|
||||
IsSubmitted = true
|
||||
};
|
||||
var changed = new SaveSimulationResponseRequest
|
||||
{
|
||||
ChoiceId = "C",
|
||||
EliminatedChoiceIds = ["A"],
|
||||
Flagged = true,
|
||||
IsSubmitted = true
|
||||
};
|
||||
|
||||
Assert.True(ExamSimulationStore.ResponseContentMatches(response, same, ["A", "B", "C"]));
|
||||
Assert.False(ExamSimulationStore.ResponseContentMatches(response, changed, ["A", "B", "C"]));
|
||||
}
|
||||
|
||||
private static List<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
|
||||
Enumerable.Range(0, count).Select(index => new ExamSimulationItem
|
||||
{
|
||||
QuestionExternalId = $"question-{index}",
|
||||
QuestionElo = 1000,
|
||||
QuestionDifficulty = 3,
|
||||
WasSeen = wasSeen,
|
||||
BlueprintCategory = "Cardiovascular"
|
||||
}).ToList();
|
||||
@@ -123,5 +274,5 @@ public sealed class ExamSimulationTests
|
||||
private static Dictionary<string, Question> BuildQuestions(int count) =>
|
||||
Enumerable.Range(0, count).ToDictionary(
|
||||
index => $"question-{index}",
|
||||
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A" });
|
||||
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A", Difficulty = 3 });
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/access")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class AccessController(
|
||||
AccessStore access,
|
||||
ExamSimulationStore simulations,
|
||||
RatingStore ratings,
|
||||
TimeProvider clock) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get() =>
|
||||
Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
|
||||
|
||||
[HttpPost("redeem-credits")]
|
||||
public async Task<IActionResult> RedeemCredits(RedeemCreditsRequest request)
|
||||
{
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
var status = await access.RedeemCreditsAsync(UserId(), request.OperationId.Trim());
|
||||
return status is null
|
||||
? Conflict(new { code = "insufficient_credits", message = "100 credits are required to redeem a day pass." })
|
||||
: Ok(status);
|
||||
}
|
||||
|
||||
[HttpPost("activate-token")]
|
||||
public async Task<IActionResult> ActivateToken(ActivateAccessTokenRequest request)
|
||||
{
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
if (request.Token.Trim().ToLowerInvariant() is not ("day" or "week" or "month"))
|
||||
return BadRequest("token must be day, week, or month");
|
||||
var status = await access.ActivateTokenAsync(UserId(), request.OperationId.Trim(), request.Token);
|
||||
return status is null
|
||||
? Conflict(new { code = "token_unavailable", message = "That access token is not available." })
|
||||
: Ok(status);
|
||||
}
|
||||
|
||||
[HttpPost("qbank-reset")]
|
||||
public async Task<IActionResult> ResetQBank(ResetQBankRequest request)
|
||||
{
|
||||
if (request.Confirmation != "RESET_QBANK") return BadRequest("Type RESET_QBANK to confirm.");
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
if (await simulations.FindActiveAsync(UserId()) is not null)
|
||||
return Conflict(new { code = "active_exam", message = "Finish or abandon the active exam simulation before resetting the QBank." });
|
||||
var cycle = await access.ApplyResetAsync(UserId(), request.OperationId.Trim());
|
||||
if (cycle is null)
|
||||
return Conflict(new { code = "reset_token_unavailable", message = "A QBank reset token is required." });
|
||||
await ratings.ResetUserAsync(UserId(), clock.GetUtcNow().UtcDateTime);
|
||||
return Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
|
||||
}
|
||||
|
||||
[HttpPost("steam-checkouts")]
|
||||
public IActionResult SteamCheckout() => Problem(
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Steam Wallet checkout is not configured",
|
||||
detail: "Steam purchases will be enabled after the Steamworks application and product catalog are configured.");
|
||||
|
||||
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.Trim().Length <= 100;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/access")]
|
||||
public sealed class AccessPurchasesController(
|
||||
AccessPurchaseStore purchases,
|
||||
AccessStore access,
|
||||
IStripeAccessGateway stripe,
|
||||
IConfiguration configuration,
|
||||
ILogger<AccessPurchasesController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("web-checkouts")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> CreateCheckout(CreateAccessCheckoutRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!AccessCatalog.Products.TryGetValue(request.Sku.Trim(), out var product))
|
||||
return BadRequest("Unknown access product.");
|
||||
var purchase = await purchases.CreateAsync(UserId(), product);
|
||||
try
|
||||
{
|
||||
var checkout = await stripe.CreateCheckoutAsync(purchase, product, cancellationToken);
|
||||
await purchases.AttachSessionAsync(purchase.Id!, checkout.SessionId);
|
||||
return StatusCode(StatusCodes.Status201Created, new AccessCheckoutResponse
|
||||
{
|
||||
PurchaseId = purchase.Id!,
|
||||
CheckoutUrl = checkout.CheckoutUrl,
|
||||
Sku = product.Sku,
|
||||
AmountCents = product.AmountCents
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await purchases.MarkCheckoutFailedAsync(purchase.Id!);
|
||||
logger.LogError(exception, "Unable to create access checkout {PurchaseId}", purchase.Id);
|
||||
return Problem(statusCode: StatusCodes.Status502BadGateway,
|
||||
title: "Access checkout unavailable",
|
||||
detail: "The secure checkout could not be started. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("purchases/{id}")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> GetPurchase(string id) =>
|
||||
await purchases.GetOwnedAsync(id.Trim(), UserId()) is { } purchase
|
||||
? Ok(AccessPurchaseStore.ToStatus(purchase))
|
||||
: NotFound();
|
||||
|
||||
[HttpPost("stripe-webhook")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> StripeWebhook(CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookSecret = configuration["Stripe:AccessWebhookSecret"];
|
||||
if (string.IsNullOrWhiteSpace(webhookSecret))
|
||||
return Problem(statusCode: 503, title: "Stripe webhook is not configured");
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = await reader.ReadToEndAsync(cancellationToken);
|
||||
Event stripeEvent;
|
||||
try
|
||||
{
|
||||
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], webhookSecret);
|
||||
}
|
||||
catch (StripeException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Rejected access webhook with an invalid signature");
|
||||
return BadRequest("Invalid Stripe signature");
|
||||
}
|
||||
if (stripeEvent.Data.Object is not Session session) return Ok();
|
||||
var status = stripeEvent.Type switch
|
||||
{
|
||||
"checkout.session.completed" when session.PaymentStatus == "paid" => AccessPurchaseStatus.Paid,
|
||||
"checkout.session.async_payment_succeeded" => AccessPurchaseStatus.Paid,
|
||||
"checkout.session.async_payment_failed" => AccessPurchaseStatus.Failed,
|
||||
"checkout.session.expired" => AccessPurchaseStatus.Expired,
|
||||
_ => ""
|
||||
};
|
||||
if (status.Length == 0) return Ok();
|
||||
var purchaseId = session.ClientReferenceId;
|
||||
if (string.IsNullOrWhiteSpace(purchaseId) &&
|
||||
session.Metadata.TryGetValue("access_purchase_id", out var metadataId))
|
||||
purchaseId = metadataId;
|
||||
var purchase = await purchases.ApplyProviderStatusAsync(
|
||||
purchaseId ?? "", session.Id, session.AmountTotal ?? 0, session.Currency ?? "",
|
||||
status, session.PaymentIntentId ?? "", stripeEvent.Id);
|
||||
if (purchase is null) return Ok();
|
||||
if (status == AccessPurchaseStatus.Paid &&
|
||||
AccessCatalog.Products.TryGetValue(purchase.Sku, out var product))
|
||||
{
|
||||
await access.ApplyProductGrantAsync(purchase.UserId, product, $"purchase:{purchase.Id}");
|
||||
await purchases.MarkGrantAppliedAsync(purchase.Id!);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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, AccessStore access) : ControllerBase
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase
|
||||
{
|
||||
[HttpGet("readiness")]
|
||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||
@@ -17,26 +17,31 @@ public class ExamSimulationsController(ExamSimulationStore simulations, AccessSt
|
||||
[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}";
|
||||
if (!User.IsInRole("SUPER") && !await access.TryConsumeExamTokenAsync(UserId(), operationId))
|
||||
return StatusCode(StatusCodes.Status402PaymentRequired, new
|
||||
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."
|
||||
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 (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
|
||||
return Conflict(exception.Message);
|
||||
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
|
||||
return Conflict(new { code = "exam_creation_failed", message = exception.Message });
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
|
||||
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public class QuestionsController : ControllerBase
|
||||
private readonly ServiceMetrics _metrics;
|
||||
private readonly QuestionBookmarkStore _bookmarks;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
private readonly AccessStore _access;
|
||||
private readonly CreditStore _credits;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -30,7 +30,7 @@ public class QuestionsController : ControllerBase
|
||||
ServiceMetrics metrics,
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue,
|
||||
AccessStore access)
|
||||
CreditStore credits)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -40,7 +40,7 @@ public class QuestionsController : ControllerBase
|
||||
_metrics = metrics;
|
||||
_bookmarks = bookmarks;
|
||||
_reviewQueue = reviewQueue;
|
||||
_access = access;
|
||||
_credits = credits;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -55,13 +55,6 @@ public class QuestionsController : ControllerBase
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
|
||||
{
|
||||
if (!await _access.CanLoadQuestionAsync(UserId(), User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted. Redeem or activate an access pass to continue.",
|
||||
access = await _access.GetStatusAsync(UserId())
|
||||
});
|
||||
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
|
||||
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
|
||||
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
|
||||
@@ -127,8 +120,6 @@ public class QuestionsController : ControllerBase
|
||||
return BadRequest("questionExternalId required");
|
||||
if (string.IsNullOrWhiteSpace(req.ChoiceId))
|
||||
return BadRequest("choiceId required");
|
||||
if (string.IsNullOrWhiteSpace(req.CompletionId) || req.CompletionId.Trim().Length > 100)
|
||||
return BadRequest("completionId required and must be 100 characters or fewer");
|
||||
|
||||
var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
|
||||
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
|
||||
@@ -158,15 +149,6 @@ public class QuestionsController : ControllerBase
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
var completionId = req.CompletionId.Trim();
|
||||
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted.",
|
||||
access = await _access.GetStatusAsync(userId)
|
||||
});
|
||||
var bankCycle = await _access.GetBankCycleAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
@@ -176,8 +158,7 @@ public class QuestionsController : ControllerBase
|
||||
false,
|
||||
presentedChoiceIds,
|
||||
req.DurationSeconds,
|
||||
bankCycle,
|
||||
completionId);
|
||||
await _credits.GetBankCycleAsync(userId));
|
||||
if (record.Inserted)
|
||||
{
|
||||
_metrics.AttemptCreated();
|
||||
@@ -195,8 +176,8 @@ public class QuestionsController : ControllerBase
|
||||
_metrics.AttemptDuplicate();
|
||||
}
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (record.Inserted && !User.IsInRole("SUPER"))
|
||||
await _access.TryAwardCreditAsync(userId, $"qbank-credit:{record.Attempt.Id}");
|
||||
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(
|
||||
@@ -230,7 +211,7 @@ public class QuestionsController : ControllerBase
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating,
|
||||
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
|
||||
Credits = await _credits.GetStatusAsync(userId)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -278,7 +259,7 @@ public class QuestionsController : ControllerBase
|
||||
|
||||
[HttpPost("{externalId}/skip")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Skip(string externalId, [FromQuery] string completionId)
|
||||
public async Task<IActionResult> Skip(string externalId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(externalId))
|
||||
return BadRequest("externalId required");
|
||||
@@ -290,17 +271,6 @@ public class QuestionsController : ControllerBase
|
||||
var userId = CurrentUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(completionId) || completionId.Trim().Length > 100)
|
||||
return BadRequest("completionId required and must be 100 characters or fewer");
|
||||
completionId = completionId.Trim();
|
||||
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted.",
|
||||
access = await _access.GetStatusAsync(userId)
|
||||
});
|
||||
var bankCycle = await _access.GetBankCycleAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
@@ -308,10 +278,7 @@ public class QuestionsController : ControllerBase
|
||||
"",
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
0,
|
||||
bankCycle,
|
||||
completionId);
|
||||
bankCycle: await _credits.GetBankCycleAsync(userId));
|
||||
if (record.Inserted)
|
||||
_metrics.AttemptCreated();
|
||||
else
|
||||
@@ -345,8 +312,7 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating,
|
||||
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
|
||||
Rating = rating
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class AccessCatalog
|
||||
{
|
||||
public const int FreeQuestionLimit = 20;
|
||||
public const int CreditEarningLimit = 20;
|
||||
public const int DayPassCreditCost = 100;
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, AccessProduct> Products =
|
||||
new Dictionary<string, AccessProduct>(StringComparer.Ordinal)
|
||||
{
|
||||
["access_day_1"] = new("access_day_1", "One-day QBank token", 100, "usd", AccessGrant.DayToken),
|
||||
["access_week_1"] = new("access_week_1", "Seven-day QBank token", 500, "usd", AccessGrant.WeekToken),
|
||||
["access_month_1"] = new("access_month_1", "Thirty-day QBank token", 2000, "usd", AccessGrant.MonthToken),
|
||||
["exam_token_1"] = new("exam_token_1", "Exam simulation token", 199, "usd", AccessGrant.ExamToken),
|
||||
["qbank_reset_1"] = new("qbank_reset_1", "QBank reset token", 499, "usd", AccessGrant.ResetToken)
|
||||
};
|
||||
}
|
||||
|
||||
public static class AccessGrant
|
||||
{
|
||||
public const string DayToken = "day_token";
|
||||
public const string WeekToken = "week_token";
|
||||
public const string MonthToken = "month_token";
|
||||
public const string ExamToken = "exam_token";
|
||||
public const string ResetToken = "reset_token";
|
||||
}
|
||||
|
||||
public sealed record AccessProduct(string Sku, string Name, long AmountCents, string Currency, string Grant);
|
||||
|
||||
public sealed class AccessEvent
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string SubjectId { get; set; } = "";
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserAccessAccount
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public int Credits { get; set; }
|
||||
public int DayTokens { get; set; }
|
||||
public int WeekTokens { get; set; }
|
||||
public int MonthTokens { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int ResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public DateTime? UnlimitedUntilUtc { get; set; }
|
||||
public List<AccessEvent> RecentUsage { get; set; } = [];
|
||||
public List<AccessEvent> RecentCredits { get; set; } = [];
|
||||
public List<string> AppliedOperationIds { get; set; } = [];
|
||||
public int Revision { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class AccessStatusResponse
|
||||
{
|
||||
public int FreeQuestionLimit { get; set; } = AccessCatalog.FreeQuestionLimit;
|
||||
public int QuestionsUsed { get; set; }
|
||||
public int QuestionsRemaining { get; set; }
|
||||
public DateTime? NextQuestionAvailableUtc { get; set; }
|
||||
public int Credits { get; set; }
|
||||
public int DayPassCreditCost { get; set; } = AccessCatalog.DayPassCreditCost;
|
||||
public int DayTokens { get; set; }
|
||||
public int WeekTokens { get; set; }
|
||||
public int MonthTokens { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int ResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public bool Unlimited { get; set; }
|
||||
public DateTime? UnlimitedUntilUtc { get; set; }
|
||||
public List<AccessProduct> Products { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ActivateAccessTokenRequest
|
||||
{
|
||||
public string Token { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class RedeemCreditsRequest
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ResetQBankRequest
|
||||
{
|
||||
public string Confirmation { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class CreateAccessCheckoutRequest
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class AccessCheckoutResponse
|
||||
{
|
||||
public string PurchaseId { get; set; } = "";
|
||||
public string CheckoutUrl { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
}
|
||||
|
||||
public static class AccessPurchaseStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Paid = "paid";
|
||||
public const string Failed = "failed";
|
||||
public const string Expired = "expired";
|
||||
public const string Refunded = "refunded";
|
||||
}
|
||||
|
||||
public sealed class AccessPurchase
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "usd";
|
||||
public string Status { get; set; } = AccessPurchaseStatus.Pending;
|
||||
public string Provider { get; set; } = "stripe";
|
||||
public string ProviderSessionId { get; set; } = "";
|
||||
public string ProviderPaymentId { get; set; } = "";
|
||||
public List<string> ProviderEventIds { get; set; } = [];
|
||||
public bool GrantApplied { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class AccessPurchaseStatusResponse
|
||||
{
|
||||
public string PurchaseId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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();
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class ExamSimulationItem
|
||||
public int Position { get; set; }
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public int QuestionDifficulty { get; set; }
|
||||
public bool WasSeen { get; set; }
|
||||
public string BlueprintCategory { get; set; } = "Other";
|
||||
}
|
||||
@@ -52,6 +53,7 @@ public class ExamSimulationResponse
|
||||
public List<string> EliminatedChoiceIds { get; set; } = [];
|
||||
public bool Flagged { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public bool? IsSubmitted { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -64,7 +66,9 @@ public class ExamSimulationScore
|
||||
public int Correct { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int SeenCount { get; set; }
|
||||
public double AbilityElo { get; set; }
|
||||
public double RawAccuracy { get; set; }
|
||||
public double DifficultyAdjustedAccuracy { get; set; }
|
||||
public double? AbilityElo { get; set; }
|
||||
public Dictionary<string, ExamSimulationCategoryScore> Categories { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -81,6 +85,7 @@ public class SaveSimulationResponseRequest
|
||||
public List<string> EliminatedChoiceIds { get; set; } = [];
|
||||
public bool Flagged { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public bool IsSubmitted { get; set; }
|
||||
}
|
||||
|
||||
public class ExamSimulationQuestionResponse
|
||||
|
||||
@@ -12,7 +12,6 @@ public class QuestionAttempt
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public int BankCycle { get; set; }
|
||||
public string CompletionId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public bool TracksPresentation { get; set; }
|
||||
|
||||
@@ -45,9 +45,9 @@ public class FlagQuestionRequest
|
||||
|
||||
public class FirstQuestionAttemptRequest
|
||||
{
|
||||
public string CompletionId { get; set; } = "";
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
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,7 @@ public class FirstQuestionAttemptResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public AccessStatusResponse? Access { get; set; }
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
@@ -151,7 +151,6 @@ public class SkipQuestionResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public AccessStatusResponse? Access { get; set; }
|
||||
}
|
||||
|
||||
public static class RatingCategory
|
||||
|
||||
@@ -47,4 +47,6 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -55,6 +55,17 @@ if (args.FirstOrDefault() == "flag-media-questions")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "migrate-question-attempt-cycles")
|
||||
{
|
||||
var migrationConfiguration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
Environment.ExitCode = await QuestionAttemptCycleMigrationCommand.RunAsync(
|
||||
migrationConfiguration, args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
@@ -67,9 +78,7 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
|
||||
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<AccessStore>();
|
||||
builder.Services.AddSingleton<AccessPurchaseStore>();
|
||||
builder.Services.AddSingleton<IStripeAccessGateway, StripeAccessGateway>();
|
||||
builder.Services.AddSingleton<CreditStore>();
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
||||
@@ -116,6 +125,7 @@ builder.Services.AddSingleton<DonationStore>();
|
||||
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
builder.Services.AddHostedService<ExamSimulationScoringMigrationService>();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AccessPurchaseStore
|
||||
{
|
||||
private readonly IMongoCollection<AccessPurchase> _purchases;
|
||||
|
||||
public AccessPurchaseStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_purchases = mongo.Database.GetCollection<AccessPurchase>("AccessPurchases");
|
||||
_purchases.Indexes.CreateMany([
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)),
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.ProviderSessionId),
|
||||
new CreateIndexOptions<AccessPurchase>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = Builders<AccessPurchase>.Filter.Ne(value => value.ProviderSessionId, "")
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase> CreateAsync(string userId, AccessProduct product)
|
||||
{
|
||||
var purchase = new AccessPurchase
|
||||
{
|
||||
UserId = userId,
|
||||
Sku = product.Sku,
|
||||
AmountCents = product.AmountCents,
|
||||
Currency = product.Currency
|
||||
};
|
||||
await _purchases.InsertOneAsync(purchase);
|
||||
return purchase;
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase?> GetOwnedAsync(string id, string userId) =>
|
||||
await _purchases.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<AccessPurchase?> GetAsync(string id) =>
|
||||
await _purchases.Find(value => value.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public Task AttachSessionAsync(string id, string sessionId) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public Task MarkCheckoutFailedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, AccessPurchaseStatus.Failed)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public async Task<AccessPurchase?> ApplyProviderStatusAsync(
|
||||
string id, string sessionId, long amountCents, string currency,
|
||||
string status, string paymentId, string eventId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sessionId)) return null;
|
||||
var filters = Builders<AccessPurchase>.Filter;
|
||||
var filter = filters.Eq(value => value.Id, id) &
|
||||
filters.Eq(value => value.AmountCents, amountCents) &
|
||||
filters.Eq(value => value.Currency, currency.ToLowerInvariant()) &
|
||||
(filters.Eq(value => value.ProviderSessionId, sessionId) |
|
||||
filters.Eq(value => value.ProviderSessionId, "")) &
|
||||
filters.Not(filters.AnyEq(value => value.ProviderEventIds, eventId));
|
||||
var update = Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, status)
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.ProviderPaymentId, paymentId)
|
||||
.AddToSet(value => value.ProviderEventIds, eventId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow);
|
||||
return await _purchases.FindOneAndUpdateAsync(filter, update,
|
||||
new FindOneAndUpdateOptions<AccessPurchase> { ReturnDocument = ReturnDocument.After });
|
||||
}
|
||||
|
||||
public Task MarkGrantAppliedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Paid,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.GrantApplied, true)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public static AccessPurchaseStatusResponse ToStatus(AccessPurchase purchase) => new()
|
||||
{
|
||||
PurchaseId = purchase.Id ?? "",
|
||||
Sku = purchase.Sku,
|
||||
AmountCents = purchase.AmountCents,
|
||||
Currency = purchase.Currency,
|
||||
Status = purchase.Status
|
||||
};
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AccessStore
|
||||
{
|
||||
private readonly IMongoCollection<UserAccessAccount> _accounts;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public AccessStore(MongoDatabaseProvider mongo, TimeProvider time)
|
||||
{
|
||||
_accounts = mongo.Database.GetCollection<UserAccessAccount>("UserAccessAccounts");
|
||||
_time = time;
|
||||
_accounts.Indexes.CreateOne(new CreateIndexModel<UserAccessAccount>(
|
||||
Builders<UserAccessAccount>.IndexKeys.Ascending(value => value.UserId),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_access_user" }));
|
||||
}
|
||||
|
||||
public async Task<AccessStatusResponse> GetStatusAsync(string userId, bool bypass = false)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
return ToStatus(account, now, bypass);
|
||||
}
|
||||
|
||||
public async Task<int> GetBankCycleAsync(string userId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
|
||||
}
|
||||
|
||||
public async Task<bool> CanLoadQuestionAsync(string userId, bool bypass)
|
||||
{
|
||||
if (bypass) return true;
|
||||
var status = await GetStatusAsync(userId);
|
||||
return status.Unlimited || status.QuestionsRemaining > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ClaimQuestionAsync(string userId, string completionId, string questionExternalId, bool bypass)
|
||||
{
|
||||
if (bypass) return true;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var exactClaim = filters.ElemMatch(value => value.RecentUsage,
|
||||
item => item.Id == completionId && item.SubjectId == questionExternalId);
|
||||
if (await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync())
|
||||
return true;
|
||||
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
|
||||
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
|
||||
{
|
||||
new MongoDB.Bson.BsonDocument("$size", "$RecentUsage"),
|
||||
AccessCatalog.FreeQuestionLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.ElemMatch(value => value.RecentUsage, item => item.Id == completionId)) &
|
||||
(filters.Gt(value => value.UnlimitedUntilUtc, now) |
|
||||
capacity);
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.AddToSet(value => value.RecentUsage, new AccessEvent
|
||||
{
|
||||
Id = completionId,
|
||||
SubjectId = questionExternalId,
|
||||
CreatedUtc = now
|
||||
})
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
if ((await _accounts.UpdateOneAsync(filter, update)).MatchedCount == 1)
|
||||
return true;
|
||||
return await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> TryAwardCreditAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
|
||||
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
|
||||
{
|
||||
new MongoDB.Bson.BsonDocument("$size", "$RecentCredits"),
|
||||
AccessCatalog.CreditEarningLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)) &
|
||||
capacity;
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.Credits, 1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Push(value => value.RecentCredits, new AccessEvent { Id = operationId, CreatedUtc = now })
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public Task<AccessStatusResponse?> RedeemCreditsAsync(string userId, string operationId) =>
|
||||
SpendAndExtendAsync(userId, operationId, AccessCatalog.DayPassCreditCost, null, TimeSpan.FromHours(24));
|
||||
|
||||
public Task<AccessStatusResponse?> ActivateTokenAsync(string userId, string operationId, string token) =>
|
||||
token.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"day" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.DayTokens), TimeSpan.FromHours(24)),
|
||||
"week" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.WeekTokens), TimeSpan.FromHours(168)),
|
||||
"month" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.MonthTokens), TimeSpan.FromHours(720)),
|
||||
_ => Task.FromResult<AccessStatusResponse?>(null)
|
||||
};
|
||||
|
||||
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Gt(value => value.ExamTokens, 0) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ExamTokens, -1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public async Task RefundExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var filter = Builders<UserAccessAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserAccessAccount>.Filter.AnyEq(value => value.AppliedOperationIds, operationId);
|
||||
await _accounts.UpdateOneAsync(filter, Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ExamTokens, 1)
|
||||
.Pull(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now));
|
||||
}
|
||||
|
||||
public async Task<int?> ApplyResetAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Gt(value => value.ResetTokens, 0) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var updated = await _accounts.FindOneAndUpdateAsync(
|
||||
filter,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ResetTokens, -1)
|
||||
.Inc(value => value.BankCycle, 1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1),
|
||||
new FindOneAndUpdateOptions<UserAccessAccount> { ReturnDocument = ReturnDocument.After });
|
||||
return updated?.BankCycle;
|
||||
}
|
||||
|
||||
public async Task<bool> ApplyProductGrantAsync(string userId, AccessProduct product, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
update = product.Grant switch
|
||||
{
|
||||
AccessGrant.DayToken => update.Inc(value => value.DayTokens, 1),
|
||||
AccessGrant.WeekToken => update.Inc(value => value.WeekTokens, 1),
|
||||
AccessGrant.MonthToken => update.Inc(value => value.MonthTokens, 1),
|
||||
AccessGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
|
||||
AccessGrant.ResetToken => update.Inc(value => value.ResetTokens, 1),
|
||||
_ => update
|
||||
};
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
private async Task<AccessStatusResponse?> SpendAndExtendAsync(
|
||||
string userId, string operationId, int credits, string? tokenProperty, TimeSpan duration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(operationId)) return null;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
if (credits > 0) filter &= filters.Gte(value => value.Credits, credits);
|
||||
filter &= tokenProperty switch
|
||||
{
|
||||
nameof(UserAccessAccount.DayTokens) => filters.Gt(value => value.DayTokens, 0),
|
||||
nameof(UserAccessAccount.WeekTokens) => filters.Gt(value => value.WeekTokens, 0),
|
||||
nameof(UserAccessAccount.MonthTokens) => filters.Gt(value => value.MonthTokens, 0),
|
||||
_ => FilterDefinition<UserAccessAccount>.Empty
|
||||
};
|
||||
var account = await _accounts.Find(filter).FirstOrDefaultAsync();
|
||||
if (account is null) return null;
|
||||
var start = account.UnlimitedUntilUtc is { } expiry && expiry > now ? expiry : now;
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Set(value => value.UnlimitedUntilUtc, start.Add(duration))
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
if (credits > 0) update = update.Inc(value => value.Credits, -credits);
|
||||
update = tokenProperty switch
|
||||
{
|
||||
nameof(UserAccessAccount.DayTokens) => update.Inc(value => value.DayTokens, -1),
|
||||
nameof(UserAccessAccount.WeekTokens) => update.Inc(value => value.WeekTokens, -1),
|
||||
nameof(UserAccessAccount.MonthTokens) => update.Inc(value => value.MonthTokens, -1),
|
||||
_ => update
|
||||
};
|
||||
var applied = await _accounts.UpdateOneAsync(
|
||||
filter & filters.Eq(value => value.Revision, account.Revision), update);
|
||||
return applied.ModifiedCount == 1 ? await GetStatusAsync(userId) : null;
|
||||
}
|
||||
|
||||
private async Task EnsureAndCleanAsync(string userId, DateTime now)
|
||||
{
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.SetOnInsert(value => value.UserId, userId)
|
||||
.SetOnInsert(value => value.UpdatedUtc, now),
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
var cutoff = now.AddHours(-24);
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.PullFilter(value => value.RecentUsage, item => item.CreatedUtc <= cutoff)
|
||||
.PullFilter(value => value.RecentCredits, item => item.CreatedUtc <= cutoff));
|
||||
}
|
||||
|
||||
private static AccessStatusResponse ToStatus(UserAccessAccount account, DateTime now, bool bypass)
|
||||
{
|
||||
var unlimited = bypass || account.UnlimitedUntilUtc > now;
|
||||
var used = account.RecentUsage.Count;
|
||||
return new AccessStatusResponse
|
||||
{
|
||||
QuestionsUsed = used,
|
||||
QuestionsRemaining = unlimited ? AccessCatalog.FreeQuestionLimit : Math.Max(0, AccessCatalog.FreeQuestionLimit - used),
|
||||
NextQuestionAvailableUtc = !unlimited && used >= AccessCatalog.FreeQuestionLimit
|
||||
? account.RecentUsage.MinBy(value => value.CreatedUtc)?.CreatedUtc.AddHours(24)
|
||||
: null,
|
||||
Credits = account.Credits,
|
||||
DayTokens = account.DayTokens,
|
||||
WeekTokens = account.WeekTokens,
|
||||
MonthTokens = account.MonthTokens,
|
||||
ExamTokens = account.ExamTokens,
|
||||
ResetTokens = account.ResetTokens,
|
||||
BankCycle = account.BankCycle,
|
||||
Unlimited = unlimited,
|
||||
UnlimitedUntilUtc = account.UnlimitedUntilUtc,
|
||||
Products = AccessCatalog.Products.Values.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,11 @@ public static class ContentPromotionCommand
|
||||
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||
|
||||
var questions = await source.GetCollection<BsonDocument>("Questions")
|
||||
var sourceQuestions = 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
|
||||
? []
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,6 @@ public sealed class DashboardStore
|
||||
private readonly FlashcardScheduleStore _flashcardSchedules;
|
||||
private readonly RatingStore _ratingStore;
|
||||
private readonly CcsRatingStore _ccsRatingStore;
|
||||
private readonly AccessStore _accessStore;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
|
||||
public static readonly string[] Step3Systems =
|
||||
@@ -49,8 +48,7 @@ public sealed class DashboardStore
|
||||
FlashcardStudyService flashcards,
|
||||
FlashcardScheduleStore flashcardSchedules,
|
||||
RatingStore ratingStore,
|
||||
CcsRatingStore ccsRatingStore,
|
||||
AccessStore accessStore)
|
||||
CcsRatingStore ccsRatingStore)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||
@@ -67,7 +65,6 @@ public sealed class DashboardStore
|
||||
_flashcardSchedules = flashcardSchedules;
|
||||
_ratingStore = ratingStore;
|
||||
_ccsRatingStore = ccsRatingStore;
|
||||
_accessStore = accessStore;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
@@ -75,9 +72,7 @@ public sealed class DashboardStore
|
||||
var today = nowUtc.Date;
|
||||
var trendStart = today.AddDays(-29);
|
||||
var weekStart = today.AddDays(-6);
|
||||
var bankCycleTask = _accessStore.GetBankCycleAsync(userId);
|
||||
var bankCycle = await bankCycleTask;
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId && value.BankCycle == bankCycle).ToListAsync();
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
|
||||
@@ -4,33 +4,35 @@ namespace QuestionsApi.Services;
|
||||
|
||||
public static class ExamSimulationScoring
|
||||
{
|
||||
public const string ModelVersion = "provisional-1";
|
||||
private const double PriorMean = 1000.0;
|
||||
private const double PriorSd = 400.0;
|
||||
public const string ModelVersion = "provisional-2";
|
||||
private const double PassingAccuracy = 0.60;
|
||||
private const double ScoreIntercept = 50.0;
|
||||
private const double ScoreScale = 250.0;
|
||||
|
||||
public static ExamSimulationScore Calculate(
|
||||
IReadOnlyList<ExamSimulationItem> items,
|
||||
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
|
||||
IReadOnlyDictionary<string, Question> questions)
|
||||
{
|
||||
var ability = EstimateAbility(items, responses, questions);
|
||||
var predicted = Math.Clamp((int)Math.Round(227.0 + (ability - 1000.0) * 15.0 / 200.0), 1, 300);
|
||||
var information = 1.0 / (PriorSd * PriorSd);
|
||||
var correct = 0;
|
||||
var seen = 0;
|
||||
var weightedCorrect = 0.0;
|
||||
var weightSum = 0.0;
|
||||
var squaredWeightSum = 0.0;
|
||||
var categories = new Dictionary<string, ExamSimulationCategoryScore>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!questions.TryGetValue(item.QuestionExternalId, out var question))
|
||||
continue;
|
||||
questions.TryGetValue(item.QuestionExternalId, out var question);
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var isCorrect = response?.ChoiceId == question.CorrectChoiceId;
|
||||
var isCorrect = question is not null && response?.ChoiceId == question.CorrectChoiceId;
|
||||
if (isCorrect) correct++;
|
||||
if (item.WasSeen) seen++;
|
||||
var weight = item.WasSeen ? 0.5 : 1.0;
|
||||
var probability = Expected(ability, item.QuestionElo);
|
||||
information += weight * probability * (1.0 - probability) * Math.Pow(Math.Log(10.0) / 400.0, 2);
|
||||
var difficulty = ResolveDifficulty(item, question);
|
||||
var weight = DifficultyWeight(difficulty);
|
||||
weightSum += weight;
|
||||
squaredWeightSum += weight * weight;
|
||||
if (isCorrect) weightedCorrect += weight;
|
||||
if (!categories.TryGetValue(item.BlueprintCategory, out var category))
|
||||
{
|
||||
category = new ExamSimulationCategoryScore();
|
||||
@@ -40,51 +42,53 @@ public static class ExamSimulationScoring
|
||||
if (isCorrect) category.Correct++;
|
||||
}
|
||||
|
||||
var abilitySe = Math.Sqrt(1.0 / information);
|
||||
var scoreSe = abilitySe * 15.0 / 200.0;
|
||||
var combinedSe = Math.Sqrt(7.0 * 7.0 + 15.0 * 15.0 + scoreSe * scoreSe);
|
||||
var range = (int)Math.Ceiling(combinedSe);
|
||||
var passProbability = NormalCdf((predicted - 200.0) / combinedSe);
|
||||
var rawAccuracy = items.Count == 0 ? 0.0 : (double)correct / items.Count;
|
||||
var adjustedAccuracy = weightSum == 0.0 ? 0.0 : weightedCorrect / weightSum;
|
||||
var predicted = ToScore(adjustedAccuracy);
|
||||
var effectiveSampleSize = squaredWeightSum == 0.0 ? 0.0 : weightSum * weightSum / squaredWeightSum;
|
||||
var accuracySe = effectiveSampleSize == 0.0
|
||||
? 0.0
|
||||
: Math.Sqrt(adjustedAccuracy * (1.0 - adjustedAccuracy) / effectiveSampleSize);
|
||||
var accuracyMargin = 1.96 * accuracySe;
|
||||
var rangeLow = ToScore(Math.Clamp(adjustedAccuracy - accuracyMargin, 0.0, 1.0));
|
||||
var rangeHigh = ToScore(Math.Clamp(adjustedAccuracy + accuracyMargin, 0.0, 1.0));
|
||||
var passProbability = accuracySe == 0.0
|
||||
? adjustedAccuracy >= PassingAccuracy ? 1.0 : 0.0
|
||||
: NormalCdf((adjustedAccuracy - PassingAccuracy) / accuracySe);
|
||||
return new ExamSimulationScore
|
||||
{
|
||||
PredictedScore = predicted,
|
||||
RangeLow = Math.Clamp(predicted - range, 1, 300),
|
||||
RangeHigh = Math.Clamp(predicted + range, 1, 300),
|
||||
RangeLow = rangeLow,
|
||||
RangeHigh = rangeHigh,
|
||||
PassProbability = Math.Round(passProbability, 4),
|
||||
Correct = correct,
|
||||
Total = items.Count,
|
||||
SeenCount = seen,
|
||||
AbilityElo = Math.Round(ability, 1),
|
||||
RawAccuracy = Math.Round(rawAccuracy, 4),
|
||||
DifficultyAdjustedAccuracy = Math.Round(adjustedAccuracy, 4),
|
||||
AbilityElo = null,
|
||||
Categories = categories
|
||||
};
|
||||
}
|
||||
|
||||
private static double EstimateAbility(
|
||||
IReadOnlyList<ExamSimulationItem> items,
|
||||
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
|
||||
IReadOnlyDictionary<string, Question> questions)
|
||||
public static int ResolveDifficulty(ExamSimulationItem item, Question? question = null)
|
||||
{
|
||||
var low = 100.0;
|
||||
var high = 1900.0;
|
||||
for (var iteration = 0; iteration < 80; iteration++)
|
||||
{
|
||||
var ability = (low + high) / 2.0;
|
||||
var derivative = -(ability - PriorMean) / (PriorSd * PriorSd);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!questions.TryGetValue(item.QuestionExternalId, out var question)) continue;
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var actual = response?.ChoiceId == question.CorrectChoiceId ? 1.0 : 0.0;
|
||||
var weight = item.WasSeen ? 0.5 : 1.0;
|
||||
derivative += weight * (actual - Expected(ability, item.QuestionElo)) * Math.Log(10.0) / 400.0;
|
||||
}
|
||||
if (derivative > 0) low = ability; else high = ability;
|
||||
}
|
||||
return (low + high) / 2.0;
|
||||
if (item.QuestionDifficulty is >= 1 and <= 5) return item.QuestionDifficulty;
|
||||
return question?.Difficulty is >= 1 and <= 5 ? question.Difficulty : 3;
|
||||
}
|
||||
|
||||
private static double Expected(double ability, int difficulty) =>
|
||||
1.0 / (1.0 + Math.Pow(10.0, (difficulty - ability) / 400.0));
|
||||
private static double DifficultyWeight(int difficulty) => difficulty switch
|
||||
{
|
||||
1 => 0.8,
|
||||
2 => 0.9,
|
||||
3 => 1.0,
|
||||
4 => 1.1,
|
||||
5 => 1.2,
|
||||
_ => 1.0
|
||||
};
|
||||
|
||||
private static int ToScore(double accuracy) =>
|
||||
Math.Clamp((int)Math.Round(ScoreIntercept + ScoreScale * accuracy), 1, 300);
|
||||
|
||||
private static double NormalCdf(double value) => 0.5 * (1.0 + Erf(value / Math.Sqrt(2.0)));
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class ExamSimulationScoringMigrationService : BackgroundService
|
||||
{
|
||||
private readonly ExamSimulationStore _simulations;
|
||||
private readonly ILogger<ExamSimulationScoringMigrationService> _logger;
|
||||
|
||||
public ExamSimulationScoringMigrationService(
|
||||
ExamSimulationStore simulations,
|
||||
ILogger<ExamSimulationScoringMigrationService> logger)
|
||||
{
|
||||
_simulations = simulations;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _simulations.BackfillScoringModelAsync(stoppingToken);
|
||||
_logger.LogInformation(
|
||||
"Exam simulation scoring migration completed; recalculated {UpdatedCount} simulation(s)",
|
||||
result.UpdatedCount);
|
||||
foreach (var reference in result.MissingQuestionIds)
|
||||
_logger.LogWarning(
|
||||
"Exam simulation scoring migration used neutral difficulty for missing question {Reference}",
|
||||
reference);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Exam simulation scoring migration failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public class ExamSimulationStore
|
||||
private readonly IMongoCollection<Step3Outcome> _outcomes;
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly QuestionAttemptStore _attempts;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly AnswerStatisticsStore _statistics;
|
||||
private readonly RatingStore _ratings;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
@@ -22,6 +23,7 @@ public class ExamSimulationStore
|
||||
MongoDatabaseProvider mongo,
|
||||
QuestionStore questions,
|
||||
QuestionAttemptStore attempts,
|
||||
CreditStore credits,
|
||||
AnswerStatisticsStore statistics,
|
||||
RatingStore ratings,
|
||||
ServiceMetrics metrics,
|
||||
@@ -31,6 +33,7 @@ public class ExamSimulationStore
|
||||
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
_credits = credits;
|
||||
_statistics = statistics;
|
||||
_ratings = ratings;
|
||||
_metrics = metrics;
|
||||
@@ -68,7 +71,8 @@ public class ExamSimulationStore
|
||||
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
|
||||
if (bank.Count < RequiredQuestions)
|
||||
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required.");
|
||||
var seen = (await _attempts.GetUserQuestionIdsAsync(userId)).ToHashSet(StringComparer.Ordinal);
|
||||
var bankCycle = await _credits.GetBankCycleAsync(userId);
|
||||
var seen = (await _attempts.GetUserQuestionIdsAsync(userId, bankCycle)).ToHashSet(StringComparer.Ordinal);
|
||||
var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
|
||||
var targets = BuildTargets();
|
||||
var selected = SelectForm(bank, seen, targets);
|
||||
@@ -87,6 +91,7 @@ public class ExamSimulationStore
|
||||
Position = offset,
|
||||
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
|
||||
QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000),
|
||||
QuestionDifficulty = question.Difficulty,
|
||||
WasSeen = seen.Contains(question.ExternalId),
|
||||
BlueprintCategory = BlueprintCategory(question)
|
||||
});
|
||||
@@ -124,6 +129,7 @@ public class ExamSimulationStore
|
||||
Position = PositionWithinBlock(index),
|
||||
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
|
||||
QuestionElo = 1000,
|
||||
QuestionDifficulty = question.Difficulty,
|
||||
WasSeen = false,
|
||||
BlueprintCategory = BlueprintCategory(question)
|
||||
}).ToList();
|
||||
@@ -169,15 +175,22 @@ public class ExamSimulationStore
|
||||
(item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive))
|
||||
.SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId) =>
|
||||
(await _simulations.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
|
||||
.SortByDescending(item => item.CompletedUtc).FirstOrDefaultAsync())?.Score;
|
||||
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId)
|
||||
{
|
||||
var simulation = await _simulations
|
||||
.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
|
||||
.SortByDescending(item => item.CompletedUtc)
|
||||
.FirstOrDefaultAsync();
|
||||
return simulation is null ? null : (await UpgradeScoringModelAsync(simulation)).Score;
|
||||
}
|
||||
|
||||
public async Task<ExamSimulation?> GetOwnedAsync(string id, string userId)
|
||||
{
|
||||
var simulation = await _simulations.Find(item => item.Id == id && item.UserId == userId).FirstOrDefaultAsync();
|
||||
if (simulation is not null && simulation.State == ExamSimulationStates.BlockActive && simulation.BlockDeadlineUtc <= DateTime.UtcNow)
|
||||
simulation = await SubmitBlockAsync(simulation, true);
|
||||
if (simulation is not null)
|
||||
simulation = await UpgradeScoringModelAsync(simulation);
|
||||
return simulation;
|
||||
}
|
||||
|
||||
@@ -191,6 +204,7 @@ public class ExamSimulationStore
|
||||
var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync();
|
||||
var hasMore = simulations.Count > limit;
|
||||
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
|
||||
await UpgradeScoringModelsAsync(simulations);
|
||||
return new CompletedExamSimulationPage
|
||||
{
|
||||
Items = simulations.Select(item => new CompletedExamSimulationSummary
|
||||
@@ -257,6 +271,7 @@ public class ExamSimulationStore
|
||||
var simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync();
|
||||
var hasMore = simulations.Count > limit;
|
||||
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
|
||||
await UpgradeScoringModelsAsync(simulations.Where(item => item.State == ExamSimulationStates.Completed));
|
||||
var last = simulations.LastOrDefault();
|
||||
return new ExamSimulationHistoryPage
|
||||
{
|
||||
@@ -333,23 +348,53 @@ public class ExamSimulationStore
|
||||
if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "")
|
||||
throw new ArgumentException("Choice is not presented for this question.");
|
||||
var response = simulation.Responses.FirstOrDefault(candidate => candidate.QuestionExternalId == request.QuestionExternalId);
|
||||
if (response is not null && IsResponseLocked(response))
|
||||
{
|
||||
if (ResponseContentMatches(response, request, item.PresentedChoiceIds))
|
||||
return simulation;
|
||||
throw new InvalidOperationException("A submitted simulation answer cannot be changed.");
|
||||
}
|
||||
if (response is null)
|
||||
{
|
||||
response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId };
|
||||
response = new ExamSimulationResponse
|
||||
{
|
||||
QuestionExternalId = request.QuestionExternalId,
|
||||
IsSubmitted = false
|
||||
};
|
||||
simulation.Responses.Add(response);
|
||||
}
|
||||
response.ChoiceId = request.ChoiceId;
|
||||
response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList();
|
||||
response.Flagged = request.Flagged;
|
||||
response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800);
|
||||
response.IsSubmitted = request.IsSubmitted;
|
||||
response.UpdatedUtc = DateTime.UtcNow;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
await ReplaceAsync(simulation);
|
||||
return simulation;
|
||||
}
|
||||
|
||||
public static bool IsResponseLocked(ExamSimulationResponse response) =>
|
||||
response.IsSubmitted == true ||
|
||||
(response.IsSubmitted is null && !string.IsNullOrEmpty(response.ChoiceId));
|
||||
|
||||
public static bool ResponseContentMatches(
|
||||
ExamSimulationResponse response,
|
||||
SaveSimulationResponseRequest request,
|
||||
IReadOnlyCollection<string> presentedChoiceIds)
|
||||
{
|
||||
var eliminated = request.EliminatedChoiceIds
|
||||
.Where(presentedChoiceIds.Contains)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
return response.ChoiceId == request.ChoiceId &&
|
||||
response.Flagged == request.Flagged &&
|
||||
response.EliminatedChoiceIds.ToHashSet(StringComparer.Ordinal).SetEquals(eliminated);
|
||||
}
|
||||
|
||||
public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
|
||||
{
|
||||
if (IsAlreadySubmittedState(simulation.State))
|
||||
return simulation;
|
||||
if (simulation.State != ExamSimulationStates.BlockActive)
|
||||
throw new InvalidOperationException("There is no active block to submit.");
|
||||
if (!expired && simulation.BlockDeadlineUtc is not null)
|
||||
@@ -366,6 +411,9 @@ public class ExamSimulationStore
|
||||
return simulation;
|
||||
}
|
||||
|
||||
public static bool IsAlreadySubmittedState(string state) =>
|
||||
state is ExamSimulationStates.BetweenBlocks or ExamSimulationStates.Completed;
|
||||
|
||||
public async Task AbandonAsync(ExamSimulation simulation)
|
||||
{
|
||||
simulation.State = ExamSimulationStates.Abandoned;
|
||||
@@ -433,7 +481,9 @@ public class ExamSimulationStore
|
||||
{
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
|
||||
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
|
||||
SnapshotDifficulties(simulation, questionMap);
|
||||
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
|
||||
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
|
||||
simulation.State = ExamSimulationStates.Completed;
|
||||
simulation.CompletedUtc = DateTime.UtcNow;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
@@ -443,7 +493,10 @@ public class ExamSimulationStore
|
||||
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var choiceId = response?.ChoiceId ?? "";
|
||||
var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds);
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
item.QuestionExternalId, simulation.UserId, choiceId,
|
||||
choiceId == question.CorrectChoiceId, choiceId == "",
|
||||
item.PresentedChoiceIds, bankCycle: await _credits.GetBankCycleAsync(simulation.UserId));
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (!record.Inserted) continue;
|
||||
_metrics.AttemptCreated();
|
||||
@@ -468,6 +521,71 @@ public class ExamSimulationStore
|
||||
private Task ReplaceAsync(ExamSimulation simulation) =>
|
||||
_simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, simulation);
|
||||
|
||||
public async Task<ExamSimulationBackfillResult> BackfillScoringModelAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<ExamSimulation>.Filter.Eq(item => item.State, ExamSimulationStates.Completed) &
|
||||
Builders<ExamSimulation>.Filter.Ne(item => item.ModelVersion, ExamSimulationScoring.ModelVersion);
|
||||
var simulations = await _simulations.Find(filter).ToListAsync(cancellationToken);
|
||||
var missingQuestionIds = new List<string>();
|
||||
var updated = 0;
|
||||
foreach (var simulation in simulations)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
|
||||
missingQuestionIds.AddRange(simulation.Items
|
||||
.Where(item => !questionMap.ContainsKey(item.QuestionExternalId))
|
||||
.Select(item => $"{simulation.Id}:{item.QuestionExternalId}"));
|
||||
SnapshotDifficulties(simulation, questionMap);
|
||||
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
|
||||
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
|
||||
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
await ReplaceAsync(simulation);
|
||||
updated++;
|
||||
}
|
||||
return new ExamSimulationBackfillResult(updated, missingQuestionIds);
|
||||
}
|
||||
|
||||
private async Task UpgradeScoringModelsAsync(IEnumerable<ExamSimulation> simulations)
|
||||
{
|
||||
foreach (var simulation in simulations)
|
||||
await UpgradeScoringModelAsync(simulation);
|
||||
}
|
||||
|
||||
private async Task<ExamSimulation> UpgradeScoringModelAsync(ExamSimulation simulation)
|
||||
{
|
||||
var needsDifficultySnapshot = simulation.Items.Any(item => item.QuestionDifficulty is < 1 or > 5);
|
||||
var needsScoreUpgrade = simulation.State == ExamSimulationStates.Completed &&
|
||||
simulation.ModelVersion != ExamSimulationScoring.ModelVersion;
|
||||
if (!needsDifficultySnapshot && !needsScoreUpgrade)
|
||||
return simulation;
|
||||
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
|
||||
SnapshotDifficulties(simulation, questionMap);
|
||||
if (simulation.State == ExamSimulationStates.Completed)
|
||||
{
|
||||
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
|
||||
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
|
||||
}
|
||||
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
|
||||
simulation.UpdatedUtc = DateTime.UtcNow;
|
||||
await ReplaceAsync(simulation);
|
||||
return simulation;
|
||||
}
|
||||
|
||||
private static void SnapshotDifficulties(
|
||||
ExamSimulation simulation,
|
||||
IReadOnlyDictionary<string, Question> questions)
|
||||
{
|
||||
foreach (var item in simulation.Items)
|
||||
{
|
||||
questions.TryGetValue(item.QuestionExternalId, out var question);
|
||||
item.QuestionDifficulty = ExamSimulationScoring.ResolveDifficulty(item, question);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExamSimulationBackfillResult(int UpdatedCount, IReadOnlyList<string> MissingQuestionIds);
|
||||
|
||||
private static int BlockIndexForPosition(int position)
|
||||
{
|
||||
var consumed = 0;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class QuestionAttemptCycleMigrationCommand
|
||||
{
|
||||
public const string Confirmation = "MIGRATE_ATTEMPT_CYCLES";
|
||||
public const string LegacyIndexName = "QuestionExternalId_1_UserId_1";
|
||||
public const string CycleIndexName = "uq_question_user_cycle";
|
||||
|
||||
public static async Task<int> RunAsync(
|
||||
IConfiguration configuration, string[] args, TextWriter output)
|
||||
{
|
||||
var apply = args.Contains("--apply", StringComparer.Ordinal);
|
||||
var confirmation = Option(args, "--confirm");
|
||||
var mongo = new MongoDatabaseProvider(configuration);
|
||||
var attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
var missingFilter = new BsonDocumentFilterDefinition<QuestionAttempt>(
|
||||
new BsonDocument(nameof(QuestionAttempt.BankCycle),
|
||||
new BsonDocument("$exists", false)));
|
||||
var missing = await attempts.CountDocumentsAsync(missingFilter);
|
||||
var indexes = (await attempts.Indexes.ListAsync()).ToList();
|
||||
var names = indexes.Select(value => value["name"].AsString).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
await output.WriteLineAsync(
|
||||
$"QuestionAttempts missing BankCycle: {missing}; legacy index: {names.Contains(LegacyIndexName)}; cycle index: {names.Contains(CycleIndexName)}.");
|
||||
if (!apply)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
$"Dry run only. Re-run with --apply --confirm {Confirmation}.");
|
||||
return 0;
|
||||
}
|
||||
if (confirmation != Confirmation)
|
||||
{
|
||||
await output.WriteLineAsync($"Refusing migration: --confirm {Confirmation} is required.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (missing > 0)
|
||||
await attempts.UpdateManyAsync(
|
||||
missingFilter,
|
||||
Builders<QuestionAttempt>.Update.Set(value => value.BankCycle, 0));
|
||||
if (!names.Contains(CycleIndexName))
|
||||
await attempts.Indexes.CreateOneAsync(new CreateIndexModel<QuestionAttempt>(
|
||||
Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(value => value.QuestionExternalId)
|
||||
.Ascending(value => value.UserId)
|
||||
.Ascending(value => value.BankCycle),
|
||||
new CreateIndexOptions { Unique = true, Name = CycleIndexName }));
|
||||
if (names.Contains(LegacyIndexName))
|
||||
await attempts.Indexes.DropOneAsync(LegacyIndexName);
|
||||
|
||||
var remaining = await attempts.CountDocumentsAsync(missingFilter);
|
||||
await output.WriteLineAsync(
|
||||
$"Migration complete. Backfilled {missing}; missing BankCycle: {remaining}; dropped legacy index: {names.Contains(LegacyIndexName)}.");
|
||||
return remaining == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
private static string Option(string[] args, string name)
|
||||
{
|
||||
var index = Array.IndexOf(args, name);
|
||||
return index >= 0 && index + 1 < args.Length ? args[index + 1] : "";
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,6 @@ public class QuestionAttemptStore
|
||||
{
|
||||
_col = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
|
||||
try
|
||||
{
|
||||
_col.Indexes.DropOne("QuestionExternalId_1_UserId_1");
|
||||
}
|
||||
catch (MongoCommandException exception) when (exception.Code is 26 or 27)
|
||||
{
|
||||
}
|
||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
.Ascending(a => a.UserId)
|
||||
@@ -47,15 +40,13 @@ public class QuestionAttemptStore
|
||||
bool isSkip = false,
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||
int durationSeconds = 0,
|
||||
int bankCycle = 0,
|
||||
string completionId = "")
|
||||
int bankCycle = 0)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
QuestionExternalId = questionExternalId,
|
||||
UserId = userId,
|
||||
BankCycle = bankCycle,
|
||||
CompletionId = completionId,
|
||||
ChoiceId = choiceId,
|
||||
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||
@@ -75,7 +66,8 @@ public class QuestionAttemptStore
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
var existing = await _col
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId && a.BankCycle == bankCycle)
|
||||
.Find(a => a.QuestionExternalId == questionExternalId &&
|
||||
a.UserId == userId && a.BankCycle == bankCycle)
|
||||
.FirstAsync();
|
||||
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
||||
}
|
||||
@@ -87,14 +79,17 @@ public class QuestionAttemptStore
|
||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) =>
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null)
|
||||
{
|
||||
var filter = Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId);
|
||||
if (bankCycle.HasValue)
|
||||
filter &= Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value);
|
||||
return
|
||||
_col.Distinct(
|
||||
attempt => attempt.QuestionExternalId,
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId) &
|
||||
(bankCycle is null
|
||||
? FilterDefinition<QuestionAttempt>.Empty
|
||||
: Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value)))
|
||||
filter)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ 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)
|
||||
{
|
||||
@@ -28,9 +30,9 @@ public static class QuestionPromotionCommand
|
||||
|
||||
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
|
||||
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
|
||||
var questions = await source.GetCollection<BsonDocument>("Questions")
|
||||
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
|
||||
.Find(questionFilter).ToListAsync();
|
||||
var duplicateIds = questions
|
||||
var duplicateIds = sourceQuestions
|
||||
.GroupBy(ExternalId, StringComparer.Ordinal)
|
||||
.Where(group => group.Count() > 1)
|
||||
.Select(group => group.Key)
|
||||
@@ -38,6 +40,7 @@ public static class QuestionPromotionCommand
|
||||
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
|
||||
? []
|
||||
@@ -95,7 +98,7 @@ public static class QuestionPromotionCommand
|
||||
["SourceDatabase"] = options.SourceDatabase,
|
||||
["TargetDatabase"] = options.TargetDatabase,
|
||||
["Scope"] = "questions",
|
||||
["Eligibility"] = "published_unflagged",
|
||||
["Eligibility"] = "published_unflagged; media_requires_production_review",
|
||||
["Questions"] = summary.EligibleQuestions,
|
||||
["Inserted"] = summary.Inserted,
|
||||
["Updated"] = summary.Updated,
|
||||
@@ -118,6 +121,21 @@ public static class QuestionPromotionCommand
|
||||
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>();
|
||||
@@ -192,6 +210,11 @@ public static class QuestionPromotionCommand
|
||||
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");
|
||||
@@ -224,7 +247,7 @@ public static class QuestionPromotionCommand
|
||||
{
|
||||
mode,
|
||||
scope = "questions",
|
||||
eligibility = "published_unflagged",
|
||||
eligibility = "published_unflagged; media_requires_production_review",
|
||||
source = options.SourceDatabase,
|
||||
target = options.TargetDatabase,
|
||||
eligibleQuestions = summary.EligibleQuestions,
|
||||
|
||||
@@ -68,11 +68,31 @@ 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,32 +27,49 @@ 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
|
||||
CategoryElos = rating.CategoryElos,
|
||||
Tier = tierName,
|
||||
TierDivision = tierDivision
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly (string Name, int FloorElo)[] TierBands =
|
||||
[
|
||||
("Bronze", MinElo),
|
||||
("Silver", 1000),
|
||||
("Gold", 1300),
|
||||
("Platinum", 1900),
|
||||
("Diamond", 2200)
|
||||
];
|
||||
|
||||
private static (string Name, int Division) GetTier(int elo)
|
||||
{
|
||||
var bandIndex = 0;
|
||||
for (var i = 0; i < TierBands.Length; i++)
|
||||
{
|
||||
if (elo >= TierBands[i].FloorElo)
|
||||
bandIndex = i;
|
||||
}
|
||||
|
||||
var band = TierBands[bandIndex];
|
||||
var nextFloor = bandIndex + 1 < TierBands.Length ? TierBands[bandIndex + 1].FloorElo : band.FloorElo + 300;
|
||||
var bandWidth = nextFloor - band.FloorElo;
|
||||
var progress = bandWidth > 0 ? (elo - band.FloorElo) / (double)bandWidth : 0;
|
||||
var division = 3 - Math.Clamp((int)(progress * 3), 0, 2);
|
||||
return (band.Name, division);
|
||||
}
|
||||
|
||||
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
|
||||
{
|
||||
var rating = await GetOrCreateUserRatingAsync(userId);
|
||||
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
||||
}
|
||||
|
||||
public Task ResetUserAsync(string userId, DateTime nowUtc) =>
|
||||
_users.UpdateOneAsync(
|
||||
rating => rating.UserId == userId,
|
||||
Builders<UserRating>.Update
|
||||
.Set(rating => rating.OverallElo, BaseElo)
|
||||
.Set(rating => rating.RatedQuestionCount, 0)
|
||||
.Set(rating => rating.CategoryElos, DefaultCategoryElos())
|
||||
.Set(rating => rating.HistoryBaselineElo, null)
|
||||
.Set(rating => rating.HistoryStartedUtc, null)
|
||||
.Set(rating => rating.UpdatedUtc, nowUtc),
|
||||
new UpdateOptions { IsUpsert = false });
|
||||
|
||||
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
||||
{
|
||||
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
using QuestionsApi.Models;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public interface IStripeAccessGateway
|
||||
{
|
||||
Task<StripeCheckoutResult> CreateCheckoutAsync(AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class StripeAccessGateway(IConfiguration configuration) : IStripeAccessGateway
|
||||
{
|
||||
public async Task<StripeCheckoutResult> CreateCheckoutAsync(
|
||||
AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken)
|
||||
{
|
||||
var secretKey = configuration["Stripe:SecretKey"];
|
||||
if (string.IsNullOrWhiteSpace(secretKey)) throw new InvalidOperationException("Stripe secret key is not configured.");
|
||||
var successUrl = configuration["Stripe:AccessSuccessUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}";
|
||||
var cancelUrl = configuration["Stripe:AccessCancelUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-canceled.html";
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["access_purchase_id"] = purchase.Id ?? "",
|
||||
["access_sku"] = product.Sku,
|
||||
["user_id"] = purchase.UserId
|
||||
};
|
||||
var service = new SessionService(new StripeClient(secretKey));
|
||||
var session = await service.CreateAsync(new SessionCreateOptions
|
||||
{
|
||||
Mode = "payment",
|
||||
SuccessUrl = successUrl,
|
||||
CancelUrl = cancelUrl,
|
||||
ClientReferenceId = purchase.Id,
|
||||
CustomerCreation = "always",
|
||||
BillingAddressCollection = "auto",
|
||||
AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true },
|
||||
Metadata = metadata,
|
||||
PaymentIntentData = new SessionPaymentIntentDataOptions { Metadata = metadata },
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
Quantity = 1,
|
||||
PriceData = new SessionLineItemPriceDataOptions
|
||||
{
|
||||
Currency = product.Currency,
|
||||
UnitAmount = product.AmountCents,
|
||||
ProductData = new SessionLineItemPriceDataProductDataOptions
|
||||
{
|
||||
Name = product.Name,
|
||||
Description = "A manually activated RanaZe access entitlement."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}, null, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(session.Id) || string.IsNullOrWhiteSpace(session.Url))
|
||||
throw new InvalidOperationException("Stripe did not return a checkout URL.");
|
||||
return new StripeCheckoutResult(session.Id, session.Url);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserAccessAccounts", "AccessPurchases"
|
||||
"UserCreditAccounts"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html",
|
||||
"AccessSuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"AccessCancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"PasswordReset": {
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html",
|
||||
"AccessSuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"AccessCancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Steam": {
|
||||
"Realm": "https://s3q.ranaze.com",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 2v6.29a2 2 0 0 1-.5 1.32L3.9 17.8A2 2 0 0 0 5.4 21h13.2a2 2 0 0 0 1.5-3.2l-5.6-8.19A2 2 0 0 1 14 8.29V2" />
|
||||
<path d="M6.5 15h11" />
|
||||
<path d="M8.5 2h7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c2ud26rg3exo1"
|
||||
path="res://.godot/imported/flask-conical.svg-f4d9011dce33718bd3c2a0f719a8656a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/flask-conical.svg"
|
||||
dest_files=["res://.godot/imported/flask-conical.svg-f4d9011dce33718bd3c2a0f719a8656a.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 3h12l4 6-10 12L2 9Z" />
|
||||
<path d="M11 3 8 9l4 12 4-12-3-6" />
|
||||
<path d="M2 9h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://caty3viul0634"
|
||||
path="res://.godot/imported/gem.svg-33acfe3fd8cfb097f65cc061d34abd2e.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/gem.svg"
|
||||
dest_files=["res://.godot/imported/gem.svg-33acfe3fd8cfb097f65cc061d34abd2e.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cwt1kxtnhfgqo"
|
||||
path="res://.godot/imported/rotate-ccw.svg-72097dc869bb37466442f5645fc6d506.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/rotate-ccw.svg"
|
||||
dest_files=["res://.godot/imported/rotate-ccw.svg-72097dc869bb37466442f5645fc6d506.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 381 B |
@@ -0,0 +1,43 @@
|
||||
[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
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://jwyvc10c44uu"
|
||||
path="res://.godot/imported/cabinet_frame.png-97e2b6084ba7b318c962ef3eba634aa6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/cabinet_frame.png"
|
||||
dest_files=["res://.godot/imported/cabinet_frame.png-97e2b6084ba7b318c962ef3eba634aa6.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cqrjyobyp5h45"
|
||||
path="res://.godot/imported/lever.png-08544c384e43fad84ca6f4c75fce0c0f.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/lever.png"
|
||||
dest_files=["res://.godot/imported/lever.png-08544c384e43fad84ca6f4c75fce0c0f.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bgp8erpmjoel7"
|
||||
path="res://.godot/imported/slot_coin.png-0cc970c575e374ae6106c943cfe43ee1.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_coin.png"
|
||||
dest_files=["res://.godot/imported/slot_coin.png-0cc970c575e374ae6106c943cfe43ee1.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bi1vl8p2q6bgn"
|
||||
path="res://.godot/imported/slot_coin_stack.png-f16acecc5a2cc4262b0bf33096e5a7d6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_coin_stack.png"
|
||||
dest_files=["res://.godot/imported/slot_coin_stack.png-f16acecc5a2cc4262b0bf33096e5a7d6.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dd00eb70k5wbn"
|
||||
path="res://.godot/imported/slot_gem.png-2012a867d096c962431a19f1d3e3a5c9.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_gem.png"
|
||||
dest_files=["res://.godot/imported/slot_gem.png-2012a867d096c962431a19f1d3e3a5c9.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dqmvdujjawr5f"
|
||||
path="res://.godot/imported/slot_gift.png-8717ecd0f5473181fb969b2aa00068db.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_gift.png"
|
||||
dest_files=["res://.godot/imported/slot_gift.png-8717ecd0f5473181fb969b2aa00068db.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c8r8uookc1ngx"
|
||||
path="res://.godot/imported/slot_potion.png-ecd877764f2c68367f1819752498d30a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_potion.png"
|
||||
dest_files=["res://.godot/imported/slot_potion.png-ecd877764f2c68367f1819752498d30a.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://fsejopj5yup5"
|
||||
path="res://.godot/imported/slot_shield.png-f53f74d032415edb239232a0bab0bef4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_shield.png"
|
||||
dest_files=["res://.godot/imported/slot_shield.png-f53f74d032415edb239232a0bab0bef4.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -1,82 +0,0 @@
|
||||
# Access economy
|
||||
|
||||
RanaZe is free to install and use on the public web client and on Steam. An
|
||||
account is required. The server is authoritative for quotas, credits, token
|
||||
inventory, prices, and grants.
|
||||
|
||||
## QBank limits and credits
|
||||
|
||||
- Standard QBank use includes 20 completed questions in a rolling 24-hour
|
||||
window. An answer or skip consumes one slot.
|
||||
- An eligible first answer in the current QBank cycle earns one credit,
|
||||
regardless of correctness. Skips and repeat answers earn no credit.
|
||||
- A user can earn at most 20 credits in a rolling 24-hour window, including
|
||||
while an unlimited pass is active.
|
||||
- 100 earned credits can be redeemed for 24 hours of unlimited standard QBank
|
||||
use. Credits cannot be purchased.
|
||||
- Paid access tokens stay in inventory until manually activated. Activation
|
||||
extends from the later of the current time or the current pass expiration.
|
||||
|
||||
Other study tools remain free. An unlimited QBank pass changes only the standard
|
||||
QBank completion limit.
|
||||
|
||||
## Product catalog
|
||||
|
||||
| SKU | Grant | Price |
|
||||
| --- | --- | ---: |
|
||||
| `access_day_1` | one manually activated 24-hour QBank token | $1.00 |
|
||||
| `access_week_1` | one manually activated 168-hour QBank token | $5.00 |
|
||||
| `access_month_1` | one manually activated 720-hour QBank token | $20.00 |
|
||||
| `exam_token_1` | one exam simulation | $1.99 |
|
||||
| `qbank_reset_1` | one QBank reset | $4.99 |
|
||||
|
||||
A reset requires explicit confirmation and is blocked while an exam simulation
|
||||
is active. It starts a new personal QBank cycle, resets personal QBank rating
|
||||
and current-cycle performance, and makes questions eligible as new samples.
|
||||
Existing global aggregate samples are not removed. Bookmarks, notes, and review
|
||||
content remain.
|
||||
|
||||
Existing accounts begin with zero credits and no tokens. Historical attempts
|
||||
are not backfilled into credits.
|
||||
|
||||
## Web checkout
|
||||
|
||||
Web purchases use Stripe-hosted Checkout. Configure:
|
||||
|
||||
```text
|
||||
Stripe__SecretKey
|
||||
Stripe__AccessWebhookSecret
|
||||
Stripe__AccessSuccessUrl
|
||||
Stripe__AccessCancelUrl
|
||||
```
|
||||
|
||||
Create separate access webhook endpoints:
|
||||
|
||||
- UAT test mode: `https://us3q.ranaze.com/api/access/stripe-webhook`
|
||||
- production live mode: `https://s3q.ranaze.com/api/access/stripe-webhook`
|
||||
|
||||
Each endpoint has a distinct signing secret. Store them as
|
||||
`UAT_STRIPE_ACCESS_WEBHOOK_SECRET` and
|
||||
`PRD_STRIPE_ACCESS_WEBHOOK_SECRET` in Gitea. Subscribe both endpoints to
|
||||
`checkout.session.completed`, `checkout.session.async_payment_succeeded`,
|
||||
`checkout.session.async_payment_failed`, and `checkout.session.expired`.
|
||||
|
||||
Prices and grants are selected from the API catalog; the client never supplies
|
||||
an amount. Webhook signatures, expected amount, currency, session, and purchase
|
||||
ownership are verified before an idempotent grant is applied.
|
||||
|
||||
## Steam checkout
|
||||
|
||||
The Windows Steam export has the `steam` feature flag and routes purchase
|
||||
requests to the disabled Steam checkout endpoint. It does not expose Stripe
|
||||
checkout. The endpoint intentionally returns 503 until the Steamworks AppID,
|
||||
Microtransactions product catalog, server credentials, callbacks,
|
||||
reconciliation, refund handling, and sandbox tests are implemented.
|
||||
|
||||
## Operational checks
|
||||
|
||||
Before release, verify all products in Stripe test mode and then live mode.
|
||||
Exercise duplicate webhook delivery and concurrent quota submissions. Confirm
|
||||
that data export/deletion includes `UserAccessAccounts` and `AccessPurchases`.
|
||||
Monitor rejected checkouts, webhook verification failures, unmatched payments,
|
||||
grant failures, and quota conflicts.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Question-completion credits
|
||||
|
||||
This branch introduces credit earning only. It does not impose a question
|
||||
limit, redeem credits, sell products, activate passes, consume exam tokens, or
|
||||
reset QBank progress.
|
||||
|
||||
- A successfully stored first answer earns one credit regardless of whether it
|
||||
is correct.
|
||||
- A repeated answer does not earn another credit.
|
||||
- A skipped question does not earn credit.
|
||||
- At most 20 credits can be earned in a rolling 24-hour window.
|
||||
- Historical attempts are not backfilled.
|
||||
- `GET /api/credits` returns the authenticated user's balance and current
|
||||
rolling-window capacity.
|
||||
- The first-attempt response also includes the updated credit status.
|
||||
|
||||
The API is authoritative. Credit grants use the immutable attempt ID as their
|
||||
idempotency key, so retrying the same completed question cannot grant another
|
||||
credit. Credit accounts are included in account export and deletion.
|
||||
@@ -1,275 +0,0 @@
|
||||
# Steam alpha release checklist
|
||||
|
||||
Use this as the go/no-go record for the first external Windows alpha of
|
||||
**RanaZe: Step 3 Trainer**. Put the owner, completion date, and a link to the
|
||||
evidence beside each checked item. An unchecked **BLOCKER** means do not admit
|
||||
testers.
|
||||
|
||||
## Release decision
|
||||
|
||||
- [x] **BLOCKER — Choose the release vehicle.** **Decision: release the app
|
||||
free on Steam and keep the existing public web client available. Both clients
|
||||
use the same account access model. QBank passes and study tokens are optional
|
||||
in-app purchases; Steam purchases remain disabled until Steamworks
|
||||
Microtransactions are configured.**
|
||||
- [ ] Record the decision, target date, tester count, supported countries,
|
||||
tester-selection method, feedback channel, and person with final go/no-go
|
||||
authority.
|
||||
- [ ] Define alpha success and stop conditions: minimum completed sessions,
|
||||
acceptable crash/error rate, critical defect threshold, support response
|
||||
time, server-capacity ceiling, and the date results will be reviewed.
|
||||
- [ ] Define the advertised alpha scope. List working features, known
|
||||
limitations, expected resets or breaking changes, and features explicitly
|
||||
excluded from this test.
|
||||
|
||||
Decision: Free Steam alpha plus the existing public web client; shared freemium access model
|
||||
Target date/time and time zone: ____________________
|
||||
Release owner: ____________________
|
||||
Build commit SHA: ____________________
|
||||
Steam base AppID: ____________________
|
||||
Steam Playtest AppID (if used): ____________________
|
||||
|
||||
## Steamworks account and application
|
||||
|
||||
- [ ] **BLOCKER — Complete Steamworks onboarding.** Verify the legal business
|
||||
name, bank and tax details, identity verification, Steam Distribution
|
||||
Agreement, and team permissions.
|
||||
- [ ] Pay and activate the Steam Direct app credit for the base application
|
||||
(currently USD 100 per product, subject to tax).
|
||||
- [ ] Create the base application and save its AppID above.
|
||||
- [ ] For a Playtest, create the child Playtest app from **Associated Packages
|
||||
& DLC**, choose its public name carefully, and save its AppID above. The
|
||||
public Playtest name cannot be changed after release.
|
||||
- [ ] Restrict Steamworks permissions to the people who need them; require
|
||||
Steam Guard and make sure at least two trusted people can handle a release.
|
||||
- [ ] Decide whether the main store page will be public. A Playtest can instead
|
||||
use keys without making signups or the store page public, but testers should
|
||||
not be promised confidentiality merely because access is limited.
|
||||
|
||||
## Content, legal, and privacy clearance
|
||||
|
||||
- [ ] **BLOCKER — Complete the repository's outstanding originality review for
|
||||
every released question and explanation.** Retain reviewer, date, sources,
|
||||
revisions, and disposition.
|
||||
- [ ] **BLOCKER — Complete the outstanding copyright/license audit for every
|
||||
image-bearing question.** Remove or replace anything whose reuse rights
|
||||
cannot be established.
|
||||
- [ ] **BLOCKER — Complete the duplicate-question review and authoritative
|
||||
reference verification described in `README.md`.** Release only published,
|
||||
unflagged, cleared content.
|
||||
- [ ] Review all other shipped assets—branding, fonts, icons, audio, code,
|
||||
screenshots, trailer footage, and third-party notices—and retain a license
|
||||
inventory.
|
||||
- [ ] Have qualified counsel/ownership review the Terms of Service, Privacy
|
||||
Policy, educational/medical disclaimer, trademark claims, refund/customer
|
||||
terms, and the handling of study notes or other user-entered data.
|
||||
- [ ] Confirm the Steam store description and screenshots never imply medical
|
||||
advice, clinical certification, guaranteed exam performance, or affiliation
|
||||
with USMLE, NBME, FSMB, or a licensing authority.
|
||||
- [ ] Complete Steam's Content Survey honestly and make its answers consistent
|
||||
with both the build and store page.
|
||||
- [ ] Verify `[email protected]` is monitored and that account data export,
|
||||
permanent deletion, provider unlinking, and privacy requests work end to end.
|
||||
- [ ] Publish and test the production legal pages:
|
||||
`https://step3.ranaze.com/terms.html` and
|
||||
`https://step3.ranaze.com/privacy.html`.
|
||||
|
||||
## Production service readiness
|
||||
|
||||
- [ ] **BLOCKER — Complete the initial PRD launch and question promotion in
|
||||
`ENVIRONMENTS.md`, including dry run, approval, apply, and post-promotion
|
||||
verification.**
|
||||
- [ ] Confirm PRD uses a unique JWT key, PRD Mongo database, production host
|
||||
names, production legal versions, and production secrets; no UAT data or
|
||||
credentials may be present.
|
||||
- [ ] Configure and test the live Steam callbacks, including:
|
||||
`https://s3q.ranaze.com/api/Auth/steam/callback` and
|
||||
`https://s3q.ranaze.com/api/Auth/external/steam/callback`.
|
||||
- [ ] From a clean Steam account, test create-account, link-existing-account,
|
||||
sign-out/sign-in, expired/cancelled authentication, unlinking, and account
|
||||
deletion. Confirm the app never asks for or receives a Steam password.
|
||||
- [ ] **BLOCKER — Configure and test Stripe access purchases on the web** at
|
||||
both `https://us3q.ranaze.com/api/access/stripe-webhook` in test mode and
|
||||
`https://s3q.ranaze.com/api/access/stripe-webhook` in live mode. Use distinct
|
||||
signing secrets, subscribe the required Checkout events, and test all five
|
||||
fixed-price products. Keep the separate contribution flow only if wanted.
|
||||
- [ ] **BLOCKER — Keep Stripe checkout unavailable in the Steam build.** Before
|
||||
enabling purchases there, configure Steamworks Microtransactions, product
|
||||
IDs, callbacks, reconciliation, refunds, and sandbox tests. Confirm the
|
||||
desktop export carries the `steam` feature flag.
|
||||
- [ ] Exercise production readiness endpoints and dashboards, and configure
|
||||
alerts for API unavailability, elevated 5xx responses, authentication
|
||||
failures, database capacity, and certificate expiry.
|
||||
- [ ] Load-test the API at the planned tester count plus headroom. Document a
|
||||
tester-admission cap and who can stop new admissions.
|
||||
- [ ] Take and restore-test a production database backup. Record retention,
|
||||
rollback, incident-response, and credential-rotation procedures.
|
||||
|
||||
## Windows release build
|
||||
|
||||
- [ ] Install the required Godot 4.6.x release export templates and confirm the
|
||||
pinned engine version used for the release.
|
||||
- [ ] Update all visible versions from `pre-alpha`/`0.1.0` to the approved alpha
|
||||
version in `project.godot`, `export_presets.cfg`, the UI, and release notes.
|
||||
- [ ] Decide whether to disable the Windows console wrapper
|
||||
(`debug/export_console_wrapper=1`) for tester-facing builds.
|
||||
- [ ] Decide whether to Authenticode-sign `RanaZe.exe`; if signing, protect the
|
||||
certificate, timestamp the executable, and verify the signature.
|
||||
- [ ] Export only from the reviewed release commit, with a clean working tree,
|
||||
using Git Bash:
|
||||
|
||||
```bash
|
||||
GODOT_BIN=/absolute/path/to/Godot_v4.6.x-stable_win64_console.exe \
|
||||
./tools/export_steam_release.sh build/windows/RanaZe.exe
|
||||
```
|
||||
|
||||
- [ ] **BLOCKER — Verify the export reports `deployment_environment="prd"`,
|
||||
the exact release commit in `build_revision`, and only `s3q.ranaze.com`
|
||||
production API endpoints.**
|
||||
- [ ] Confirm the upload directory contains `RanaZe.exe`, `RanaZe.pck`, and all
|
||||
runtime dependencies, but no source, secrets, `.env` files, export backup,
|
||||
debug symbols not intended for testers, or test data.
|
||||
- [ ] Virus-scan the staged files and archive a manifest containing SHA-256
|
||||
hashes, build command, Godot version, commit SHA, build time, and builder.
|
||||
|
||||
## SteamPipe and Steam configuration
|
||||
|
||||
- [ ] Download the current Steamworks SDK. The SDK is required to upload with
|
||||
SteamPipe; integrating the Steamworks runtime API is optional for this app's
|
||||
existing browser-based Steam OpenID flow.
|
||||
- [ ] Create the Windows depot and default package for the selected AppID.
|
||||
- [ ] Create version-controlled SteamPipe app/depot VDF templates with AppIDs
|
||||
but no Steam credentials. Point the depot content root at the staged Windows
|
||||
directory.
|
||||
- [ ] Upload through `steamcmd`, add a human-readable build description with
|
||||
the alpha version and commit SHA, and first assign it to a password-protected
|
||||
`alpha` branch.
|
||||
- [ ] Configure the Windows launch option to run `RanaZe.exe` from the depot
|
||||
root. Verify OS is Windows and architecture is 64-bit.
|
||||
- [ ] Set supported language(s) to match what is actually available in the
|
||||
build. Do not claim interface, audio, or subtitle support that has not been
|
||||
tested.
|
||||
- [ ] Configure install/uninstall behavior and required redistributables, if
|
||||
any. Confirm uninstall does not remove cloud/server account data and that
|
||||
locally generated sensitive files are understood.
|
||||
- [ ] If using a Playtest, upload and configure the build on the **Playtest
|
||||
AppID**, not merely the base game's depot.
|
||||
- [ ] Test installation, launch, update, rollback, file verification, uninstall,
|
||||
and reinstall entirely through the Steam client—never only by running the
|
||||
local export.
|
||||
|
||||
## Store and tester communication
|
||||
|
||||
- [ ] Prepare truthful short and long descriptions, developer/publisher names,
|
||||
tags, platform/language support, system requirements, support contact, and
|
||||
privacy-policy URL.
|
||||
- [ ] Create required store, library, community, and client graphical assets at
|
||||
Steam's current dimensions. Use actual alpha screenshots and clearly label
|
||||
footage or features that are not representative of the current build.
|
||||
- [ ] Check all asset rights and Steam's graphical asset rules. Localize text
|
||||
appearing in graphical assets for every language the game claims to support.
|
||||
- [ ] For Early Access only, complete the Early Access questionnaire, describe
|
||||
the current state and update plan without promises the team cannot guarantee,
|
||||
set a price justified by the current build, and use required Early Access
|
||||
branding wherever keys are sold.
|
||||
- [ ] Draft tester instructions covering scope, known issues, medical
|
||||
disclaimer, feedback/bug-report format, log/screenshot handling, privacy,
|
||||
support hours, update/reset expectations, and test end date.
|
||||
- [ ] Draft the launch announcement, first patch notes, outage message, and
|
||||
alpha-ending message before admitting testers.
|
||||
|
||||
## Release-candidate acceptance
|
||||
|
||||
- [ ] Promote the exact uploaded build to the protected `alpha` branch and have
|
||||
at least two people test it independently on clean Windows 10 and Windows 11
|
||||
user profiles.
|
||||
- [ ] Test at 1920×1080 plus a smaller supported resolution and at 100%, 125%,
|
||||
and 150% Windows scaling. Check keyboard/mouse navigation, text clipping,
|
||||
external browser transitions, focus return, and readable error states.
|
||||
- [ ] Run the complete learning journey: registration and legal consent,
|
||||
practice question, randomized choices, answer/explanation/reference,
|
||||
dashboard, review queue, notes/bookmarks, flashcards, CCS, feedback/report,
|
||||
data export, and account deletion.
|
||||
- [ ] Run the access journey on web: questions 1–20, rolling-window exhaustion,
|
||||
one credit per eligible first attempt, no credit for skips/repeats, the
|
||||
20-credit earning cap, 100-credit redemption, purchase and manual activation
|
||||
of each pass, exam-token consumption, reset confirmation, and retained
|
||||
bookmarks/notes after reset.
|
||||
- [ ] Test offline start, connection loss during a session, API timeout, expired
|
||||
token, server maintenance, malformed response, and recovery without corrupting
|
||||
learner progress.
|
||||
- [ ] Run automated API/Godot tests and the production-equivalent smoke suite;
|
||||
attach results to the release record. Do not rely only on the web UAT smoke
|
||||
suite for the Windows Steam build.
|
||||
- [ ] Verify every feature and claim shown on the store page exists in the
|
||||
submitted build.
|
||||
- [ ] Triage all findings. Zero unresolved critical issues; every accepted
|
||||
non-critical issue has an owner, rationale, workaround, and target release.
|
||||
- [ ] Obtain written sign-off from engineering, content/medical review, legal/
|
||||
privacy, operations, and the release owner.
|
||||
|
||||
## Valve review and scheduling
|
||||
|
||||
- [ ] Complete the selected app's Steamworks store-presence checklist and submit
|
||||
it for review. Valve advises allowing at least seven business days even though
|
||||
reviews commonly take three to five business days.
|
||||
- [ ] After store presence is submitted, complete the game-build checklist,
|
||||
place a mostly final build on the default branch for Valve, and submit the
|
||||
build for review.
|
||||
- [ ] Resolve every Valve review note and repeat the relevant acceptance tests
|
||||
against any changed build or store material.
|
||||
- [ ] If publishing the main Coming Soon page, allow at least the current
|
||||
Steam-required visibility period before an Early Access/full release. Confirm
|
||||
the exact eligible release date shown in Steamworks rather than calculating
|
||||
it from memory.
|
||||
- [ ] For a Playtest, choose limited or open signup, batch size/countries, or
|
||||
key distribution. Never charge for Playtest access or sell Playtest keys.
|
||||
- [ ] Schedule the release while the release owner, backend operator, and
|
||||
support contact are available for the initial monitoring window.
|
||||
|
||||
## Launch day
|
||||
|
||||
- [ ] Freeze unrelated PRD and client changes; confirm the approved commit,
|
||||
Steam build ID, depot manifest, store copy, tester count, and rollback build.
|
||||
- [ ] Re-run public health/readiness checks, login, one question flow, feedback,
|
||||
legal links, database backup status, alerts, and Steam callback.
|
||||
- [ ] Set the approved build live on the intended Steam branch.
|
||||
- [ ] For a Playtest, set the app **Playable**, then admit only the planned
|
||||
first batch or enable the approved signup mode.
|
||||
- [ ] Install and launch from a tester account with no developer permissions.
|
||||
Verify download size, executable, version/SHA, production environment, and
|
||||
end-to-end authentication.
|
||||
- [ ] Publish tester instructions/announcement and open the staffed feedback
|
||||
channel.
|
||||
- [ ] Record release time, Steam build ID, admitted tester count, incidents,
|
||||
decisions, and changes in the release log.
|
||||
|
||||
## Monitoring, rollback, and alpha close
|
||||
|
||||
- [ ] Monitor API/error/auth rates, capacity, reports, support inbox, Steam
|
||||
discussions, and content-safety feedback at the defined cadence.
|
||||
- [ ] Acknowledge critical medical-accuracy or copyright reports immediately;
|
||||
unpublish affected content while it is reviewed.
|
||||
- [ ] Stop admissions for capacity or support overload. Set the Playtest to
|
||||
**Not Playable** or roll back its live branch when a stop condition is met,
|
||||
and communicate the impact to testers.
|
||||
- [ ] Patch from a reviewed commit, repeat affected acceptance tests, upload to
|
||||
the protected branch, test through Steam, then promote the exact build.
|
||||
- [ ] At alpha end, announce the deadline, stop signups, communicate account/
|
||||
progress retention, and set the Playtest to **Not Playable** when appropriate.
|
||||
- [ ] Export metrics without exposing personal data; triage feedback into owned
|
||||
work; hold a retrospective; document the next test's entry criteria.
|
||||
- [ ] Retain build manifests, approvals, test evidence, incidents, content
|
||||
review records, and the final release log according to the team's policy.
|
||||
|
||||
## Official Steam references
|
||||
|
||||
- [Steam Playtest](https://partner.steamgames.com/doc/features/playtest)
|
||||
- [Steam release process](https://partner.steamgames.com/doc/store/releasing)
|
||||
- [Steam review process](https://partner.steamgames.com/doc/store/review_process)
|
||||
- [Uploading to Steam / SteamPipe](https://partner.steamgames.com/doc/sdk/uploading)
|
||||
- [Steam Direct fee](https://partner.steamgames.com/doc/gettingstarted/appfee)
|
||||
- [Steamworks onboarding](https://partner.steamgames.com/doc/gettingstarted/onboarding)
|
||||
- [Content Survey](https://partner.steamgames.com/doc/gettingstarted/contentsurvey)
|
||||
- [Early Access](https://partner.steamgames.com/doc/store/earlyaccess)
|
||||
- [Store graphical assets](https://partner.steamgames.com/doc/store/assets/standard)
|
||||
@@ -7,9 +7,9 @@ Contributions are not tax-deductible.
|
||||
## Stripe test-mode setup
|
||||
|
||||
1. In the Stripe Dashboard, switch to test mode.
|
||||
2. Create the UAT test-mode webhook endpoint at:
|
||||
2. Create a webhook endpoint at:
|
||||
|
||||
`https://us3q.ranaze.com/api/donations/stripe-webhook`
|
||||
`https://s3q.ranaze.com/api/donations/stripe-webhook`
|
||||
|
||||
3. Subscribe the endpoint to these events:
|
||||
|
||||
@@ -22,12 +22,10 @@ Contributions are not tax-deductible.
|
||||
5. Copy a test secret API key (`sk_test_...`).
|
||||
6. Add both values as Gitea Actions secrets in the API repository:
|
||||
|
||||
- `UAT_STRIPE_SECRET_KEY`
|
||||
- `UAT_STRIPE_WEBHOOK_SECRET`
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
The deployment workflow writes these values to the environment's
|
||||
`step3-runtime-secrets` Kubernetes secret. Never commit either value to the
|
||||
repository.
|
||||
The deployment workflow writes these values to the Kubernetes secret `medmind-stripe`. Never commit either value to the repository.
|
||||
|
||||
## Stripe account settings
|
||||
|
||||
@@ -49,10 +47,9 @@ The client-visible status endpoint returns only the donation ID, amount, currenc
|
||||
|
||||
## Live-mode cutover
|
||||
|
||||
1. Create `https://s3q.ranaze.com/api/donations/stripe-webhook` in Stripe live
|
||||
mode.
|
||||
2. Set `PRD_STRIPE_SECRET_KEY` to the live secret key (`sk_live_...`).
|
||||
3. Set `PRD_STRIPE_WEBHOOK_SECRET` to that endpoint's signing secret.
|
||||
1. Create the same webhook endpoint in Stripe live mode.
|
||||
2. Replace `STRIPE_SECRET_KEY` with the live secret key (`sk_live_...`).
|
||||
3. Replace `STRIPE_WEBHOOK_SECRET` with the live endpoint's signing secret.
|
||||
4. Trigger a deployment and complete a small real contribution.
|
||||
5. Confirm the payment, receipt, webhook delivery, and `paid` status before announcing availability.
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ 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=false
|
||||
html/experimental_virtual_keyboard=true
|
||||
progressive_web_app/enabled=false
|
||||
|
||||
[preset.1]
|
||||
@@ -35,7 +35,7 @@ platform="Windows Desktop"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features="steam"
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
|
||||
@@ -56,11 +56,6 @@ spec:
|
||||
secretKeyRef:
|
||||
name: step3-runtime-secrets
|
||||
key: stripe-webhook-secret
|
||||
- name: Stripe__AccessWebhookSecret
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-runtime-secrets
|
||||
key: stripe-access-webhook-secret
|
||||
- name: Google__ClientId
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -29,7 +29,5 @@ configMapGenerator:
|
||||
- Legal__PrivacyUrl=https://step3.ranaze.com/privacy.html
|
||||
- Stripe__SuccessUrl=https://step3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__CancelUrl=https://step3.ranaze.com/donation-canceled.html
|
||||
- Stripe__AccessSuccessUrl=https://step3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__AccessCancelUrl=https://step3.ranaze.com/donation-canceled.html
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
@@ -26,7 +26,5 @@ configMapGenerator:
|
||||
- Legal__PrivacyUrl=https://ustep3.ranaze.com/privacy.html
|
||||
- Stripe__SuccessUrl=https://ustep3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__CancelUrl=https://ustep3.ranaze.com/donation-canceled.html
|
||||
- Stripe__AccessSuccessUrl=https://ustep3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__AccessCancelUrl=https://ustep3.ranaze.com/donation-canceled.html
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
@@ -13,14 +13,13 @@ 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"
|
||||
boot_splash/fullsize=false
|
||||
boot_splash/use_filter=true
|
||||
config/icon="res://assets/branding/ranaze-app-icon.png"
|
||||
|
||||
[autoload]
|
||||
|
||||
@@ -40,10 +39,10 @@ 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"
|
||||
AccessService="*res://scripts/autoload/access_service.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -58,9 +57,9 @@ 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]
|
||||
@@ -73,7 +72,6 @@ notes_api_url="https://us3q.ranaze.com/api/notes"
|
||||
dashboard_api_url="https://us3q.ranaze.com/api/dashboard"
|
||||
profile_api_url="https://us3q.ranaze.com/api/profile"
|
||||
donations_api_url="https://us3q.ranaze.com/api/donations"
|
||||
access_api_url="https://us3q.ranaze.com/api/access"
|
||||
review_queue_api_url="https://us3q.ranaze.com/api/review-queue"
|
||||
flashcards_api_url="https://us3q.ranaze.com/api/flashcards"
|
||||
build_revision="development"
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
extends Node
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/access"
|
||||
const REQUEST_TIMEOUT_SECONDS := 30.0
|
||||
const POLL_INTERVAL_SECONDS := 3.0
|
||||
const POLL_LIMIT := 200
|
||||
const CHECKOUT_STORAGE_KEY := "ranaze_access_checkout_url"
|
||||
|
||||
signal status_changed(status: Dictionary)
|
||||
signal operation_succeeded(message: String)
|
||||
signal operation_failed(message: String)
|
||||
signal checkout_started(purchase_id: String, checkout_url: String)
|
||||
|
||||
var api_url := DEFAULT_URL
|
||||
var status: Dictionary = {}
|
||||
var status_request: HTTPRequest
|
||||
var action_request: HTTPRequest
|
||||
var purchase_request: HTTPRequest
|
||||
var poll_timer: Timer
|
||||
var pending_action := ""
|
||||
var request_token := ""
|
||||
var active_purchase_id := ""
|
||||
var poll_count := 0
|
||||
var checkout_popup_opened := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(
|
||||
str(ProjectSettings.get_setting("step3/access_api_url", DEFAULT_URL)),
|
||||
"/api/access")
|
||||
status_request = _request_node(_on_status_completed)
|
||||
action_request = _request_node(_on_action_completed)
|
||||
purchase_request = _request_node(_on_purchase_status_completed)
|
||||
poll_timer = Timer.new()
|
||||
poll_timer.wait_time = POLL_INTERVAL_SECONDS
|
||||
poll_timer.timeout.connect(_poll_purchase)
|
||||
add_child(poll_timer)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
if AuthState.is_logged_in:
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if not AuthState.can_send_authenticated_request() or status_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
request_token = AuthState.access_token
|
||||
status_request.request(api_url, PackedStringArray(["Authorization: Bearer %s" % request_token]))
|
||||
|
||||
|
||||
func redeem_credits() -> void:
|
||||
_send_action("redeem", "/redeem-credits", {"operationId": _operation_id("credit-pass")})
|
||||
|
||||
|
||||
func activate_token(token: String) -> void:
|
||||
_send_action("activate", "/activate-token", {
|
||||
"token": token,
|
||||
"operationId": _operation_id("activate-%s" % token)
|
||||
})
|
||||
|
||||
|
||||
func reset_qbank() -> void:
|
||||
_send_action("reset", "/qbank-reset", {
|
||||
"confirmation": "RESET_QBANK",
|
||||
"operationId": _operation_id("qbank-reset")
|
||||
})
|
||||
|
||||
|
||||
func begin_checkout(sku: String) -> void:
|
||||
if OS.has_feature("steam"):
|
||||
_send_action("steam_checkout", "/steam-checkouts", {"sku": sku})
|
||||
return
|
||||
if OS.has_feature("web") and not _prepare_browser_window():
|
||||
operation_failed.emit("Checkout needs a popup. Allow popups for this site, then try again.")
|
||||
return
|
||||
_send_action("checkout", "/web-checkouts", {"sku": sku})
|
||||
|
||||
|
||||
func apply_embedded_status(value: Variant) -> void:
|
||||
if typeof(value) != TYPE_DICTIONARY:
|
||||
return
|
||||
status = value.duplicate(true)
|
||||
status_changed.emit(status)
|
||||
|
||||
|
||||
func has_unlimited_access() -> bool:
|
||||
return bool(status.get("unlimited", false))
|
||||
|
||||
|
||||
func questions_remaining() -> int:
|
||||
return int(status.get("questionsRemaining", 20))
|
||||
|
||||
|
||||
func _send_action(action: String, path: String, payload: Dictionary) -> void:
|
||||
if pending_action != "":
|
||||
operation_failed.emit("Another access request is still in progress.")
|
||||
return
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
operation_failed.emit("Log in before managing access.")
|
||||
return
|
||||
pending_action = action
|
||||
request_token = AuthState.access_token
|
||||
var error := action_request.request(
|
||||
"%s%s" % [api_url.rstrip("/"), path],
|
||||
PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer %s" % request_token
|
||||
]),
|
||||
HTTPClient.METHOD_POST,
|
||||
JSON.stringify(payload))
|
||||
if error != OK:
|
||||
pending_action = ""
|
||||
operation_failed.emit("The access request could not be started.")
|
||||
|
||||
|
||||
func _on_status_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_token)
|
||||
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())
|
||||
apply_embedded_status(parsed)
|
||||
|
||||
|
||||
func _on_action_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var action := pending_action
|
||||
pending_action = ""
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
_close_browser_window()
|
||||
operation_failed.emit(_failure_message("Access request failed.", response_code, body))
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if action == "checkout":
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
_close_browser_window()
|
||||
operation_failed.emit("Checkout returned an invalid response.")
|
||||
return
|
||||
active_purchase_id = str(parsed.get("purchaseId", ""))
|
||||
var checkout_url := str(parsed.get("checkoutUrl", ""))
|
||||
if active_purchase_id.is_empty() or not checkout_url.begins_with("https://"):
|
||||
_close_browser_window()
|
||||
operation_failed.emit("Checkout returned an invalid link.")
|
||||
return
|
||||
_open_checkout_url(checkout_url)
|
||||
checkout_started.emit(active_purchase_id, checkout_url)
|
||||
poll_count = 0
|
||||
poll_timer.start()
|
||||
_poll_purchase()
|
||||
return
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
apply_embedded_status(parsed)
|
||||
operation_succeeded.emit({
|
||||
"redeem": "Your 24-hour QBank pass is active.",
|
||||
"activate": "Your access token was activated.",
|
||||
"reset": "Your QBank has been reset for a new cycle."
|
||||
}.get(action, "Access updated."))
|
||||
|
||||
|
||||
func _poll_purchase() -> void:
|
||||
if active_purchase_id.is_empty() or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
if purchase_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
poll_count += 1
|
||||
if poll_count > POLL_LIMIT:
|
||||
poll_timer.stop()
|
||||
return
|
||||
request_token = AuthState.access_token
|
||||
purchase_request.request(
|
||||
"%s/purchases/%s" % [api_url.rstrip("/"), active_purchase_id.uri_encode()],
|
||||
PackedStringArray(["Authorization: Bearer %s" % request_token]))
|
||||
|
||||
|
||||
func _on_purchase_status_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
|
||||
match str(parsed.get("status", "")):
|
||||
"paid":
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
active_purchase_id = ""
|
||||
operation_succeeded.emit("Purchase confirmed. Your token is ready to activate.")
|
||||
refresh()
|
||||
"failed", "expired", "refunded":
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
active_purchase_id = ""
|
||||
operation_failed.emit("The purchase was not completed.")
|
||||
|
||||
|
||||
func _request_node(callback: Callable) -> HTTPRequest:
|
||||
var node := HTTPRequest.new()
|
||||
node.timeout = REQUEST_TIMEOUT_SECONDS
|
||||
node.request_completed.connect(callback)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _operation_id(prefix: String) -> String:
|
||||
return "%s-%d-%d" % [prefix, Time.get_ticks_usec(), randi()]
|
||||
|
||||
|
||||
func _open_checkout_url(url: String) -> void:
|
||||
if OS.has_feature("web"):
|
||||
JavaScriptBridge.eval(
|
||||
"var ranazeUrl=%s;window.localStorage.setItem('%s',ranazeUrl);if(window.ranazeAccessWindow&&!window.ranazeAccessWindow.closed){window.ranazeAccessWindow.location.replace(ranazeUrl);window.ranazeAccessWindow.focus();}else{window.location.assign(ranazeUrl);}" %
|
||||
[JSON.stringify(url), CHECKOUT_STORAGE_KEY], true)
|
||||
else:
|
||||
OS.shell_open(url)
|
||||
|
||||
|
||||
func _prepare_browser_window() -> bool:
|
||||
checkout_popup_opened = bool(JavaScriptBridge.eval(
|
||||
"window.ranazeAccessWindow=window.open('about:blank','ranaze_access','popup=yes,width=620,height=760,resizable=yes,scrollbars=yes');window.ranazeAccessWindow!==null;",
|
||||
true))
|
||||
return checkout_popup_opened
|
||||
|
||||
|
||||
func _close_browser_window() -> void:
|
||||
if OS.has_feature("web") and checkout_popup_opened:
|
||||
JavaScriptBridge.eval(
|
||||
"window.localStorage.removeItem('%s');if(window.ranazeAccessWindow&&!window.ranazeAccessWindow.closed){window.ranazeAccessWindow.close();}window.ranazeAccessWindow=null;window.focus();" %
|
||||
CHECKOUT_STORAGE_KEY, true)
|
||||
checkout_popup_opened = false
|
||||
|
||||
|
||||
func _failure_message(prefix: String, response_code: int, body: PackedByteArray) -> String:
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
var message := str(parsed.get("message", parsed.get("detail", ""))).strip_edges()
|
||||
if not message.is_empty():
|
||||
return message
|
||||
return "%s Status %d." % [prefix, response_code]
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if AuthState.is_logged_in:
|
||||
refresh()
|
||||
else:
|
||||
status = {}
|
||||
active_purchase_id = ""
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
status_changed.emit(status)
|
||||
@@ -1 +0,0 @@
|
||||
uid://ch2scupdmgjpj
|
||||
@@ -256,18 +256,26 @@ func restore_simulation_responses(saved_responses: Array) -> void:
|
||||
"is_correct": false,
|
||||
"eliminated_choice_ids": response.get("eliminatedChoiceIds", []).duplicate(),
|
||||
"flagged": bool(response.get("flagged", false)),
|
||||
"elapsed_seconds": int(response.get("elapsedSeconds", 0))
|
||||
"elapsed_seconds": int(response.get("elapsedSeconds", 0)),
|
||||
"is_submitted": _simulation_response_is_submitted(response)
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
func _simulation_response_is_submitted(response: Dictionary) -> bool:
|
||||
var submitted: Variant = response.get("isSubmitted", null)
|
||||
if submitted == null:
|
||||
return not str(response.get("choiceId", "")).is_empty()
|
||||
return bool(submitted)
|
||||
|
||||
|
||||
func get_current_question() -> Dictionary:
|
||||
if active_questions.is_empty() or current_index < 0 or current_index >= active_questions.size():
|
||||
return {}
|
||||
return active_questions[current_index]
|
||||
|
||||
|
||||
func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dictionary:
|
||||
func answer_current(choice_id: String, eliminated: Array, flagged: bool, is_submitted := false) -> Dictionary:
|
||||
var question := get_current_question()
|
||||
if question.is_empty():
|
||||
return {}
|
||||
@@ -284,6 +292,7 @@ func answer_current(choice_id: String, eliminated: Array, flagged: bool) -> Dict
|
||||
answers[current_index] = answer
|
||||
if session_kind == "simulation":
|
||||
answer["is_correct"] = false
|
||||
answer["is_submitted"] = is_submitted
|
||||
answers[current_index] = answer
|
||||
session_changed.emit()
|
||||
return answer
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bi323du7o7dn2
|
||||
@@ -8,6 +8,7 @@ signal state_changed
|
||||
signal request_failed(message: String)
|
||||
signal outcome_changed
|
||||
signal history_changed
|
||||
signal exam_token_required
|
||||
|
||||
var api_url := DEFAULT_API_URL
|
||||
var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes"
|
||||
@@ -22,6 +23,8 @@ var is_loading := false
|
||||
var pending_action := ""
|
||||
var request_queue: Array = []
|
||||
var request_access_token := ""
|
||||
var last_failed_action := ""
|
||||
var submit_recovery_attempts := 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -67,7 +70,7 @@ func start_next_block() -> void:
|
||||
_send("start", HTTPClient.METHOD_POST, "/%s/blocks/start" % str(simulation.get("id", "")))
|
||||
|
||||
|
||||
func save_response(question_id: String, choice_id: String, eliminated: Array, flagged: bool, elapsed_seconds: int) -> void:
|
||||
func save_response(question_id: String, choice_id: String, eliminated: Array, flagged: bool, elapsed_seconds: int, is_submitted := false) -> void:
|
||||
if simulation.is_empty():
|
||||
return
|
||||
_send("save", HTTPClient.METHOD_PUT, "/%s/responses" % str(simulation.get("id", "")), {
|
||||
@@ -75,13 +78,15 @@ func save_response(question_id: String, choice_id: String, eliminated: Array, fl
|
||||
"choiceId": choice_id,
|
||||
"eliminatedChoiceIds": eliminated,
|
||||
"flagged": flagged,
|
||||
"elapsedSeconds": elapsed_seconds
|
||||
"elapsedSeconds": elapsed_seconds,
|
||||
"isSubmitted": is_submitted
|
||||
})
|
||||
|
||||
|
||||
func submit_block() -> void:
|
||||
if simulation.is_empty():
|
||||
return
|
||||
submit_recovery_attempts = 0
|
||||
_send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", "")))
|
||||
|
||||
|
||||
@@ -140,6 +145,7 @@ func _send_absolute(action: String, method: int, url: String, payload: Dictionar
|
||||
if err != OK:
|
||||
is_loading = false
|
||||
pending_action = ""
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation request could not be started.")
|
||||
|
||||
|
||||
@@ -151,12 +157,30 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_access_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
if _is_stale_submit_failure(action):
|
||||
return
|
||||
if _should_reconcile_submit_failure(action, response_code):
|
||||
submit_recovery_attempts += 1
|
||||
_send(
|
||||
"submit_reconcile",
|
||||
HTTPClient.METHOD_GET,
|
||||
"/%s" % str(simulation.get("id", "")).uri_encode())
|
||||
return
|
||||
var server_message := body.get_string_from_utf8().strip_edges()
|
||||
var error_parsed: Variant = JSON.parse_string(server_message)
|
||||
if typeof(error_parsed) == TYPE_DICTIONARY:
|
||||
var credit_status: Variant = error_parsed.get("credits", {})
|
||||
if typeof(credit_status) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_status(credit_status)
|
||||
if str(error_parsed.get("code", "")) == "exam_token_required":
|
||||
exam_token_required.emit()
|
||||
server_message = str(error_parsed.get("message", server_message))
|
||||
if server_message.length() > 240:
|
||||
server_message = server_message.left(240)
|
||||
var message := "Simulation request failed with status %d." % response_code
|
||||
if not server_message.is_empty():
|
||||
message = "%s %s" % [message, server_message]
|
||||
last_failed_action = action
|
||||
request_failed.emit(message)
|
||||
return
|
||||
if response_code == 204:
|
||||
@@ -169,6 +193,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation API returned an invalid response.")
|
||||
return
|
||||
if action == "readiness":
|
||||
@@ -190,6 +215,25 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
return
|
||||
simulation = _normalize_simulation(parsed)
|
||||
state_changed.emit()
|
||||
if action == "submit":
|
||||
submit_recovery_attempts = 0
|
||||
elif action == "submit_reconcile":
|
||||
if str(simulation.get("state", "")) == "block_active":
|
||||
_send("submit", HTTPClient.METHOD_POST, "/%s/blocks/submit" % str(simulation.get("id", "")))
|
||||
else:
|
||||
submit_recovery_attempts = 0
|
||||
|
||||
|
||||
func _is_stale_submit_failure(action: String) -> bool:
|
||||
return action == "submit" and str(simulation.get("state", "")) in ["between_blocks", "completed"]
|
||||
|
||||
|
||||
func _should_reconcile_submit_failure(action: String, response_code: int) -> bool:
|
||||
return (
|
||||
action == "submit" and
|
||||
response_code == 409 and
|
||||
submit_recovery_attempts == 0 and
|
||||
str(simulation.get("state", "")) == "block_active")
|
||||
|
||||
|
||||
func _process_request_queue() -> void:
|
||||
|
||||
@@ -130,6 +130,14 @@ func reset_qbank_memory() -> void:
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func reset_qbank_eligibility() -> void:
|
||||
_ensure_tracking_fields()
|
||||
profile["completed_question_ids"] = []
|
||||
profile["correct_question_ids"] = []
|
||||
save_profile()
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func apply_question_results(results: Dictionary) -> void:
|
||||
_ensure_daily_current()
|
||||
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
|
||||
|
||||
@@ -19,7 +19,6 @@ signal question_rating_updated(question_id: String, question_elo: int, rating: D
|
||||
signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary)
|
||||
signal question_report_submitted(question_id: String, report_id: String)
|
||||
signal question_report_failed(question_id: String, message: String)
|
||||
signal access_required(message: String)
|
||||
|
||||
var questions: Array = []
|
||||
var request: HTTPRequest
|
||||
@@ -39,6 +38,8 @@ 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 := {}
|
||||
@@ -158,14 +159,6 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(questions_request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
if response_code == 403:
|
||||
var denied: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(denied) == TYPE_DICTIONARY and str(denied.get("code", "")) == "qbank_limit_reached":
|
||||
AccessService.apply_embedded_status(denied.get("access", {}))
|
||||
var denied_message := str(denied.get("message", "Your free QBank allowance is exhausted."))
|
||||
_set_load_error(denied_message, false)
|
||||
access_required.emit(denied_message)
|
||||
return
|
||||
var message := "The connection to the question service was interrupted."
|
||||
if response_code > 0:
|
||||
message = "Questions API request failed with status %d." % response_code
|
||||
@@ -255,6 +248,8 @@ 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 = {}
|
||||
@@ -266,10 +261,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) -> void:
|
||||
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> 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)
|
||||
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds, met_time_limit)
|
||||
|
||||
|
||||
func skip_question(question_id: String) -> void:
|
||||
@@ -395,7 +390,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) -> void:
|
||||
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:
|
||||
var account := AuthState.username.strip_edges()
|
||||
if account == "":
|
||||
return
|
||||
@@ -409,7 +404,7 @@ 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),
|
||||
"completion_id": _new_completion_id(question_id),
|
||||
"met_time_limit": met_time_limit,
|
||||
"retry_count": 0
|
||||
})
|
||||
_save_attempt_queue()
|
||||
@@ -431,10 +426,6 @@ func _process_attempt_queue() -> void:
|
||||
return
|
||||
|
||||
var queued: Dictionary = attempt_queue[active_attempt_index]
|
||||
if str(queued.get("completion_id", "")).is_empty():
|
||||
queued["completion_id"] = _new_completion_id(str(queued.get("question_id", "")))
|
||||
attempt_queue[active_attempt_index] = queued
|
||||
_save_attempt_queue()
|
||||
attempt_request_token = AuthState.access_token
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
@@ -443,18 +434,14 @@ func _process_attempt_queue() -> void:
|
||||
var question_id := str(queued.get("question_id", ""))
|
||||
var url := question_attempt_api_url
|
||||
var body := JSON.stringify({
|
||||
"completionId": str(queued.get("completion_id", "")),
|
||||
"questionExternalId": question_id,
|
||||
"choiceId": str(queued.get("choice_id", "")),
|
||||
"presentedChoiceIds": queued.get("presented_choice_ids", []),
|
||||
"durationSeconds": int(queued.get("duration_seconds", 0))
|
||||
"durationSeconds": int(queued.get("duration_seconds", 0)),
|
||||
"metTimeLimit": bool(queued.get("met_time_limit", true))
|
||||
})
|
||||
if str(queued.get("kind", "answer")) == "skip":
|
||||
url = "%s/%s/skip?completionId=%s" % [
|
||||
question_api_base_url.rstrip("/"),
|
||||
question_id.uri_encode(),
|
||||
str(queued.get("completion_id", "")).uri_encode()
|
||||
]
|
||||
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
||||
body = ""
|
||||
|
||||
attempt_request_active = true
|
||||
@@ -493,14 +480,7 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(attempt_request_token)
|
||||
active_attempt_index = -1
|
||||
elif response_code == 403:
|
||||
var denied: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(denied) == TYPE_DICTIONARY:
|
||||
AccessService.apply_embedded_status(denied.get("access", {}))
|
||||
access_required.emit(str(denied.get("message", "Your free QBank allowance is exhausted.")))
|
||||
_remove_active_attempt()
|
||||
_process_attempt_queue()
|
||||
elif response_code in [400, 404]:
|
||||
elif response_code in [400, 403, 404]:
|
||||
push_warning("Discarding invalid question attempt with status %d." % response_code)
|
||||
_remove_active_attempt()
|
||||
_process_attempt_queue()
|
||||
@@ -518,14 +498,9 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
_apply_skip_response(parsed)
|
||||
else:
|
||||
_apply_first_attempt_response(parsed)
|
||||
AccessService.apply_embedded_status(parsed.get("access", {}))
|
||||
_process_attempt_queue()
|
||||
|
||||
|
||||
func _new_completion_id(question_id: String) -> String:
|
||||
return "%s-%d-%d" % [question_id.sha256_text().left(12), Time.get_ticks_usec(), randi()]
|
||||
|
||||
|
||||
func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
||||
|
||||
var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
@@ -538,6 +513,9 @@ 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)
|
||||
|
||||
@@ -643,6 +621,8 @@ 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()
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
extends MarginContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
|
||||
var main: Control
|
||||
var summary: Label
|
||||
var quota: Label
|
||||
var status_label: Label
|
||||
var token_labels: Dictionary = {}
|
||||
var action_buttons: Array[Button] = []
|
||||
var reset_dialog: ConfirmationDialog
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_build_ui()
|
||||
AccessService.status_changed.connect(_render)
|
||||
AccessService.operation_succeeded.connect(_on_success)
|
||||
AccessService.operation_failed.connect(_on_failure)
|
||||
AccessService.checkout_started.connect(_on_checkout_started)
|
||||
UIThemeService.theme_changed.connect(refresh_theme_controls)
|
||||
AccessService.refresh()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
AccessService.refresh()
|
||||
_render(AccessService.status)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
var page := VBoxContainer.new()
|
||||
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
page.add_theme_constant_override("separation", 20)
|
||||
scroll.add_child(page)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Credits & Access"
|
||||
title.add_theme_font_override("font", Brand.font)
|
||||
title.add_theme_font_size_override("font_size", 36)
|
||||
page.add_child(title)
|
||||
var intro := Label.new()
|
||||
intro.text = "Study 20 QBank questions in a rolling 24-hour window. Eligible first attempts earn credits; 100 credits unlocks unlimited QBank access for 24 hours."
|
||||
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
intro.add_theme_font_override("font", Brand.font)
|
||||
intro.add_theme_font_size_override("font_size", 17)
|
||||
page.add_child(intro)
|
||||
|
||||
var overview := _panel()
|
||||
page.add_child(overview)
|
||||
var overview_content := VBoxContainer.new()
|
||||
overview_content.add_theme_constant_override("separation", 10)
|
||||
overview.add_child(overview_content)
|
||||
summary = _label("", 24)
|
||||
quota = _label("", 17)
|
||||
overview_content.add_child(summary)
|
||||
overview_content.add_child(quota)
|
||||
var redeem := _button("Redeem 100 credits for 24 hours", AccessService.redeem_credits)
|
||||
overview_content.add_child(redeem)
|
||||
|
||||
var token_grid := GridContainer.new()
|
||||
token_grid.columns = 3
|
||||
token_grid.add_theme_constant_override("h_separation", 16)
|
||||
token_grid.add_theme_constant_override("v_separation", 16)
|
||||
page.add_child(token_grid)
|
||||
_add_access_card(token_grid, "day", "1 day", "$1.00", "24 hours")
|
||||
_add_access_card(token_grid, "week", "1 week", "$5.00", "168 hours")
|
||||
_add_access_card(token_grid, "month", "1 month", "$20.00", "30 days")
|
||||
|
||||
var extras := _panel()
|
||||
page.add_child(extras)
|
||||
var extras_content := VBoxContainer.new()
|
||||
extras_content.add_theme_constant_override("separation", 12)
|
||||
extras.add_child(extras_content)
|
||||
extras_content.add_child(_label("Study tools", 24))
|
||||
token_labels["exam"] = _label("", 16)
|
||||
extras_content.add_child(token_labels["exam"])
|
||||
extras_content.add_child(_button("Buy exam simulation token — $1.99",
|
||||
func(): AccessService.begin_checkout("exam_token_1")))
|
||||
token_labels["reset"] = _label("", 16)
|
||||
extras_content.add_child(token_labels["reset"])
|
||||
extras_content.add_child(_button("Buy QBank reset token — $4.99",
|
||||
func(): AccessService.begin_checkout("qbank_reset_1")))
|
||||
extras_content.add_child(_button("Use QBank reset token", _confirm_reset))
|
||||
|
||||
status_label = _label("", 15)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
page.add_child(status_label)
|
||||
|
||||
reset_dialog = ConfirmationDialog.new()
|
||||
reset_dialog.title = "Reset QBank progress?"
|
||||
reset_dialog.dialog_text = "This starts a new QBank cycle. Questions become unused and credit-eligible again. Historical aggregate samples, bookmarks, notes, and review content remain."
|
||||
reset_dialog.ok_button_text = "Reset QBank"
|
||||
reset_dialog.confirmed.connect(AccessService.reset_qbank)
|
||||
add_child(reset_dialog)
|
||||
refresh_theme_controls()
|
||||
|
||||
|
||||
func _add_access_card(parent: GridContainer, token: String, heading: String, price: String, duration: String) -> void:
|
||||
var panel := _panel()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
parent.add_child(panel)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(content)
|
||||
content.add_child(_label(heading, 24))
|
||||
content.add_child(_label("%s of unlimited QBank access" % duration, 15))
|
||||
token_labels[token] = _label("", 15)
|
||||
content.add_child(token_labels[token])
|
||||
content.add_child(_button("Buy token — %s" % price,
|
||||
func(): AccessService.begin_checkout("access_%s_1" % token)))
|
||||
content.add_child(_button("Activate token",
|
||||
func(): AccessService.activate_token(token)))
|
||||
|
||||
|
||||
func _render(value: Dictionary) -> void:
|
||||
if summary == null:
|
||||
return
|
||||
var credits := int(value.get("credits", 0))
|
||||
var unlimited := bool(value.get("unlimited", false))
|
||||
var expiry := str(value.get("unlimitedUntilUtc", ""))
|
||||
summary.text = "%d credits • %s" % [
|
||||
credits,
|
||||
("Unlimited QBank active until %s" % expiry) if unlimited else "Free QBank access"
|
||||
]
|
||||
quota.text = "%d of %d free questions remaining in the rolling window" % [
|
||||
int(value.get("questionsRemaining", 20)),
|
||||
int(value.get("freeQuestionLimit", 20))
|
||||
]
|
||||
token_labels.get("day", _label()).text = "%d available" % int(value.get("dayTokens", 0))
|
||||
token_labels.get("week", _label()).text = "%d available" % int(value.get("weekTokens", 0))
|
||||
token_labels.get("month", _label()).text = "%d available" % int(value.get("monthTokens", 0))
|
||||
token_labels.get("exam", _label()).text = "Exam tokens: %d" % int(value.get("examTokens", 0))
|
||||
token_labels.get("reset", _label()).text = "QBank reset tokens: %d" % int(value.get("resetTokens", 0))
|
||||
|
||||
|
||||
func _confirm_reset() -> void:
|
||||
reset_dialog.popup_centered(Vector2i(620, 260))
|
||||
|
||||
|
||||
func _on_success(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
|
||||
|
||||
func _on_failure(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Color("ef4444"))
|
||||
|
||||
|
||||
func _on_checkout_started(_purchase_id: String, _url: String) -> void:
|
||||
status_label.text = "Complete checkout in the secure payment window."
|
||||
|
||||
|
||||
func refresh_theme_controls() -> void:
|
||||
for button in action_buttons:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
button.add_theme_font_override("font", Brand.font)
|
||||
button.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
||||
button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
||||
button.add_theme_stylebox_override("pressed", Brand.secondary_button_box(dark, true))
|
||||
|
||||
|
||||
func _panel() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
return panel
|
||||
|
||||
|
||||
func _label(text := "", size := 16) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", size)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
return label
|
||||
|
||||
|
||||
func _button(text: String, callback: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = text
|
||||
button.custom_minimum_size = Vector2(0, 48)
|
||||
button.pressed.connect(callback)
|
||||
action_buttons.append(button)
|
||||
return button
|
||||
@@ -1 +0,0 @@
|
||||
uid://bgkgwx6ds6cjn
|
||||
@@ -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", Brand.text_color(dark))
|
||||
operation_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
display_label.add_theme_color_override("font_color", Color.WHITE)
|
||||
operation_label.add_theme_color_override("font_color", Brand.muted_color(true))
|
||||
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, "Combined Prediction", prediction_value, prediction_detail, dark)
|
||||
_add_metric(row, "Provisional Combined Estimate", prediction_value, prediction_detail, dark)
|
||||
|
||||
|
||||
func _add_metric(parent: BoxContainer, caption: String, value: String, detail: String, dark: bool) -> void:
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://doisoxqnk1mf3
|
||||
@@ -99,9 +99,13 @@ func _draw() -> void:
|
||||
|
||||
static func point_value(point: Dictionary, series_mode: String) -> Variant:
|
||||
if series_mode == "accuracy":
|
||||
return float(point.get("accuracy", 0)) if int(point.get("total", 0)) > 0 else null
|
||||
if int(point.get("total", 0)) > 0:
|
||||
return float(point.get("accuracy", 0))
|
||||
return null
|
||||
var value: Variant = point.get("questionBankRating", null)
|
||||
return null if value == null else float(value)
|
||||
if value == null:
|
||||
return null
|
||||
return float(value)
|
||||
|
||||
|
||||
static func axis_bounds(series_points: Array, series_mode: String) -> Vector2:
|
||||
|
||||
@@ -94,7 +94,7 @@ func _build() -> void:
|
||||
readiness_segment_bar = DashboardSegmentBar.new()
|
||||
readiness_segment_bar.custom_minimum_size = Vector2(0, 10)
|
||||
_add_stat_card("readiness", "Exam Readiness", "chart-no-axes-column-increasing", Brand.PURPLE, readiness_segment_bar,
|
||||
"Predicted pass probability from your most recent completed exam simulation.")
|
||||
"Heuristic pass probability from your most recent completed exam simulation.")
|
||||
|
||||
accuracy_progress_bar = ProgressBar.new()
|
||||
accuracy_progress_bar.show_percentage = false
|
||||
@@ -229,8 +229,8 @@ func _render_rating_card(key: String, sparkline: Control, accent: Color, rating_
|
||||
if delta_value != null:
|
||||
var delta := int(delta_value)
|
||||
var arrow := "↗" if delta >= 0 else "↘"
|
||||
var sign := "+" if delta >= 0 else ""
|
||||
trend_text = "%s %s%d this week" % [arrow, sign, delta]
|
||||
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)
|
||||
@@ -254,8 +254,8 @@ func _render_readiness_card(data: Dictionary) -> void:
|
||||
var label := str(label_value)
|
||||
var pass_percent := int(data.get("examReadinessProjectedPassPercent", 0))
|
||||
var status_color := Brand.SUCCESS if label == "High" else (Brand.AMBER if label == "Medium" else Brand.MAGENTA)
|
||||
var caption := "You're on track!" if label != "Low" else "Keep practicing"
|
||||
_set_stat_card("readiness", label, "Projected pass: %d%%" % pass_percent, status_color, caption)
|
||||
var caption := "Provisional estimate" if label != "Low" else "Keep practicing"
|
||||
_set_stat_card("readiness", label, "Heuristic pass: %d%%" % pass_percent, status_color, caption)
|
||||
(metric_values.get("readiness") as Label).add_theme_color_override("font_color", status_color)
|
||||
readiness_segment_bar.set_progress(int(round(pass_percent / 100.0 * 4.0)), 4, status_color, dark)
|
||||
|
||||
@@ -269,8 +269,8 @@ func _render_accuracy_card(data: Dictionary) -> void:
|
||||
if delta_value != null:
|
||||
var delta := int(delta_value)
|
||||
var arrow := "↗" if delta >= 0 else "↘"
|
||||
var sign := "+" if delta >= 0 else ""
|
||||
trend_text = "%s %s%d%% vs last 100" % [arrow, sign, delta]
|
||||
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
|
||||
|
||||
@@ -65,6 +65,13 @@ 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)
|
||||
@@ -151,6 +158,7 @@ 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()
|
||||
@@ -850,7 +858,8 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body:
|
||||
"requires_account":
|
||||
poll_timer.stop()
|
||||
_close_steam_popup()
|
||||
external_verified_email = str(data.get("email", ""))
|
||||
var verified_email_value: Variant = data.get("email")
|
||||
external_verified_email = "" if verified_email_value == null else str(verified_email_value)
|
||||
steam_email_input.text = external_verified_email
|
||||
steam_email_input.editable = external_verified_email.is_empty()
|
||||
_set_mode("steam")
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -27,9 +28,7 @@ var sidebar_content: VBoxContainer
|
||||
var drawer_backdrop: ColorRect
|
||||
var drawer_open := false
|
||||
var menu_button: Button
|
||||
var account_button: Button
|
||||
var account_chevron_button: Button
|
||||
var account_menu: PanelContainer
|
||||
var credits_badge: PanelContainer
|
||||
var nav_buttons: Dictionary = {}
|
||||
var current_nav_key := "home"
|
||||
var compact_shell := false
|
||||
@@ -54,8 +53,6 @@ func _update_responsive_layout() -> void:
|
||||
_apply_screen_margins(current_screen_name)
|
||||
if feedback_button != null:
|
||||
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
|
||||
if account_chevron_button != null:
|
||||
account_chevron_button.visible = size.x >= 480.0
|
||||
if drawer_open and not compact_shell:
|
||||
_close_drawer()
|
||||
if app_menu != null:
|
||||
@@ -67,7 +64,6 @@ func _update_responsive_layout() -> void:
|
||||
|
||||
func show_screen(screen_name: String, payload: Variant = null) -> void:
|
||||
_close_app_menu()
|
||||
_close_account_menu()
|
||||
_close_drawer()
|
||||
for child in screens_host.get_children():
|
||||
if child.visible and child.has_method("on_hide"):
|
||||
@@ -186,23 +182,11 @@ func _build_ui() -> void:
|
||||
header.add_child(theme_button)
|
||||
_refresh_theme_button()
|
||||
|
||||
account_button = Button.new()
|
||||
account_button.tooltip_text = "Open account menu"
|
||||
account_button.custom_minimum_size = Vector2(44, 44)
|
||||
account_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
account_button.pressed.connect(_toggle_account_menu)
|
||||
_style_account_avatar(account_button)
|
||||
header.add_child(account_button)
|
||||
|
||||
account_chevron_button = Button.new()
|
||||
account_chevron_button.icon = preload("res://assets/icons/lucide/chevron-down.svg")
|
||||
account_chevron_button.tooltip_text = "Open account menu"
|
||||
account_chevron_button.custom_minimum_size = Vector2(28, 44)
|
||||
account_chevron_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
account_chevron_button.add_theme_constant_override("icon_max_width", 17)
|
||||
account_chevron_button.pressed.connect(_toggle_account_menu)
|
||||
_style_account_chevron(account_chevron_button)
|
||||
header.add_child(account_chevron_button)
|
||||
credits_badge = CreditsBadge.new()
|
||||
credits_badge.store_requested.connect(func():
|
||||
if Brand.is_uat_environment():
|
||||
show_screen("rz_store"))
|
||||
header.add_child(credits_badge)
|
||||
|
||||
var screens_container := MarginContainer.new()
|
||||
screens_margin = screens_container
|
||||
@@ -350,11 +334,11 @@ func _build_ui() -> void:
|
||||
donations.main = self
|
||||
screens_container.add_child(donations)
|
||||
screens["donations"] = donations
|
||||
var access_store := preload("res://scripts/ui/access_store_screen.gd").new()
|
||||
access_store.name = "AccessStoreScreen"
|
||||
access_store.main = self
|
||||
screens_container.add_child(access_store)
|
||||
screens["access_store"] = access_store
|
||||
var rz_store := preload("res://scripts/ui/rz_store_screen.gd").new()
|
||||
rz_store.name = "RzCoinStore"
|
||||
rz_store.main = self
|
||||
screens_container.add_child(rz_store)
|
||||
screens["rz_store"] = rz_store
|
||||
|
||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||
feedback_dialog.name = "AppFeedbackDialog"
|
||||
@@ -369,21 +353,14 @@ func _build_ui() -> void:
|
||||
legal_document_dialog = preload("res://scripts/ui/legal_document_dialog.gd").new()
|
||||
legal_document_dialog.name = "LegalDocumentDialog"
|
||||
add_child(legal_document_dialog)
|
||||
_build_account_menu()
|
||||
_build_drawer()
|
||||
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
AuthState.session_expired.connect(_on_session_expired)
|
||||
QuestionBankService.access_required.connect(_on_qbank_access_required)
|
||||
_refresh_header_controls()
|
||||
_build_app_menu()
|
||||
|
||||
|
||||
func _on_qbank_access_required(_message: String) -> void:
|
||||
if AuthState.is_logged_in:
|
||||
show_screen("access_store")
|
||||
|
||||
|
||||
func _build_sidebar() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.name = "Sidebar"
|
||||
@@ -437,9 +414,9 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
container.add_child(spacer)
|
||||
container.add_child(HSeparator.new())
|
||||
_add_nav_button(container, "access_store", "Credits & Access", "clock-3", true)
|
||||
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
|
||||
_add_nav_button(container, "performance", "Profile", "user-round", true)
|
||||
_add_nav_button(container, "rz_store", "RZ Shop", "star", true, Brand.is_uat_environment())
|
||||
_add_nav_button(container, "settings", "Settings", "settings", true)
|
||||
_add_nav_button(container, "logout", "Log out", "log-out", true)
|
||||
|
||||
@@ -483,7 +460,7 @@ func _on_navigation_pressed(key: String) -> void:
|
||||
"notes": show_screen("notes")
|
||||
"bookmarks": show_screen("bookmarks")
|
||||
"donations": show_screen("donations")
|
||||
"access_store": show_screen("access_store")
|
||||
"rz_store": show_screen("rz_store")
|
||||
"logout": _logout()
|
||||
|
||||
|
||||
@@ -495,6 +472,7 @@ func _update_header_title(screen_name: String) -> void:
|
||||
"selection": "Question Bank",
|
||||
"dynamic_questions": "Dynamic Questions",
|
||||
"simulation_history": "Exam Simulations",
|
||||
"simulation_results": "Exam Simulations",
|
||||
"ccs_hub": "CCS Cases",
|
||||
"flashcards_hub": "Flashcards",
|
||||
"flashcards_study": "Flashcards",
|
||||
@@ -502,33 +480,11 @@ func _update_header_title(screen_name: String) -> void:
|
||||
"settings": "Settings",
|
||||
"notes": "Notes",
|
||||
"bookmarks": "Bookmarks",
|
||||
"donations": "Support RanaZe",
|
||||
"access_store": "Credits & Access"
|
||||
"donations": "Support RanaZe"
|
||||
}
|
||||
header_title.text = str(titles.get(screen_name, ""))
|
||||
|
||||
|
||||
func _build_account_menu() -> void:
|
||||
account_menu = PanelContainer.new()
|
||||
account_menu.visible = false
|
||||
account_menu.top_level = true
|
||||
account_menu.custom_minimum_size = Vector2(230, 230)
|
||||
account_menu.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
add_child(account_menu)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 8)
|
||||
account_menu.add_child(content)
|
||||
for spec in [["Profile", "user-round", "performance"], ["Settings", "settings", "settings"], ["Log out", "log-out", "logout"]]:
|
||||
var button := Button.new()
|
||||
button.text = spec[0]
|
||||
button.icon = load("res://assets/icons/lucide/%s.svg" % spec[1])
|
||||
button.add_theme_constant_override("icon_max_width", 22)
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.custom_minimum_size = Vector2(0, 46)
|
||||
button.pressed.connect(_on_navigation_pressed.bind(spec[2]))
|
||||
content.add_child(button)
|
||||
|
||||
|
||||
func _build_drawer() -> void:
|
||||
drawer_backdrop = ColorRect.new()
|
||||
drawer_backdrop.visible = false
|
||||
@@ -570,25 +526,6 @@ func _on_drawer_backdrop_input(event: InputEvent) -> void:
|
||||
_close_drawer()
|
||||
|
||||
|
||||
func _toggle_account_menu() -> void:
|
||||
if account_menu == null:
|
||||
return
|
||||
if account_menu.visible:
|
||||
_close_account_menu()
|
||||
return
|
||||
account_menu.visible = true
|
||||
account_menu.size = account_menu.custom_minimum_size
|
||||
account_menu.position = Vector2(
|
||||
get_viewport_rect().size.x - account_menu.size.x - 24,
|
||||
82
|
||||
)
|
||||
|
||||
|
||||
func _close_account_menu() -> void:
|
||||
if account_menu != null:
|
||||
account_menu.visible = false
|
||||
|
||||
|
||||
func _screen_uses_shell(screen_name: String) -> bool:
|
||||
return AuthState.is_logged_in and screen_name not in ["login", "legal_consent", "player", "ccs_player"]
|
||||
|
||||
@@ -680,32 +617,6 @@ func _style_header_feedback_button(button: Button) -> void:
|
||||
button.add_theme_color_override("icon_hover_color", Brand.SKY_BLUE)
|
||||
|
||||
|
||||
func _style_account_avatar(button: Button) -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var normal := StyleBoxFlat.new()
|
||||
normal.bg_color = Color("26324f") if dark else Color("dce5f5")
|
||||
normal.corner_radius_top_left = 22
|
||||
normal.corner_radius_top_right = 22
|
||||
normal.corner_radius_bottom_left = 22
|
||||
normal.corner_radius_bottom_right = 22
|
||||
var hover := normal.duplicate() as StyleBoxFlat
|
||||
hover.bg_color = Color("344465") if dark else Color("cad7ed")
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", hover)
|
||||
button.add_theme_color_override("font_color", Color("f8fafc") if dark else Color("172033"))
|
||||
button.add_theme_color_override("font_hover_color", Color("ffffff") if dark else Color("0f172a"))
|
||||
|
||||
|
||||
func _style_account_chevron(button: Button) -> void:
|
||||
var empty := StyleBoxEmpty.new()
|
||||
button.add_theme_stylebox_override("normal", empty)
|
||||
button.add_theme_stylebox_override("hover", empty)
|
||||
button.add_theme_stylebox_override("pressed", empty)
|
||||
button.add_theme_color_override("icon_normal_color", Style.MUTED)
|
||||
button.add_theme_color_override("icon_hover_color", Style.TEXT)
|
||||
|
||||
|
||||
func _style_shell_theme_button(button: Button) -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var normal := StyleBoxFlat.new()
|
||||
@@ -737,36 +648,18 @@ func _input(event: InputEvent) -> void:
|
||||
_close_drawer()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if account_menu != null and account_menu.visible:
|
||||
_close_account_menu()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
if current_screen_name in ["player", "ccs_player", "flashcards_study"]:
|
||||
_toggle_app_menu()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _refresh_header_controls() -> void:
|
||||
if account_button != null:
|
||||
account_button.text = _account_initials(AuthState.username)
|
||||
if feedback_button != null:
|
||||
feedback_button.visible = AuthState.is_logged_in and size.x >= 560.0
|
||||
feedback_button.icon = preload("res://assets/icons/lucide/message-square.svg")
|
||||
feedback_button.add_theme_constant_override("icon_max_width", 21)
|
||||
|
||||
|
||||
func _account_initials(username: String) -> String:
|
||||
var cleaned := ""
|
||||
for character in username:
|
||||
if character.to_lower() != character.to_upper():
|
||||
cleaned += character
|
||||
elif character.is_valid_int():
|
||||
cleaned += character
|
||||
if cleaned.length() >= 2:
|
||||
break
|
||||
return cleaned.to_upper() if not cleaned.is_empty() else "ME"
|
||||
|
||||
|
||||
func open_feedback_dialog() -> void:
|
||||
if not AuthState.is_logged_in or feedback_dialog == null:
|
||||
return
|
||||
@@ -783,6 +676,7 @@ 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"]:
|
||||
@@ -803,14 +697,10 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
|
||||
if app_background != null:
|
||||
app_background.color = Brand.background_color(is_dark_mode)
|
||||
app_background.material = Brand.background_material(is_dark_mode)
|
||||
if account_menu != null:
|
||||
account_menu.add_theme_stylebox_override("panel", Brand.panel_box(is_dark_mode))
|
||||
if feedback_button != null:
|
||||
_style_header_feedback_button(feedback_button)
|
||||
if account_button != null:
|
||||
_style_account_avatar(account_button)
|
||||
if account_chevron_button != null:
|
||||
_style_account_chevron(account_chevron_button)
|
||||
if credits_badge != null:
|
||||
credits_badge.refresh_theme_controls()
|
||||
if sidebar != null:
|
||||
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
|
||||
if header_panel != null:
|
||||
@@ -828,9 +718,6 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
|
||||
var donations = screens.get("donations")
|
||||
if donations != null and donations.has_method("refresh_theme_controls"):
|
||||
donations.refresh_theme_controls()
|
||||
var access_store = screens.get("access_store")
|
||||
if access_store != null and access_store.has_method("refresh_theme_controls"):
|
||||
access_store.refresh_theme_controls()
|
||||
var player = screens.get("player")
|
||||
if player != null and player.has_method("refresh_theme_controls"):
|
||||
player.refresh_theme_controls()
|
||||
@@ -913,7 +800,7 @@ func _build_app_menu() -> void:
|
||||
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_run)
|
||||
menu_exit_button.pressed.connect(exit_current_run)
|
||||
content.add_child(menu_exit_button)
|
||||
|
||||
var logout := Button.new()
|
||||
@@ -960,7 +847,7 @@ func _navigate_from_pause(target_screen: String) -> void:
|
||||
BlockSessionService.pause_current_question_timer()
|
||||
|
||||
|
||||
func _exit_run() -> void:
|
||||
func exit_current_run() -> void:
|
||||
_close_app_menu()
|
||||
if current_screen_name == "ccs_player":
|
||||
var ccs_player = screens.get("ccs_player")
|
||||
@@ -969,15 +856,8 @@ func _exit_run() -> void:
|
||||
if current_screen_name == "flashcards_study":
|
||||
show_screen("flashcards_hub")
|
||||
return
|
||||
var was_core_run := BlockSessionService.session_kind == "core"
|
||||
var was_review_run := BlockSessionService.session_kind == "review"
|
||||
var was_dynamic_run := BlockSessionService.session_kind == "dynamic"
|
||||
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
|
||||
BlockSessionService.finish_block()
|
||||
elif BlockSessionService.session_kind == "simulation":
|
||||
BlockSessionService.block_started = false
|
||||
if was_review_run or was_dynamic_run:
|
||||
DashboardService.refresh()
|
||||
show_screen("dashboard")
|
||||
else:
|
||||
show_screen("selection", "exam" if was_core_run else "step3")
|
||||
show_screen("dashboard")
|
||||
|
||||
@@ -29,6 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
||||
key = "bookmarks"
|
||||
elif screen_name == "donations":
|
||||
key = "donations"
|
||||
elif screen_name == "access_store":
|
||||
key = "access_store"
|
||||
elif screen_name == "rz_store":
|
||||
key = "rz_store"
|
||||
return key
|
||||
|
||||
@@ -4,6 +4,8 @@ const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
||||
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
|
||||
const SlotMachinePanel := preload("res://scripts/ui/rz_slot_machine_screen.gd")
|
||||
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
|
||||
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
|
||||
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
|
||||
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
|
||||
@@ -14,6 +16,7 @@ const FLAG_DARK_ICON := preload("res://assets/icons/medmind/flag-dark.svg")
|
||||
const FLAG_LIGHT_ICON := preload("res://assets/icons/medmind/flag-light.svg")
|
||||
const BADGE_CIRCLE_OUTLINE_ICON := preload("res://assets/icons/medmind/badge-circle-outline.svg")
|
||||
const BADGE_CIRCLE_FILLED_ICON := preload("res://assets/icons/medmind/badge-circle-filled.svg")
|
||||
const COIN_ICON := preload("res://assets/icons/lucide/star.svg")
|
||||
const FEEDBACK_FADE_SECONDS := 0.45
|
||||
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
|
||||
const TEXT_SCALE_MIN := 0.85
|
||||
@@ -58,6 +61,8 @@ var stem_plain_text := ""
|
||||
var explanation_plain_text := ""
|
||||
var highlight_drag_source := ""
|
||||
var timer_color_state := ""
|
||||
var last_timer_bar_value := -1.0
|
||||
var last_timer_display_seconds := -1
|
||||
var pending_correct_advance := false
|
||||
var question_text_scale := 1.0
|
||||
var presented_choices: Array = []
|
||||
@@ -90,6 +95,8 @@ var text_smaller_button: Button
|
||||
var text_larger_button: Button
|
||||
var labs_button: Button
|
||||
var calculator_button: Button
|
||||
var slot_machine_button: Button
|
||||
var exit_button: Button
|
||||
var clear_highlight_button: Button
|
||||
var flag_button: Button
|
||||
var report_button: Button
|
||||
@@ -99,6 +106,7 @@ var flashcard_prefill_dialog: Control
|
||||
var bookmark_button: Button
|
||||
var app_feedback_button: Button
|
||||
var theme_button: Button
|
||||
var credits_badge: PanelContainer
|
||||
var question_toolbar: HFlowContainer
|
||||
var header_stack: VBoxContainer
|
||||
var header_row: HBoxContainer
|
||||
@@ -121,6 +129,7 @@ var labs_overlay: Control
|
||||
var labs_panel: PanelContainer
|
||||
var labs_content: VBoxContainer
|
||||
var calculator_panel
|
||||
var slot_machine_panel
|
||||
var report_overlay: Control
|
||||
var report_panel: PanelContainer
|
||||
var report_form_content: VBoxContainer
|
||||
@@ -317,6 +326,18 @@ func _ready() -> void:
|
||||
UISoundService.set_button_sound(text_larger_button, "toggle")
|
||||
text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP))
|
||||
|
||||
exit_button = Button.new()
|
||||
exit_button.name = "ExitButton"
|
||||
exit_button.tooltip_text = "Return to main area"
|
||||
exit_button.icon = preload("res://assets/icons/lucide/house.svg")
|
||||
exit_button.custom_minimum_size = Vector2(44, 44)
|
||||
exit_button.add_theme_constant_override("icon_max_width", 22)
|
||||
exit_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
exit_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
exit_button.focus_mode = Control.FOCUS_NONE
|
||||
UISoundService.set_button_sound(exit_button, "back")
|
||||
exit_button.pressed.connect(func(): main.exit_current_run())
|
||||
|
||||
calculator_button = Button.new()
|
||||
calculator_button.name = "CalculatorButton"
|
||||
calculator_button.tooltip_text = "Open calculator"
|
||||
@@ -329,6 +350,18 @@ func _ready() -> void:
|
||||
UISoundService.set_button_sound(calculator_button, "open")
|
||||
calculator_button.pressed.connect(_toggle_calculator)
|
||||
|
||||
slot_machine_button = Button.new()
|
||||
slot_machine_button.name = "SlotMachineButton"
|
||||
slot_machine_button.tooltip_text = "Open RZ Slot Machine"
|
||||
slot_machine_button.icon = preload("res://assets/icons/lucide/gem.svg")
|
||||
slot_machine_button.custom_minimum_size = Vector2(44, 44)
|
||||
slot_machine_button.add_theme_constant_override("icon_max_width", 22)
|
||||
slot_machine_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
slot_machine_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
slot_machine_button.focus_mode = Control.FOCUS_NONE
|
||||
UISoundService.set_button_sound(slot_machine_button, "open")
|
||||
slot_machine_button.pressed.connect(_toggle_slot_machine)
|
||||
|
||||
labs_button = Button.new()
|
||||
labs_button.tooltip_text = "Review lab results"
|
||||
labs_button.custom_minimum_size = Vector2(44, 44)
|
||||
@@ -404,13 +437,17 @@ func _ready() -> void:
|
||||
Style.style_chip(app_feedback_button, Style.CYAN)
|
||||
app_feedback_button.pressed.connect(func(): main.open_feedback_dialog())
|
||||
|
||||
credits_badge = CreditsBadge.new()
|
||||
|
||||
# Every action is directly visible - no "More" overflow or popups hiding
|
||||
# controls behind an extra click. No text labels either (their varying widths
|
||||
# broke even spacing when a button hid itself) - each button's existing
|
||||
# tooltip_text carries the label as hover text instead.
|
||||
question_toolbar.add_child(exit_button)
|
||||
question_toolbar.add_child(text_smaller_button)
|
||||
question_toolbar.add_child(text_larger_button)
|
||||
question_toolbar.add_child(calculator_button)
|
||||
question_toolbar.add_child(slot_machine_button)
|
||||
question_toolbar.add_child(labs_button)
|
||||
question_toolbar.add_child(clear_highlight_button)
|
||||
question_toolbar.add_child(flag_button)
|
||||
@@ -420,6 +457,7 @@ func _ready() -> void:
|
||||
question_toolbar.add_child(notes_button)
|
||||
question_toolbar.add_child(theme_button)
|
||||
question_toolbar.add_child(app_feedback_button)
|
||||
question_toolbar.add_child(credits_badge)
|
||||
|
||||
header_toolbar_host = HBoxContainer.new()
|
||||
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
|
||||
@@ -467,6 +505,7 @@ func _ready() -> void:
|
||||
content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
content_margin.add_theme_constant_override("margin_left", 4)
|
||||
content_margin.add_theme_constant_override("margin_right", 12)
|
||||
content_margin.add_theme_constant_override("margin_top", 20)
|
||||
question_scroll.add_child(content_margin)
|
||||
|
||||
question_content = VBoxContainer.new()
|
||||
@@ -523,7 +562,10 @@ func _ready() -> void:
|
||||
|
||||
var button := Button.new()
|
||||
button.custom_minimum_size = Vector2(0, 68)
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
button.text_overrun_behavior = TextServer.OVERRUN_NO_TRIMMING
|
||||
Style.style_answer_button(button)
|
||||
UISoundService.set_button_sound(button, "select")
|
||||
_set_scaled_button_font(button, 17)
|
||||
@@ -633,6 +675,7 @@ func _ready() -> void:
|
||||
|
||||
actions = HBoxContainer.new()
|
||||
actions.add_theme_constant_override("separation", 16)
|
||||
actions.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
card_content.add_child(actions)
|
||||
|
||||
skip_button = Button.new()
|
||||
@@ -662,10 +705,6 @@ func _ready() -> void:
|
||||
bottom_progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
bottom_progress_group.add_child(bottom_progress_bar)
|
||||
|
||||
# The timer readout still runs (BlockSessionService pacing/scoring depends on
|
||||
# it, and _update_timer_bar keeps its text current), it's just no longer
|
||||
# parented into the bottom bar - the caption + digits and the "Ctrl+Enter"
|
||||
# hint were clutter next to the Submit button.
|
||||
timer_caption_label = Label.new()
|
||||
timer_caption_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
_set_scaled_label(timer_caption_label, 12, Brand.SLATE)
|
||||
@@ -674,6 +713,7 @@ func _ready() -> void:
|
||||
next_button.text = "Submit Block" if BlockSessionService.session_kind == "simulation" and BlockSessionService.current_index + 1 == BlockSessionService.active_questions.size() else "Next" if BlockSessionService.session_kind == "simulation" else "Submit"
|
||||
next_button.tooltip_text = ""
|
||||
next_button.custom_minimum_size = Vector2(140, 48)
|
||||
next_button.size_flags_horizontal = Control.SIZE_SHRINK_END
|
||||
Style.style_primary_button(next_button)
|
||||
next_button.pressed.connect(_submit_or_next)
|
||||
actions.add_child(next_button)
|
||||
@@ -681,6 +721,8 @@ func _ready() -> void:
|
||||
timer_footer = HBoxContainer.new()
|
||||
timer_footer.add_theme_constant_override("separation", 12)
|
||||
card_content.add_child(timer_footer)
|
||||
timer_footer.add_child(timer_caption_label)
|
||||
timer_footer.add_child(timer_label)
|
||||
timer_bar = ProgressBar.new()
|
||||
timer_bar.show_percentage = false
|
||||
timer_bar.min_value = 0.0
|
||||
@@ -702,6 +744,9 @@ func _ready() -> void:
|
||||
_build_labs_modal()
|
||||
calculator_panel = CalculatorPanel.new()
|
||||
add_child(calculator_panel)
|
||||
slot_machine_panel = SlotMachinePanel.new()
|
||||
slot_machine_panel.main = main
|
||||
add_child(slot_machine_panel)
|
||||
_build_report_modal()
|
||||
_build_media_viewer()
|
||||
resized.connect(_fit_modal_panels)
|
||||
@@ -787,9 +832,10 @@ func _refresh_responsive_layout() -> void:
|
||||
header_toolbar_host.reparent(target_parent)
|
||||
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
|
||||
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
# 11 bare 44px icon buttons + the wider 92px Feedback button, with 8px gaps
|
||||
# between all 12 - anything less than this wraps onto a second row.
|
||||
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 680.0
|
||||
# 13 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
|
||||
# rank/ELO + points badge, with 8px gaps between all 15 - anything less
|
||||
# than this wraps onto a second row.
|
||||
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 1020.0
|
||||
if size.x < 560.0:
|
||||
nav_palette.columns = 5
|
||||
else:
|
||||
@@ -1045,7 +1091,7 @@ func _apply_question_theme() -> void:
|
||||
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
||||
if navigator_heading != null:
|
||||
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
for control in [text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
||||
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, slot_machine_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
||||
if control == null:
|
||||
continue
|
||||
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
|
||||
@@ -1061,6 +1107,10 @@ func _apply_question_theme() -> void:
|
||||
_style_theme_toggle_button(dark)
|
||||
if calculator_panel != null:
|
||||
calculator_panel.refresh_theme_controls()
|
||||
if slot_machine_panel != null:
|
||||
slot_machine_panel.refresh_theme_controls()
|
||||
if credits_badge != null:
|
||||
credits_badge.refresh_theme_controls()
|
||||
if skip_button != null:
|
||||
var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
|
||||
skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||||
@@ -1109,21 +1159,26 @@ func on_hide() -> void:
|
||||
if calculator_panel != null:
|
||||
calculator_panel.close_panel()
|
||||
calculator_panel.reset_calculation()
|
||||
if slot_machine_panel != null:
|
||||
slot_machine_panel.close_panel()
|
||||
|
||||
|
||||
func _save_simulation_current_response() -> void:
|
||||
func _save_simulation_current_response(is_submitted := false) -> void:
|
||||
if BlockSessionService.session_kind != "simulation":
|
||||
return
|
||||
if bool(current_answer.get("is_submitted", false)) and not is_submitted:
|
||||
return
|
||||
var question := BlockSessionService.get_current_question()
|
||||
if question.is_empty():
|
||||
return
|
||||
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
||||
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged, is_submitted)
|
||||
ExamSimulationService.save_response(
|
||||
str(question.get("id", "")),
|
||||
selected_choice,
|
||||
eliminated,
|
||||
flagged,
|
||||
int(BlockSessionService.get_current_elapsed_seconds()))
|
||||
int(BlockSessionService.get_current_elapsed_seconds()),
|
||||
is_submitted)
|
||||
|
||||
|
||||
func _on_simulation_state_changed() -> void:
|
||||
@@ -1143,7 +1198,15 @@ func _on_simulation_state_changed() -> void:
|
||||
func _on_simulation_request_failed(message: String) -> void:
|
||||
if BlockSessionService.session_kind != "simulation":
|
||||
return
|
||||
if str(ExamSimulationService.simulation.get("state", "")) in ["between_blocks", "completed"]:
|
||||
simulation_submit_pending = false
|
||||
return
|
||||
simulation_submit_pending = false
|
||||
if ExamSimulationService.last_failed_action != "submit":
|
||||
# A per-question autosave failing shouldn't interrupt a timed exam with a
|
||||
# blocking "Retry" - the next answer/navigation naturally retries it, and
|
||||
# the final submit sends the complete, authoritative state regardless.
|
||||
return
|
||||
next_button.disabled = false
|
||||
next_button.text = "Retry"
|
||||
next_button.tooltip_text = message
|
||||
@@ -1244,6 +1307,8 @@ func _load_question() -> void:
|
||||
flagged = false
|
||||
selected_choice = ""
|
||||
current_answer = {}
|
||||
last_timer_bar_value = -1.0
|
||||
last_timer_display_seconds = -1
|
||||
flag_button.button_pressed = false
|
||||
_refresh_toolbar_state()
|
||||
_clear_answer_review()
|
||||
@@ -1305,7 +1370,7 @@ func _load_question() -> void:
|
||||
if category_text.is_empty():
|
||||
category_text = str(question.get("system", "")).strip_edges()
|
||||
category_label.text = category_text
|
||||
category_label.visible = not category_text.is_empty()
|
||||
category_label.visible = false
|
||||
_refresh_labs_button()
|
||||
|
||||
presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true)
|
||||
@@ -1428,15 +1493,12 @@ func _skip_current_question() -> void:
|
||||
return
|
||||
if BlockSessionService.session_kind != "core":
|
||||
if BlockSessionService.session_kind == "simulation":
|
||||
selected_choice = ""
|
||||
current_answer = {}
|
||||
_save_simulation_current_response()
|
||||
var target_index := _next_revisitable_index()
|
||||
var target_index := _next_pending_simulation_index() if BlockSessionService.session_kind == "simulation" else _next_revisitable_index()
|
||||
if target_index >= 0 and BlockSessionService.move_to(target_index):
|
||||
_load_question()
|
||||
elif BlockSessionService.session_kind == "simulation" and not simulation_submit_pending:
|
||||
simulation_submit_pending = true
|
||||
next_button.disabled = true
|
||||
next_button.text = "Submitting..."
|
||||
ExamSimulationService.submit_block()
|
||||
else:
|
||||
_finish()
|
||||
return
|
||||
@@ -1468,6 +1530,18 @@ func _next_revisitable_index() -> int:
|
||||
return (BlockSessionService.current_index + 1) % question_count
|
||||
|
||||
|
||||
func _next_pending_simulation_index() -> int:
|
||||
var question_count := BlockSessionService.active_questions.size()
|
||||
if question_count == 0:
|
||||
return -1
|
||||
for offset in range(1, question_count + 1):
|
||||
var candidate := (BlockSessionService.current_index + offset) % question_count
|
||||
var answer = BlockSessionService.get_answer_for_index(candidate)
|
||||
if answer == null or str(answer.get("selected_choice_id", "")) == "":
|
||||
return candidate
|
||||
return -1
|
||||
|
||||
|
||||
func _choice_gui_input(event: InputEvent, index: int) -> void:
|
||||
if pending_correct_advance or followup_required or _is_correct_answer_locked():
|
||||
return
|
||||
@@ -1515,12 +1589,15 @@ func _toggle_eliminate_selected() -> void:
|
||||
|
||||
func _submit_or_next() -> void:
|
||||
if BlockSessionService.session_kind == "simulation":
|
||||
_save_simulation_current_response()
|
||||
if BlockSessionService.current_index + 1 < BlockSessionService.active_questions.size():
|
||||
BlockSessionService.move_next()
|
||||
if selected_choice == "":
|
||||
return
|
||||
_save_simulation_current_response(true)
|
||||
var pending_index := _next_pending_simulation_index()
|
||||
if pending_index >= 0:
|
||||
BlockSessionService.move_to(pending_index)
|
||||
_load_question()
|
||||
return
|
||||
elif not simulation_submit_pending:
|
||||
if not simulation_submit_pending:
|
||||
simulation_submit_pending = true
|
||||
next_button.disabled = true
|
||||
next_button.text = "Submitting..."
|
||||
@@ -1542,12 +1619,15 @@ func _submit_or_next() -> void:
|
||||
return
|
||||
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
|
||||
var attempted_choice := selected_choice
|
||||
var met_time_limit := true
|
||||
if BlockSessionService.session_kind == "core":
|
||||
met_time_limit = BlockSessionService.get_current_elapsed_seconds() <= BlockSessionService.core_current_target_seconds
|
||||
QuestionBankService.record_first_attempt(
|
||||
question_id_for_attempt,
|
||||
attempted_choice,
|
||||
_presented_choice_ids(),
|
||||
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))
|
||||
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())),
|
||||
met_time_limit
|
||||
)
|
||||
var result: Dictionary
|
||||
if BlockSessionService.session_kind == "core":
|
||||
@@ -1570,6 +1650,15 @@ func _submit_or_next() -> void:
|
||||
_update_question_elo_label(true)
|
||||
_show_answer_review()
|
||||
_trigger_correct_feedback()
|
||||
var earned_coin := (
|
||||
BlockSessionService.session_kind == "core"
|
||||
and bool(result.get("mastered", false))
|
||||
and met_time_limit
|
||||
)
|
||||
if earned_coin:
|
||||
var correct_choice_index := _choice_index_for_id(selected_choice)
|
||||
if correct_choice_index >= 0 and correct_choice_index < choice_rows.size():
|
||||
_play_coin_burst(choice_rows[correct_choice_index].get_global_rect())
|
||||
return
|
||||
_lock_wrong_choice(attempted_choice)
|
||||
_show_wrong_answer_followup(attempted_choice)
|
||||
@@ -1670,6 +1759,9 @@ func _on_random_question_failed(message: String) -> void:
|
||||
|
||||
|
||||
func _show_answer_review() -> void:
|
||||
if BlockSessionService.session_kind == "simulation":
|
||||
_clear_answer_review()
|
||||
return
|
||||
_show_explanation()
|
||||
_show_references(BlockSessionService.get_current_question().get("references", []))
|
||||
_ensure_answer_review_followups()
|
||||
@@ -1716,6 +1808,7 @@ func _show_explanation() -> void:
|
||||
explanation_plain_text = str(question.get("explanation", ""))
|
||||
_render_explanation()
|
||||
explanation_label.visible = true
|
||||
category_label.visible = not category_label.text.strip_edges().is_empty()
|
||||
_update_question_elo_label(true)
|
||||
_refresh_nav_palette()
|
||||
_update_content_vertical_alignment()
|
||||
@@ -1740,6 +1833,8 @@ func _clear_answer_review() -> void:
|
||||
explanation_label.visible = false
|
||||
if correct_review_panel != null:
|
||||
correct_review_panel.visible = false
|
||||
if category_label != null:
|
||||
category_label.visible = false
|
||||
_clear_container_children(teaching_cards)
|
||||
_clear_container_children(review_followups_content)
|
||||
review_followup_states.clear()
|
||||
@@ -1751,13 +1846,7 @@ func _clear_answer_review() -> void:
|
||||
func _update_content_vertical_alignment() -> void:
|
||||
if question_content == null:
|
||||
return
|
||||
# Before an answer is submitted the stem+choices block is usually shorter than the
|
||||
# available panel height; center it so it doesn't sit pinned to the top with a slab
|
||||
# of empty space below. Once the answer review content appears, go back to top
|
||||
# alignment so the growing explanation/follow-up sections read top-down and scroll
|
||||
# naturally instead of being centered mid-panel.
|
||||
var answered := correct_review_panel != null and correct_review_panel.visible
|
||||
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN if answered else BoxContainer.ALIGNMENT_CENTER
|
||||
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||||
|
||||
|
||||
func _clear_container_children(container: Control) -> void:
|
||||
@@ -1811,6 +1900,9 @@ func _render_review_followups() -> void:
|
||||
if review_followups_content == null:
|
||||
return
|
||||
_clear_container_children(review_followups_content)
|
||||
if BlockSessionService.session_kind == "simulation":
|
||||
review_followups_section.visible = false
|
||||
return
|
||||
if not BlockSessionService.show_review_followups:
|
||||
review_followups_section.visible = false
|
||||
return
|
||||
@@ -2043,7 +2135,10 @@ func _update_timer_bar() -> void:
|
||||
return
|
||||
var is_core_timer := BlockSessionService.session_kind == "core"
|
||||
var ratio := BlockSessionService.get_session_timer_ratio()
|
||||
timer_bar.value = ratio * 100.0
|
||||
var next_value := ratio * 100.0
|
||||
if last_timer_bar_value < 0.0 or absf(next_value - last_timer_bar_value) >= 0.1:
|
||||
last_timer_bar_value = next_value
|
||||
timer_bar.value = next_value
|
||||
if is_core_timer and timer_label.visible:
|
||||
_set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())))
|
||||
var color_state := "blue"
|
||||
@@ -2063,6 +2158,9 @@ func _set_timer_display(total_seconds: int) -> void:
|
||||
if timer_label == null:
|
||||
return
|
||||
var safe_seconds := maxi(0, total_seconds)
|
||||
if safe_seconds == last_timer_display_seconds:
|
||||
return
|
||||
last_timer_display_seconds = safe_seconds
|
||||
timer_label.text = "%02d:%02d" % [int(floor(float(safe_seconds) / 60.0)), safe_seconds % 60]
|
||||
|
||||
|
||||
@@ -2165,6 +2263,39 @@ func _trigger_correct_feedback() -> void:
|
||||
feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true))
|
||||
|
||||
|
||||
func _play_coin_burst(origin_rect: Rect2) -> void:
|
||||
if credits_badge == null:
|
||||
return
|
||||
var origin := origin_rect.get_center()
|
||||
var target := credits_badge.get_global_rect().get_center()
|
||||
var coin_count := 6
|
||||
var flight_time := 0.55
|
||||
var stagger := 0.035
|
||||
for index in range(coin_count):
|
||||
var coin := TextureRect.new()
|
||||
coin.texture = COIN_ICON
|
||||
coin.modulate = Brand.AMBER
|
||||
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 credits_badge != null:
|
||||
credits_badge.bump())
|
||||
|
||||
|
||||
func _update_answer_feedback(delta: float) -> void:
|
||||
if shake_time > 0.0:
|
||||
shake_time = max(0.0, shake_time - delta)
|
||||
@@ -2279,7 +2410,7 @@ func _refresh_toolbar_state() -> void:
|
||||
if notes_button != null:
|
||||
notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
||||
if flashcard_button != null:
|
||||
flashcard_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
||||
flashcard_button.visible = Brand.is_uat_environment() and BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
||||
flashcard_button.disabled = current_answer.is_empty()
|
||||
if bookmark_button != null:
|
||||
bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
||||
@@ -2289,6 +2420,10 @@ func _refresh_toolbar_state() -> void:
|
||||
bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question")
|
||||
if calculator_button != null:
|
||||
calculator_button.visible = true
|
||||
if slot_machine_button != null:
|
||||
slot_machine_button.visible = Brand.is_uat_environment()
|
||||
if credits_badge != null:
|
||||
credits_badge.visible = BlockSessionService.session_kind != "simulation"
|
||||
_apply_question_theme()
|
||||
|
||||
|
||||
@@ -2297,6 +2432,11 @@ func _toggle_calculator() -> void:
|
||||
calculator_panel.toggle_panel()
|
||||
|
||||
|
||||
func _toggle_slot_machine() -> void:
|
||||
if slot_machine_panel != null:
|
||||
slot_machine_panel.toggle_panel()
|
||||
|
||||
|
||||
func _toggle_bookmark_current_question() -> void:
|
||||
var question := BlockSessionService.get_current_question()
|
||||
if not question.is_empty():
|
||||
@@ -3254,14 +3394,17 @@ func _apply_saved_choice_state() -> void:
|
||||
|
||||
|
||||
func _is_correct_answer_locked() -> bool:
|
||||
return bool(current_answer.get("is_correct", false))
|
||||
return bool(current_answer.get("is_correct", false)) or (
|
||||
BlockSessionService.session_kind == "simulation" and
|
||||
bool(current_answer.get("is_submitted", false)))
|
||||
|
||||
|
||||
func _refresh_submit_state() -> void:
|
||||
if next_button == null:
|
||||
return
|
||||
if BlockSessionService.session_kind == "simulation":
|
||||
next_button.disabled = false
|
||||
next_button.disabled = selected_choice == ""
|
||||
next_button.text = "Submit Block" if selected_choice != "" and _next_pending_simulation_index() < 0 else "Next"
|
||||
return
|
||||
if pending_correct_advance or followup_required:
|
||||
next_button.disabled = true
|
||||
@@ -3298,7 +3441,9 @@ func _style_choice_button_for_state(index: int, choice_id: String) -> void:
|
||||
var dark: bool = true if theme_service == null else theme_service.is_dark_mode()
|
||||
var state := "default"
|
||||
var correct_id := str(BlockSessionService.get_current_question().get("correct_choice_id", ""))
|
||||
var reviewing := pending_correct_advance or not current_answer.is_empty()
|
||||
# Exam simulations don't reveal right/wrong - you move on after submitting,
|
||||
# same as a real exam - so only "selected"/"eliminated" styling applies here.
|
||||
var reviewing := BlockSessionService.session_kind != "simulation" and (pending_correct_advance or not current_answer.is_empty())
|
||||
if reviewing and choice_id == correct_id:
|
||||
state = "correct"
|
||||
elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id != "" and choice_id != correct_id):
|
||||
@@ -3420,6 +3565,7 @@ func _build_followup_panel(_index: int, choice_id: String) -> void:
|
||||
"selected_option_text": "",
|
||||
"was_correct": false,
|
||||
"resolved": false,
|
||||
"collapsed": false,
|
||||
"has_authored_choices": bool(followup.get("has_authored_choices", false)),
|
||||
"is_main_correct": false
|
||||
}
|
||||
@@ -3491,6 +3637,7 @@ func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_se
|
||||
state["selected_option_text"] = selected_option_text
|
||||
state["was_correct"] = is_correct
|
||||
state["resolved"] = true
|
||||
state["collapsed"] = false
|
||||
followup_states[resolved_choice_id] = state
|
||||
_render_followup_state(resolved_choice_id)
|
||||
|
||||
@@ -3517,6 +3664,8 @@ func _render_followup_state(choice_id: String) -> void:
|
||||
_style_followup_card(index, state)
|
||||
if not bool(state.get("resolved", false)):
|
||||
_render_active_followup(index, state)
|
||||
elif bool(state.get("collapsed", false)):
|
||||
_build_resolved_followup_toggle(index, state, false)
|
||||
else:
|
||||
_build_resolved_followup(index, state)
|
||||
choice_followup_panels[index].visible = true
|
||||
@@ -3525,13 +3674,95 @@ func _render_followup_state(choice_id: String) -> void:
|
||||
|
||||
func _build_resolved_followup(index: int, state: Dictionary) -> void:
|
||||
var content := choice_followup_contents[index]
|
||||
_build_resolved_followup_toggle(index, state, true)
|
||||
|
||||
var prompt := Label.new()
|
||||
prompt.name = "FollowupPrompt"
|
||||
prompt.text = str(state.get("prompt", "What finding in the stem makes this answer less appropriate?"))
|
||||
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_set_scaled_label(prompt, 14, Brand.text_color(_followup_is_dark()))
|
||||
content.add_child(prompt)
|
||||
|
||||
var selected_id := str(state.get("selected_option_id", ""))
|
||||
var correct_id := str(state.get("correct_choice_id", ""))
|
||||
var options: Array = state.get("choices", [])
|
||||
for option_index in range(options.size()):
|
||||
var option = options[option_index]
|
||||
if typeof(option) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var option_id := str(option.get("id", ""))
|
||||
var option_button := Button.new()
|
||||
option_button.name = "FollowupOption_%s" % option_id
|
||||
option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))]
|
||||
option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
option_button.disabled = true
|
||||
option_button.custom_minimum_size = Vector2(0, 38)
|
||||
_set_scaled_button_font(option_button, 14)
|
||||
_style_review_followup_option(
|
||||
option_button,
|
||||
_followup_is_dark(),
|
||||
option_id,
|
||||
selected_id,
|
||||
correct_id,
|
||||
true
|
||||
)
|
||||
content.add_child(option_button)
|
||||
|
||||
var explanation := Label.new()
|
||||
explanation.name = "FollowupExplanation"
|
||||
explanation.text = _strip_verdict_prefix(_choice_review_explanation(str(state.get("choice_id", "")), state))
|
||||
explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark()))
|
||||
content.add_child(explanation)
|
||||
|
||||
|
||||
func _build_resolved_followup_toggle(index: int, state: Dictionary, expanded: bool) -> void:
|
||||
var content := choice_followup_contents[index]
|
||||
var dark := _followup_is_dark()
|
||||
var has_authored_choices := bool(state.get("has_authored_choices", not state.get("choices", []).is_empty()))
|
||||
var was_correct := bool(state.get("was_correct", false))
|
||||
var accent := Brand.SUCCESS if was_correct else Color("fb5f6d") if has_authored_choices else Brand.PURPLE
|
||||
var row := HBoxContainer.new()
|
||||
row.name = "FollowupResult"
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
content.add_child(row)
|
||||
|
||||
var result := Label.new()
|
||||
result.name = "FollowupVerdict"
|
||||
result.text = ("Correct" if was_correct else "Incorrect") if has_authored_choices else "Review complete"
|
||||
result.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_set_scaled_label(result, 14, accent)
|
||||
row.add_child(result)
|
||||
|
||||
var toggle := Button.new()
|
||||
toggle.name = "FollowupToggle"
|
||||
toggle.flat = true
|
||||
toggle.text = "Minimize" if expanded else "Expand"
|
||||
toggle.tooltip_text = "Minimize follow-up review" if expanded else "Expand follow-up review"
|
||||
toggle.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg")
|
||||
toggle.icon_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
toggle.custom_minimum_size = Vector2(110, 30)
|
||||
toggle.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
toggle.add_theme_color_override("font_hover_color", accent)
|
||||
toggle.add_theme_color_override("icon_normal_color", Brand.text_color(dark))
|
||||
toggle.add_theme_color_override("icon_hover_color", accent)
|
||||
_set_scaled_button_font(toggle, 13)
|
||||
toggle.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", ""))))
|
||||
row.add_child(toggle)
|
||||
|
||||
|
||||
func _toggle_followup_state(choice_id: String) -> void:
|
||||
if not followup_states.has(choice_id):
|
||||
return
|
||||
var state: Dictionary = followup_states[choice_id]
|
||||
if not bool(state.get("resolved", false)):
|
||||
return
|
||||
state["collapsed"] = not bool(state.get("collapsed", false))
|
||||
followup_states[choice_id] = state
|
||||
_render_followup_state(choice_id)
|
||||
_apply_saved_choice_state()
|
||||
|
||||
|
||||
func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContainer:
|
||||
var body := PanelContainer.new()
|
||||
body.add_theme_stylebox_override("panel", _followup_body_box(_followup_is_dark(), is_correct))
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
extends PanelContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const CABINET_FRAME := preload("res://assets/slot_machine/cabinet_frame.png")
|
||||
const LEVER_TEXTURE := preload("res://assets/slot_machine/lever.png")
|
||||
const SLOT_COIN := preload("res://assets/slot_machine/slot_coin.png")
|
||||
const SLOT_GEM := preload("res://assets/slot_machine/slot_gem.png")
|
||||
const SLOT_POTION := preload("res://assets/slot_machine/slot_potion.png")
|
||||
const SLOT_SHIELD := preload("res://assets/slot_machine/slot_shield.png")
|
||||
const SLOT_COIN_STACK := preload("res://assets/slot_machine/slot_coin_stack.png")
|
||||
const SLOT_GIFT := preload("res://assets/slot_machine/slot_gift.png")
|
||||
const CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg")
|
||||
const REEL_SYMBOLS := [SLOT_COIN, SLOT_GEM, SLOT_POTION, SLOT_SHIELD, SLOT_COIN_STACK, SLOT_GIFT]
|
||||
# fraction-of-image rect of the cabinet's empty reel window, measured against cabinet_frame.png
|
||||
const WINDOW_RECT := Rect2(0.203, 0.246, 0.591, 0.604)
|
||||
const REWARD_ICONS := {
|
||||
"jackpot": {"texture": SLOT_COIN, "modulate": false},
|
||||
"epic": {"texture": SLOT_GEM, "modulate": false},
|
||||
"great": {"texture": SLOT_SHIELD, "modulate": false},
|
||||
"exam_token": {"texture": CLOCK_ICON, "modulate": true},
|
||||
"qbank_reset": {"texture": SLOT_GIFT, "modulate": false},
|
||||
"nice": {"texture": SLOT_COIN_STACK, "modulate": false},
|
||||
"good_luck": {"texture": SLOT_COIN, "modulate": false, "tint": true},
|
||||
}
|
||||
const MACHINE_SIZE := 380.0
|
||||
const CELL := 71.0
|
||||
const REEL_SEPARATION := 4
|
||||
|
||||
var main: Control
|
||||
|
||||
var drag_active := false
|
||||
var drag_offset := Vector2.ZERO
|
||||
var has_been_positioned := false
|
||||
|
||||
var machine_root: Control
|
||||
var lever_button: TextureButton
|
||||
var reel_states: Array = []
|
||||
var status_label: Label
|
||||
|
||||
var _spinning := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "RzSlotMachinePanel"
|
||||
visible = false
|
||||
top_level = true
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
custom_minimum_size = Vector2(440, 470)
|
||||
size = custom_minimum_size
|
||||
|
||||
_build_ui()
|
||||
|
||||
if not get_viewport().size_changed.is_connected(_on_viewport_size_changed):
|
||||
get_viewport().size_changed.connect(_on_viewport_size_changed)
|
||||
_on_viewport_size_changed()
|
||||
|
||||
CreditsService.credits_changed.connect(_refresh_spin_button)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls())
|
||||
refresh_theme_controls()
|
||||
_refresh_spin_button()
|
||||
|
||||
|
||||
func open_panel() -> void:
|
||||
visible = true
|
||||
move_to_front()
|
||||
if not has_been_positioned:
|
||||
var viewport_size := get_viewport_rect().size
|
||||
position = Vector2(maxf(12.0, viewport_size.x - size.x - 24.0), 72.0)
|
||||
has_been_positioned = true
|
||||
_clamp_to_viewport()
|
||||
CreditsService.refresh()
|
||||
CreditsService.fetch_spin_config()
|
||||
_refresh_spin_button()
|
||||
|
||||
|
||||
func close_panel() -> void:
|
||||
visible = false
|
||||
drag_active = false
|
||||
|
||||
|
||||
func toggle_panel() -> void:
|
||||
if visible:
|
||||
close_panel()
|
||||
else:
|
||||
open_panel()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
add_child(margin)
|
||||
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
margin.add_child(content)
|
||||
|
||||
_build_title_bar(content)
|
||||
_build_machine(content)
|
||||
|
||||
status_label = Label.new()
|
||||
status_label.add_theme_font_override("font", Brand.font)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
content.add_child(status_label)
|
||||
|
||||
|
||||
func _build_title_bar(content: VBoxContainer) -> void:
|
||||
var title_bar := HBoxContainer.new()
|
||||
title_bar.name = "SlotMachineTitleBar"
|
||||
title_bar.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
title_bar.custom_minimum_size.y = 34
|
||||
title_bar.gui_input.connect(_on_title_bar_gui_input)
|
||||
content.add_child(title_bar)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
title_bar.add_child(spacer)
|
||||
|
||||
var close := Button.new()
|
||||
close.name = "SlotMachineClose"
|
||||
close.text = "×"
|
||||
close.tooltip_text = "Close slot machine"
|
||||
close.custom_minimum_size = Vector2(36, 32)
|
||||
close.focus_mode = Control.FOCUS_NONE
|
||||
close.pressed.connect(close_panel)
|
||||
title_bar.add_child(close)
|
||||
|
||||
|
||||
func _build_machine(parent: Control) -> void:
|
||||
var center := CenterContainer.new()
|
||||
parent.add_child(center)
|
||||
|
||||
machine_root = Control.new()
|
||||
machine_root.custom_minimum_size = Vector2(MACHINE_SIZE, MACHINE_SIZE)
|
||||
center.add_child(machine_root)
|
||||
|
||||
var frame := TextureRect.new()
|
||||
frame.texture = CABINET_FRAME
|
||||
frame.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
frame.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
frame.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
|
||||
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
machine_root.add_child(frame)
|
||||
|
||||
var window := Control.new()
|
||||
window.anchor_left = WINDOW_RECT.position.x
|
||||
window.anchor_top = WINDOW_RECT.position.y
|
||||
window.anchor_right = WINDOW_RECT.position.x + WINDOW_RECT.size.x
|
||||
window.anchor_bottom = WINDOW_RECT.position.y + WINDOW_RECT.size.y
|
||||
window.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
machine_root.add_child(window)
|
||||
|
||||
var reel_center := CenterContainer.new()
|
||||
reel_center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
window.add_child(reel_center)
|
||||
|
||||
var reels_grid := HBoxContainer.new()
|
||||
reels_grid.add_theme_constant_override("separation", REEL_SEPARATION)
|
||||
reel_center.add_child(reels_grid)
|
||||
|
||||
reel_states.clear()
|
||||
for _column_index in range(3):
|
||||
var clip := Control.new()
|
||||
clip.clip_contents = true
|
||||
clip.custom_minimum_size = Vector2(CELL, CELL * 3)
|
||||
reels_grid.add_child(clip)
|
||||
|
||||
var strip := VBoxContainer.new()
|
||||
strip.add_theme_constant_override("separation", 0)
|
||||
clip.add_child(strip)
|
||||
|
||||
var icons: Array = []
|
||||
for _row_index in range(4):
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
|
||||
icon.custom_minimum_size = Vector2(CELL, CELL)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
strip.add_child(icon)
|
||||
icons.append(icon)
|
||||
|
||||
reel_states.append({
|
||||
"strip": strip,
|
||||
"icons": icons,
|
||||
"step": CELL,
|
||||
"speed": 420.0,
|
||||
"spinning": false,
|
||||
"stop_requested": false,
|
||||
"target_texture": null,
|
||||
"recycles_after_target": -1,
|
||||
"done": true,
|
||||
})
|
||||
|
||||
lever_button = TextureButton.new()
|
||||
lever_button.texture_normal = LEVER_TEXTURE
|
||||
lever_button.ignore_texture_size = true
|
||||
lever_button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
|
||||
lever_button.anchor_left = 0.80
|
||||
lever_button.anchor_right = 1.06
|
||||
lever_button.anchor_top = 0.24
|
||||
lever_button.anchor_bottom = 0.88
|
||||
lever_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
lever_button.tooltip_text = "Pull to spin"
|
||||
lever_button.resized.connect(func(): lever_button.pivot_offset = Vector2(lever_button.size.x * 0.5, lever_button.size.y * 0.92))
|
||||
lever_button.pressed.connect(_on_lever_pulled)
|
||||
machine_root.add_child(lever_button)
|
||||
|
||||
|
||||
func refresh_theme_controls() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||||
|
||||
|
||||
func _refresh_spin_button() -> void:
|
||||
if lever_button == null:
|
||||
return
|
||||
lever_button.disabled = _spinning or CreditsService.credits < CreditsService.spin_cost
|
||||
|
||||
|
||||
func _on_lever_pulled() -> void:
|
||||
_play_lever_pull_animation()
|
||||
_on_spin_pressed()
|
||||
|
||||
|
||||
func _play_lever_pull_animation() -> void:
|
||||
var tween := create_tween()
|
||||
tween.tween_property(lever_button, "rotation_degrees", -20.0, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
tween.tween_property(lever_button, "rotation_degrees", 0.0, 0.3).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
|
||||
|
||||
|
||||
func _on_spin_pressed() -> void:
|
||||
if _spinning:
|
||||
return
|
||||
_spinning = true
|
||||
_refresh_spin_button()
|
||||
await _do_one_spin()
|
||||
_spinning = false
|
||||
_refresh_spin_button()
|
||||
|
||||
|
||||
func _do_one_spin() -> bool:
|
||||
for state in reel_states:
|
||||
state["stop_requested"] = false
|
||||
state["target_texture"] = null
|
||||
state["recycles_after_target"] = -1
|
||||
state["done"] = false
|
||||
_spin_reel(state)
|
||||
|
||||
var result := await _await_spin_result()
|
||||
|
||||
if not result.get("ok", false):
|
||||
for state in reel_states:
|
||||
state["target_texture"] = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
|
||||
state["stop_requested"] = true
|
||||
await _await_reels_done()
|
||||
status_label.text = str(result.get("message", "The spin could not be completed."))
|
||||
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
|
||||
return false
|
||||
|
||||
var reward_key := str(result.get("reward_key", ""))
|
||||
var reward_texture := _icon_for_reward(reward_key)
|
||||
for index in range(reel_states.size()):
|
||||
var state: Dictionary = reel_states[index]
|
||||
get_tree().create_timer(index * 0.25).timeout.connect(func():
|
||||
state["target_texture"] = reward_texture
|
||||
state["stop_requested"] = true)
|
||||
await _await_reels_done()
|
||||
|
||||
var reward_label := str(result.get("reward_label", ""))
|
||||
var coins_won := int(result.get("coins_won", 0))
|
||||
status_label.text = "You won: %s" % reward_label
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
if coins_won > 0:
|
||||
_play_coin_burst(coins_won)
|
||||
elif main != null:
|
||||
var credits_badge: Object = main.get("credits_badge")
|
||||
if credits_badge != null:
|
||||
credits_badge.bump()
|
||||
return true
|
||||
|
||||
|
||||
func _icon_for_reward(key: String) -> Texture2D:
|
||||
var spec: Dictionary = REWARD_ICONS.get(key, {})
|
||||
var texture: Texture2D = spec.get("texture", SLOT_COIN)
|
||||
return texture if texture != null else SLOT_COIN
|
||||
|
||||
|
||||
func _await_reels_done() -> void:
|
||||
while true:
|
||||
var all_done := true
|
||||
for state in reel_states:
|
||||
if not state.get("done", false):
|
||||
all_done = false
|
||||
break
|
||||
if all_done:
|
||||
return
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
func _await_spin_result() -> Dictionary:
|
||||
var result := {}
|
||||
var on_resolved := func(reward_key: String, reward_label: String, coins_won: int):
|
||||
result.merge({"ok": true, "reward_key": reward_key, "reward_label": reward_label, "coins_won": coins_won}, true)
|
||||
var on_failed := func(message: String):
|
||||
result.merge({"ok": false, "message": message}, true)
|
||||
CreditsService.spin_resolved.connect(on_resolved, CONNECT_ONE_SHOT)
|
||||
CreditsService.credits_failed.connect(on_failed, CONNECT_ONE_SHOT)
|
||||
CreditsService.spin()
|
||||
var deadline := Time.get_ticks_msec() + 20000
|
||||
while result.is_empty() and Time.get_ticks_msec() < deadline:
|
||||
await get_tree().process_frame
|
||||
if result.is_empty():
|
||||
result = {"ok": false, "message": "The spin timed out. Check your connection and try again."}
|
||||
if CreditsService.spin_resolved.is_connected(on_resolved):
|
||||
CreditsService.spin_resolved.disconnect(on_resolved)
|
||||
if CreditsService.credits_failed.is_connected(on_failed):
|
||||
CreditsService.credits_failed.disconnect(on_failed)
|
||||
return result
|
||||
|
||||
|
||||
func _spin_reel(state: Dictionary) -> void:
|
||||
state["spinning"] = true
|
||||
var strip: VBoxContainer = state["strip"]
|
||||
var icons: Array = state["icons"]
|
||||
var step: float = state["step"]
|
||||
var speed: float = state["speed"]
|
||||
while true:
|
||||
var current_speed: float = speed if state["recycles_after_target"] == -1 else speed * 0.45
|
||||
strip.position.y -= current_speed * get_process_delta_time()
|
||||
if strip.position.y <= -step:
|
||||
strip.position.y += step
|
||||
var front: TextureRect = icons.pop_front()
|
||||
strip.move_child(front, strip.get_child_count() - 1)
|
||||
icons.append(front)
|
||||
if state["stop_requested"] and state["recycles_after_target"] == -1:
|
||||
front.texture = state["target_texture"]
|
||||
state["recycles_after_target"] = 0
|
||||
else:
|
||||
front.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
|
||||
if state["recycles_after_target"] >= 0:
|
||||
state["recycles_after_target"] += 1
|
||||
if state["recycles_after_target"] == 2:
|
||||
strip.position.y = 0.0
|
||||
break
|
||||
await get_tree().process_frame
|
||||
state["spinning"] = false
|
||||
state["done"] = true
|
||||
|
||||
|
||||
func _play_coin_burst(coins_won: int) -> void:
|
||||
if main == null:
|
||||
return
|
||||
var credits_badge: Control = main.get("credits_badge")
|
||||
if credits_badge == null or not is_instance_valid(credits_badge):
|
||||
return
|
||||
var origin := machine_root.get_global_rect().get_center()
|
||||
var target := credits_badge.get_global_rect().get_center()
|
||||
var coin_count := clampi(3 + coins_won / 500, 4, 10)
|
||||
var flight_time := 0.55
|
||||
var stagger := 0.035
|
||||
for index in range(coin_count):
|
||||
var coin := TextureRect.new()
|
||||
coin.texture = SLOT_COIN
|
||||
coin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
coin.custom_minimum_size = Vector2(16, 16)
|
||||
coin.size = Vector2(16, 16)
|
||||
coin.top_level = true
|
||||
coin.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
coin.pivot_offset = Vector2(8, 8)
|
||||
var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0))
|
||||
coin.global_position = origin + jitter - coin.size * 0.5
|
||||
add_child(coin)
|
||||
var tween := create_tween()
|
||||
tween.tween_interval(index * stagger)
|
||||
tween.set_parallel(true)
|
||||
tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
|
||||
tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
|
||||
tween.set_parallel(false)
|
||||
tween.tween_callback(coin.queue_free)
|
||||
var settle_time := (coin_count - 1) * stagger + flight_time
|
||||
get_tree().create_timer(settle_time).timeout.connect(func():
|
||||
if is_instance_valid(credits_badge):
|
||||
credits_badge.bump())
|
||||
|
||||
|
||||
func _on_title_bar_gui_input(event: InputEvent) -> void:
|
||||
var touch_event := event as InputEventScreenTouch
|
||||
if touch_event != null:
|
||||
drag_active = touch_event.pressed
|
||||
if drag_active:
|
||||
drag_offset = touch_event.position
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
var touch_drag := event as InputEventScreenDrag
|
||||
if touch_drag != null and drag_active:
|
||||
global_position += touch_drag.relative
|
||||
_clamp_to_viewport()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
var mouse_event := event as InputEventMouseButton
|
||||
if mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_LEFT:
|
||||
drag_active = mouse_event.pressed
|
||||
if drag_active:
|
||||
drag_offset = mouse_event.global_position - global_position
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
var motion := event as InputEventMouseMotion
|
||||
if motion != null and drag_active:
|
||||
global_position = motion.global_position - drag_offset
|
||||
_clamp_to_viewport()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _on_viewport_size_changed() -> void:
|
||||
_clamp_to_viewport()
|
||||
|
||||
|
||||
func _clamp_to_viewport() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
position.x = clampf(position.x, 12.0, maxf(12.0, viewport_size.x - size.x - 12.0))
|
||||
position.y = clampf(position.y, 12.0, maxf(12.0, viewport_size.y - size.y - 12.0))
|
||||
@@ -0,0 +1 @@
|
||||
uid://col1hauyr27rn
|
||||
@@ -0,0 +1,549 @@
|
||||
extends VBoxContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const ICON_BY_SKU := {
|
||||
"exam_token_1": "clock-3",
|
||||
"qbank_reset_1": "rotate-ccw",
|
||||
}
|
||||
const FEATURES_BY_SKU := {
|
||||
"exam_token_1": ["Full 6-block exam", "Timed & scored", "Performance analytics"],
|
||||
"qbank_reset_1": ["Reset eligibility", "Keep your stats", "Smart spaced retention"],
|
||||
}
|
||||
const POPULAR_SKUS := ["exam_token_1"]
|
||||
const TAB_DEFS := [
|
||||
{"label": "Featured", "icon": "star", "active": true},
|
||||
{"label": "Tokens", "icon": "clipboard-list", "active": false},
|
||||
{"label": "Boosts", "icon": "activity", "active": false},
|
||||
{"label": "Content", "icon": "book-open", "active": false},
|
||||
{"label": "Customization", "icon": "settings", "active": false},
|
||||
{"label": "Bundles", "icon": "clipboard-plus", "active": false},
|
||||
]
|
||||
|
||||
var main: Control
|
||||
var subtitle_label: Label
|
||||
var summary_card: PanelContainer
|
||||
var summary_icon_panel: PanelContainer
|
||||
var summary_icon: TextureRect
|
||||
var balance_value_label: Label
|
||||
var coin_caption_label: Label
|
||||
var inventory_label: Label
|
||||
var tab_controls: Array = []
|
||||
var section_heading: Label
|
||||
var section_subtitle: Label
|
||||
var products_grid: HFlowContainer
|
||||
var status_label: Label
|
||||
var confirm_dialog: ConfirmationDialog
|
||||
var footer_card: PanelContainer
|
||||
var footer_icon_panel: PanelContainer
|
||||
var footer_icon: TextureRect
|
||||
var footer_heading: Label
|
||||
var footer_description: Label
|
||||
var pending_sku := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 18)
|
||||
|
||||
_build_header()
|
||||
_build_summary_card()
|
||||
_build_tabs()
|
||||
|
||||
section_heading = Label.new()
|
||||
section_heading.text = "Featured"
|
||||
section_heading.add_theme_font_override("font", Brand.font)
|
||||
section_heading.add_theme_font_size_override("font_size", 18)
|
||||
add_child(section_heading)
|
||||
section_subtitle = Label.new()
|
||||
section_subtitle.text = "Hand-picked items to supercharge your Step 3 prep."
|
||||
section_subtitle.add_theme_font_override("font", Brand.font)
|
||||
section_subtitle.add_theme_font_size_override("font_size", 12)
|
||||
section_subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
add_child(section_subtitle)
|
||||
|
||||
products_grid = HFlowContainer.new()
|
||||
products_grid.add_theme_constant_override("h_separation", 14)
|
||||
products_grid.add_theme_constant_override("v_separation", 14)
|
||||
add_child(products_grid)
|
||||
|
||||
status_label = Label.new()
|
||||
status_label.add_theme_font_override("font", Brand.font)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(status_label)
|
||||
|
||||
confirm_dialog = ConfirmationDialog.new()
|
||||
confirm_dialog.confirmed.connect(_confirm_purchase)
|
||||
add_child(confirm_dialog)
|
||||
|
||||
_build_footer()
|
||||
|
||||
CreditsService.credits_changed.connect(_refresh)
|
||||
CreditsService.credits_failed.connect(_show_error)
|
||||
CreditsService.store_operation_completed.connect(_show_message)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_theme_controls(); _refresh())
|
||||
_refresh()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
CreditsService.refresh()
|
||||
_refresh()
|
||||
|
||||
|
||||
func _build_header() -> void:
|
||||
var title := Label.new()
|
||||
title.text = "RZ Coin Shop"
|
||||
title.add_theme_font_override("font", Brand.font)
|
||||
title.add_theme_font_size_override("font_size", 30)
|
||||
add_child(title)
|
||||
subtitle_label = Label.new()
|
||||
subtitle_label.text = "Spend your RZ Coins on premium content and boosts."
|
||||
subtitle_label.add_theme_font_override("font", Brand.font)
|
||||
subtitle_label.add_theme_font_size_override("font_size", 14)
|
||||
subtitle_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
add_child(subtitle_label)
|
||||
|
||||
|
||||
func _build_summary_card() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
summary_card = PanelContainer.new()
|
||||
summary_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
|
||||
summary_card.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
add_child(summary_card)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
summary_card.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(row)
|
||||
|
||||
summary_icon_panel = PanelContainer.new()
|
||||
summary_icon_panel.custom_minimum_size = Vector2(40, 40)
|
||||
summary_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
summary_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 20))
|
||||
summary_icon = TextureRect.new()
|
||||
summary_icon.texture = load("res://assets/icons/lucide/star.svg")
|
||||
summary_icon.custom_minimum_size = Vector2(22, 22)
|
||||
summary_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
summary_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
summary_icon.modulate = Brand.AMBER
|
||||
summary_icon_panel.add_child(summary_icon)
|
||||
row.add_child(summary_icon_panel)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
copy.add_theme_constant_override("separation", 0)
|
||||
balance_value_label = Label.new()
|
||||
balance_value_label.add_theme_font_override("font", Brand.font)
|
||||
balance_value_label.add_theme_font_size_override("font_size", 22)
|
||||
balance_value_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
copy.add_child(balance_value_label)
|
||||
coin_caption_label = Label.new()
|
||||
coin_caption_label.text = "RZ Coins"
|
||||
coin_caption_label.add_theme_font_override("font", Brand.font)
|
||||
coin_caption_label.add_theme_font_size_override("font_size", 12)
|
||||
coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
copy.add_child(coin_caption_label)
|
||||
inventory_label = Label.new()
|
||||
inventory_label.add_theme_font_override("font", Brand.font)
|
||||
inventory_label.add_theme_font_size_override("font_size", 12)
|
||||
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
copy.add_child(inventory_label)
|
||||
row.add_child(copy)
|
||||
|
||||
|
||||
func _build_tabs() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
tab_controls.clear()
|
||||
|
||||
var tabs_row := HFlowContainer.new()
|
||||
tabs_row.add_theme_constant_override("h_separation", 20)
|
||||
tabs_row.add_theme_constant_override("v_separation", 8)
|
||||
add_child(tabs_row)
|
||||
|
||||
for tab_def_variant in TAB_DEFS:
|
||||
var tab_def: Dictionary = tab_def_variant
|
||||
var tab := VBoxContainer.new()
|
||||
tab.add_theme_constant_override("separation", 6)
|
||||
tabs_row.add_child(tab)
|
||||
|
||||
var content := HBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 6)
|
||||
tab.add_child(content)
|
||||
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/%s.svg" % str(tab_def.get("icon", "star")))
|
||||
icon.custom_minimum_size = Vector2(16, 16)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
content.add_child(icon)
|
||||
|
||||
var label := Label.new()
|
||||
label.text = str(tab_def.get("label", ""))
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
content.add_child(label)
|
||||
|
||||
var underline := ColorRect.new()
|
||||
underline.custom_minimum_size = Vector2(0, 2)
|
||||
tab.add_child(underline)
|
||||
|
||||
var active := bool(tab_def.get("active", false))
|
||||
tab_controls.append({"icon": icon, "label": label, "underline": underline, "active": active})
|
||||
|
||||
var divider := ColorRect.new()
|
||||
divider.custom_minimum_size = Vector2(0, 1)
|
||||
add_child(divider)
|
||||
tab_controls.append({"divider": divider})
|
||||
|
||||
_apply_tab_theme(dark)
|
||||
|
||||
|
||||
func _apply_tab_theme(dark: bool) -> void:
|
||||
for entry_variant in tab_controls:
|
||||
var entry: Dictionary = entry_variant
|
||||
if entry.has("divider"):
|
||||
(entry["divider"] as ColorRect).color = Color("2a365a") if dark else Brand.LIGHT_BORDER
|
||||
continue
|
||||
var active := bool(entry["active"])
|
||||
var color := Brand.ROYAL_BLUE if active else Brand.muted_color(dark)
|
||||
(entry["icon"] as TextureRect).modulate = color
|
||||
(entry["label"] as Label).add_theme_color_override("font_color", color)
|
||||
(entry["underline"] as ColorRect).color = Brand.ROYAL_BLUE if active else Color(0, 0, 0, 0)
|
||||
|
||||
|
||||
func _build_footer() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
footer_card = PanelContainer.new()
|
||||
footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
|
||||
add_child(footer_card)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
footer_card.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(row)
|
||||
|
||||
footer_icon_panel = PanelContainer.new()
|
||||
footer_icon_panel.custom_minimum_size = Vector2(36, 36)
|
||||
footer_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))
|
||||
footer_icon = TextureRect.new()
|
||||
footer_icon.texture = load("res://assets/icons/lucide/activity.svg")
|
||||
footer_icon.custom_minimum_size = Vector2(18, 18)
|
||||
footer_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
footer_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
footer_icon.modulate = Brand.ROYAL_BLUE
|
||||
footer_icon_panel.add_child(footer_icon)
|
||||
row.add_child(footer_icon_panel)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
copy.add_theme_constant_override("separation", 2)
|
||||
footer_heading = Label.new()
|
||||
footer_heading.text = "How to earn RZ Coins"
|
||||
footer_heading.add_theme_font_override("font", Brand.font)
|
||||
footer_heading.add_theme_font_size_override("font_size", 15)
|
||||
footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
copy.add_child(footer_heading)
|
||||
footer_description = Label.new()
|
||||
footer_description.text = "Answer questions correctly on the first try, within the time limit."
|
||||
footer_description.add_theme_font_override("font", Brand.font)
|
||||
footer_description.add_theme_font_size_override("font_size", 12)
|
||||
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
footer_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.add_child(footer_description)
|
||||
row.add_child(copy)
|
||||
|
||||
|
||||
func _refresh_theme_controls() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
subtitle_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
summary_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
|
||||
summary_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 20))
|
||||
balance_value_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
_apply_tab_theme(dark)
|
||||
section_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
|
||||
footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))
|
||||
footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
balance_value_label.text = _format_thousands(CreditsService.credits)
|
||||
inventory_label.text = "%d exam token(s) · %d QBank reset token(s)" % [
|
||||
CreditsService.exam_tokens, CreditsService.qbank_reset_tokens]
|
||||
for child in products_grid.get_children():
|
||||
child.queue_free()
|
||||
for product_variant in CreditsService.products:
|
||||
if typeof(product_variant) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var product: Dictionary = product_variant
|
||||
var sku := str(product.get("sku", ""))
|
||||
var price := int(product.get("coinPrice", 0))
|
||||
products_grid.add_child(_build_item_card(
|
||||
dark,
|
||||
ICON_BY_SKU.get(sku, "star"),
|
||||
Brand.ROYAL_BLUE,
|
||||
str(product.get("name", "Store item")),
|
||||
str(product.get("description", "")),
|
||||
FEATURES_BY_SKU.get(sku, []),
|
||||
POPULAR_SKUS.has(sku),
|
||||
price,
|
||||
"Buy",
|
||||
CreditsService.credits < price,
|
||||
_ask_purchase.bind(sku, str(product.get("name", "item")), price)))
|
||||
if CreditsService.qbank_reset_tokens > 0:
|
||||
products_grid.add_child(_build_item_card(
|
||||
dark,
|
||||
"rotate-ccw",
|
||||
Brand.SUCCESS,
|
||||
"Use QBank Reset Token",
|
||||
"Question eligibility resets; ELO, history, bookmarks, notes, reviews, and earning limits are preserved.",
|
||||
FEATURES_BY_SKU.get("qbank_reset_1", []),
|
||||
false,
|
||||
-1,
|
||||
"Use token",
|
||||
false,
|
||||
_ask_reset))
|
||||
|
||||
|
||||
func _build_item_card(
|
||||
dark: bool,
|
||||
icon_name: String,
|
||||
accent: Color,
|
||||
title: String,
|
||||
description_text: String,
|
||||
features: Array,
|
||||
popular: bool,
|
||||
price: int,
|
||||
button_text: String,
|
||||
button_disabled: bool,
|
||||
on_press: Callable
|
||||
) -> PanelContainer:
|
||||
var card := _new_card(dark)
|
||||
var content: VBoxContainer = card.get_child(0).get_child(0)
|
||||
|
||||
var top_row := HBoxContainer.new()
|
||||
content.add_child(top_row)
|
||||
top_row.add_child(_card_icon(dark, icon_name, accent))
|
||||
var badge_spacer := Control.new()
|
||||
badge_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
top_row.add_child(badge_spacer)
|
||||
if popular:
|
||||
top_row.add_child(_popular_badge())
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = title
|
||||
name_label.add_theme_font_override("font", Brand.font)
|
||||
name_label.add_theme_font_size_override("font_size", 16)
|
||||
name_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
content.add_child(name_label)
|
||||
|
||||
var description := Label.new()
|
||||
description.text = description_text
|
||||
description.add_theme_font_override("font", Brand.font)
|
||||
description.add_theme_font_size_override("font_size", 12)
|
||||
description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
description.custom_minimum_size = Vector2(190, 0)
|
||||
content.add_child(description)
|
||||
|
||||
if not features.is_empty():
|
||||
var bullets := VBoxContainer.new()
|
||||
bullets.add_theme_constant_override("separation", 4)
|
||||
content.add_child(bullets)
|
||||
for feature_text in features:
|
||||
bullets.add_child(_feature_row(dark, accent, str(feature_text)))
|
||||
|
||||
var divider := ColorRect.new()
|
||||
divider.custom_minimum_size = Vector2(0, 1)
|
||||
divider.color = Color("2a365a") if dark else Brand.LIGHT_BORDER
|
||||
content.add_child(divider)
|
||||
|
||||
var footer_row := HBoxContainer.new()
|
||||
footer_row.add_theme_constant_override("separation", 8)
|
||||
content.add_child(footer_row)
|
||||
if price >= 0:
|
||||
footer_row.add_child(_price_tag(dark, price))
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
footer_row.add_child(spacer)
|
||||
var button := Button.new()
|
||||
button.text = button_text
|
||||
button.custom_minimum_size = Vector2(64, 34)
|
||||
button.add_theme_font_override("font", Brand.font)
|
||||
_style_buy_button(button)
|
||||
button.disabled = button_disabled
|
||||
button.pressed.connect(on_press)
|
||||
footer_row.add_child(button)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
func _new_card(dark: bool) -> PanelContainer:
|
||||
var card := PanelContainer.new()
|
||||
card.custom_minimum_size = Vector2(230, 0)
|
||||
card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_top", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
card.add_child(margin)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(content)
|
||||
return card
|
||||
|
||||
|
||||
func _card_icon(dark: bool, icon_name: String, accent: Color) -> PanelContainer:
|
||||
var icon_panel := PanelContainer.new()
|
||||
icon_panel.custom_minimum_size = Vector2(48, 48)
|
||||
icon_panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 24))
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
||||
icon.custom_minimum_size = Vector2(24, 24)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = accent
|
||||
icon_panel.add_child(icon)
|
||||
return icon_panel
|
||||
|
||||
|
||||
func _popular_badge() -> PanelContainer:
|
||||
var badge := PanelContainer.new()
|
||||
badge.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = Brand.ROYAL_BLUE
|
||||
box.set_corner_radius_all(6)
|
||||
box.content_margin_left = 8
|
||||
box.content_margin_right = 8
|
||||
box.content_margin_top = 3
|
||||
box.content_margin_bottom = 3
|
||||
badge.add_theme_stylebox_override("panel", box)
|
||||
var label := Label.new()
|
||||
label.text = "Popular"
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 11)
|
||||
label.add_theme_color_override("font_color", Brand.DARK_TEXT)
|
||||
badge.add_child(label)
|
||||
return badge
|
||||
|
||||
|
||||
func _feature_row(dark: bool, accent: Color, text: String) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 6)
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/circle-check.svg")
|
||||
icon.custom_minimum_size = Vector2(14, 14)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = accent
|
||||
row.add_child(icon)
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 12)
|
||||
label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
row.add_child(label)
|
||||
return row
|
||||
|
||||
|
||||
func _price_tag(dark: bool, price: int) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 4)
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/star.svg")
|
||||
icon.custom_minimum_size = Vector2(14, 14)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = Brand.AMBER
|
||||
row.add_child(icon)
|
||||
var label := Label.new()
|
||||
label.text = _format_thousands(price)
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
row.add_child(label)
|
||||
return row
|
||||
|
||||
|
||||
func _style_buy_button(button: Button) -> void:
|
||||
var normal := StyleBoxFlat.new()
|
||||
normal.bg_color = Brand.ROYAL_BLUE
|
||||
normal.set_corner_radius_all(8)
|
||||
var hover := StyleBoxFlat.new()
|
||||
hover.bg_color = Brand.SKY_BLUE
|
||||
hover.set_corner_radius_all(8)
|
||||
var disabled := StyleBoxFlat.new()
|
||||
disabled.bg_color = Color(Brand.ROYAL_BLUE, 0.35)
|
||||
disabled.set_corner_radius_all(8)
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", normal)
|
||||
button.add_theme_stylebox_override("disabled", disabled)
|
||||
button.add_theme_color_override("font_color", Brand.DARK_TEXT)
|
||||
button.add_theme_color_override("font_hover_color", Brand.DARK_TEXT)
|
||||
button.add_theme_color_override("font_disabled_color", Color(Brand.DARK_TEXT, 0.7))
|
||||
|
||||
|
||||
func _ask_purchase(sku: String, product_name: String, price: int) -> void:
|
||||
pending_sku = sku
|
||||
confirm_dialog.title = "Confirm purchase"
|
||||
confirm_dialog.dialog_text = "Buy %s for %d Rz Coins?" % [product_name, price]
|
||||
confirm_dialog.popup_centered()
|
||||
|
||||
|
||||
func _confirm_purchase() -> void:
|
||||
if pending_sku == "__reset__":
|
||||
CreditsService.reset_qbank()
|
||||
elif not pending_sku.is_empty():
|
||||
CreditsService.purchase(pending_sku)
|
||||
pending_sku = ""
|
||||
|
||||
|
||||
func _ask_reset() -> void:
|
||||
pending_sku = "__reset__"
|
||||
confirm_dialog.title = "Reset QBank eligibility"
|
||||
confirm_dialog.dialog_text = "Make all QBank questions eligible again? ELO, answer history, bookmarks, notes, reviews, and the rolling coin cap will be preserved."
|
||||
confirm_dialog.popup_centered()
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
|
||||
|
||||
|
||||
func _show_message(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
_refresh()
|
||||
|
||||
|
||||
func _format_thousands(value: int) -> String:
|
||||
var digits := str(absi(value))
|
||||
var grouped := ""
|
||||
var count := 0
|
||||
for index in range(digits.length() - 1, -1, -1):
|
||||
grouped = digits[index] + grouped
|
||||
count += 1
|
||||
if count % 3 == 0 and index != 0:
|
||||
grouped = "," + grouped
|
||||
return ("-" if value < 0 else "") + grouped
|
||||
@@ -0,0 +1 @@
|
||||
uid://cldpnvs2k6r3t
|
||||
@@ -137,8 +137,8 @@ func _connect_services() -> void:
|
||||
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
|
||||
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
|
||||
ExamSimulationService.request_failed.connect(_on_request_failed)
|
||||
if not AccessService.status_changed.is_connected(_on_access_changed):
|
||||
AccessService.status_changed.connect(_on_access_changed)
|
||||
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:
|
||||
@@ -149,7 +149,6 @@ func on_show(_payload: Variant = null) -> void:
|
||||
load_more_button.disabled = true
|
||||
ExamSimulationService.load_readiness()
|
||||
ExamSimulationService.load_active()
|
||||
AccessService.refresh()
|
||||
_load_history_page()
|
||||
|
||||
|
||||
@@ -226,8 +225,7 @@ func _render_active_section() -> void:
|
||||
card_layout.add_child(actions)
|
||||
var primary := Button.new()
|
||||
primary.name = "PrimarySimulationAction"
|
||||
var has_exam_token := int(AccessService.status.get("examTokens", 0)) > 0
|
||||
primary.text = "Resume Simulation" if active else ("Start Simulation" if has_exam_token else "Get Exam Token")
|
||||
primary.text = "Resume Simulation" if active else "Start Simulation"
|
||||
primary.custom_minimum_size = Vector2(0, 48)
|
||||
primary.disabled = pending_launch or (not active and not bool(ExamSimulationService.readiness.get("ready", false)))
|
||||
_style_gradient_button(primary)
|
||||
@@ -456,7 +454,6 @@ func _on_simulation_loaded() -> void:
|
||||
return
|
||||
if pending_launch:
|
||||
pending_launch = false
|
||||
AccessService.refresh()
|
||||
_route_loaded_simulation()
|
||||
return
|
||||
if not pending_simulation_id.is_empty():
|
||||
@@ -488,9 +485,6 @@ func _route_loaded_simulation() -> void:
|
||||
|
||||
|
||||
func _launch_or_resume() -> void:
|
||||
if not _has_active_simulation() and int(AccessService.status.get("examTokens", 0)) <= 0:
|
||||
main.show_screen("access_store")
|
||||
return
|
||||
pending_launch = true
|
||||
pending_simulation_id = ""
|
||||
_render_active_section()
|
||||
@@ -546,9 +540,9 @@ func _on_request_failed(message: String) -> void:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_access_changed(_status: Dictionary) -> void:
|
||||
if visible:
|
||||
_render_active_section()
|
||||
func _on_exam_token_required() -> void:
|
||||
pending_launch = false
|
||||
main.show_screen("rz_store")
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
@@ -577,8 +571,7 @@ func _active_history_summary() -> Dictionary:
|
||||
|
||||
func _readiness_message() -> String:
|
||||
if bool(ExamSimulationService.readiness.get("ready", false)):
|
||||
var tokens := int(AccessService.status.get("examTokens", 0))
|
||||
return "Exam tokens available: %d. One token is consumed when a new simulation is created." % tokens
|
||||
return "The question bank is ready. Start when you have enough uninterrupted time for a full simulation."
|
||||
var available := int(ExamSimulationService.readiness.get("availableQuestions", 0))
|
||||
var required := int(ExamSimulationService.readiness.get("requiredQuestions", 232))
|
||||
return "Simulation unlocks when %d published questions are available. Currently %d/%d." % [required, available, required]
|
||||
|
||||
@@ -1,91 +1,283 @@
|
||||
extends VBoxContainer
|
||||
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const CORRECT_ICON := preload("res://assets/icons/lucide/circle-check.svg")
|
||||
const PROBABILITY_ICON := preload("res://assets/icons/lucide/activity.svg")
|
||||
const SEEN_ICON := preload("res://assets/icons/lucide/eye.svg")
|
||||
const CATEGORY_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-increasing.svg")
|
||||
const REVIEW_ICON := preload("res://assets/icons/lucide/clipboard-list.svg")
|
||||
const CATEGORY_PALETTE := [Brand.ROYAL_BLUE, Brand.SUCCESS, Brand.AMBER, Brand.MAGENTA, Brand.SKY_BLUE]
|
||||
|
||||
var main: Control
|
||||
var score_label: Label
|
||||
var summary_label: Label
|
||||
var category_list: VBoxContainer
|
||||
var content: VBoxContainer
|
||||
var current_simulation := {}
|
||||
var return_screen := "selection"
|
||||
var done_button: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_theme_constant_override("separation", 12)
|
||||
var eyebrow := Label.new()
|
||||
eyebrow.text = "STEP 3 DAY 1 SIMULATION COMPLETE"
|
||||
Style.set_label(eyebrow, 14, Style.CYAN)
|
||||
add_child(eyebrow)
|
||||
score_label = Label.new()
|
||||
Style.set_label(score_label, 36, Style.LIME)
|
||||
add_child(score_label)
|
||||
summary_label = Label.new()
|
||||
summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Style.set_label(summary_label, 17, Style.TEXT)
|
||||
add_child(summary_label)
|
||||
|
||||
add_theme_constant_override("separation", 0)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
var content := VBoxContainer.new()
|
||||
content = VBoxContainer.new()
|
||||
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
content.add_theme_constant_override("separation", 20)
|
||||
scroll.add_child(content)
|
||||
var category_heading := Label.new()
|
||||
category_heading.text = "Performance by content area"
|
||||
Style.set_label(category_heading, 22, Style.TEXT)
|
||||
content.add_child(category_heading)
|
||||
category_list = VBoxContainer.new()
|
||||
content.add_child(category_list)
|
||||
var review_button := Button.new()
|
||||
review_button.text = "Review Questions"
|
||||
review_button.custom_minimum_size = Vector2(240, 50)
|
||||
review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
Style.style_primary_button(review_button)
|
||||
review_button.pressed.connect(func(): main.show_screen("simulation_review", {"simulation": current_simulation, "return_screen": return_screen}))
|
||||
content.add_child(review_button)
|
||||
|
||||
done_button = Button.new()
|
||||
done_button.text = "Return to study modes"
|
||||
done_button.custom_minimum_size = Vector2(240, 50)
|
||||
done_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
Style.style_primary_button(done_button)
|
||||
done_button.pressed.connect(_return_from_results)
|
||||
add_child(done_button)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): _render())
|
||||
|
||||
|
||||
func on_show(payload: Variant = null) -> void:
|
||||
var wrapper: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
|
||||
var simulation: Dictionary = wrapper.get("simulation", wrapper) if not wrapper.is_empty() else ExamSimulationService.simulation
|
||||
return_screen = str(wrapper.get("return_screen", "selection"))
|
||||
done_button.text = "Return to simulation history" if return_screen == "simulation_history" else "Return to study modes"
|
||||
current_simulation = simulation
|
||||
var score: Dictionary = simulation.get("score", {})
|
||||
_render()
|
||||
|
||||
|
||||
func _render() -> void:
|
||||
_clear(content)
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var score: Dictionary = current_simulation.get("score", {})
|
||||
content.add_child(_build_hero_card(dark, score))
|
||||
content.add_child(_build_stats_row(dark, score))
|
||||
content.add_child(_build_disclaimer(dark, score))
|
||||
content.add_child(_build_category_card(dark, score))
|
||||
content.add_child(_build_actions())
|
||||
|
||||
|
||||
func _build_hero_card(dark: bool, score: Dictionary) -> PanelContainer:
|
||||
var predicted := int(score.get("predictedScore", 0))
|
||||
var low := int(score.get("rangeLow", 0))
|
||||
var high := int(score.get("rangeHigh", 0))
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 24)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
copy.add_theme_constant_override("separation", 10)
|
||||
row.add_child(copy)
|
||||
|
||||
copy.add_child(_label("STEP 3 DAY 1 SIMULATION COMPLETE", 13, Brand.SKY_BLUE, true))
|
||||
var headline := _label("Provisional Step 3 estimate: %d" % predicted, 26, Brand.text_color(dark), true)
|
||||
headline.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.add_child(headline)
|
||||
|
||||
var badge := _label("%d-%d Predicted score range" % [low, high], 13, Brand.text_color(dark))
|
||||
badge.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
badge.add_theme_stylebox_override("normal", Brand.badge_box(dark, Brand.ROYAL_BLUE))
|
||||
copy.add_child(badge)
|
||||
|
||||
row.add_child(_score_medallion(dark, predicted, Brand.ROYAL_BLUE))
|
||||
return _card(dark, row, Brand.ROYAL_BLUE, true)
|
||||
|
||||
|
||||
func _score_medallion(dark: bool, score: int, accent: Color) -> Control:
|
||||
var wrap := Control.new()
|
||||
wrap.custom_minimum_size = Vector2(120, 120)
|
||||
wrap.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
wrap.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 60, true))
|
||||
wrap.add_child(panel)
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
panel.add_child(center)
|
||||
center.add_child(_label(str(score), 34, Brand.DARK_TEXT, true))
|
||||
return wrap
|
||||
|
||||
|
||||
func _build_stats_row(dark: bool, score: Dictionary) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 16)
|
||||
var correct := int(score.get("correct", 0))
|
||||
var total := int(score.get("total", 0))
|
||||
var probability := int(round(float(score.get("passProbability", 0.0)) * 100.0))
|
||||
score_label.text = "Predicted Step 3 score: %d (%d-%d)" % [predicted, low, high]
|
||||
summary_label.text = "%d/%d correct | Estimated probability of reaching 200: %d%% | Previously seen questions: %d\nThis provisional educational estimate is not an official USMLE or NBME score and excludes CCS performance." % [
|
||||
int(score.get("correct", 0)), int(score.get("total", 0)), probability, int(score.get("seenCount", 0))]
|
||||
_clear(category_list)
|
||||
for category in score.get("categories", {}):
|
||||
var value: Dictionary = score["categories"][category]
|
||||
var label := Label.new()
|
||||
label.text = "%s: %d/%d" % [category, int(value.get("correct", 0)), int(value.get("total", 0))]
|
||||
Style.set_label(label, 16, Style.TEXT)
|
||||
category_list.add_child(label)
|
||||
var seen := int(score.get("seenCount", 0))
|
||||
row.add_child(_stat_card(dark, CORRECT_ICON, Brand.ROYAL_BLUE, "%d / %d" % [correct, total], "Correct"))
|
||||
row.add_child(_stat_card(dark, PROBABILITY_ICON, Brand.SUCCESS, "%d%%" % probability, "Heuristic probability of reaching 200+"))
|
||||
row.add_child(_stat_card(dark, SEEN_ICON, Brand.SKY_BLUE, str(seen), "Previously seen questions"))
|
||||
return row
|
||||
|
||||
|
||||
func _return_from_results() -> void:
|
||||
if return_screen == "simulation_history":
|
||||
main.show_screen("simulation_history", "preserve")
|
||||
func _stat_card(dark: bool, icon: Texture2D, accent: Color, value: String, caption: String) -> PanelContainer:
|
||||
var inner := HBoxContainer.new()
|
||||
inner.add_theme_constant_override("separation", 12)
|
||||
|
||||
var icon_panel := PanelContainer.new()
|
||||
icon_panel.custom_minimum_size = Vector2(44, 44)
|
||||
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 22, true))
|
||||
var icon_rect := TextureRect.new()
|
||||
icon_rect.texture = icon
|
||||
icon_rect.custom_minimum_size = Vector2(20, 20)
|
||||
icon_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon_rect.modulate = Brand.DARK_TEXT
|
||||
icon_panel.add_child(icon_rect)
|
||||
inner.add_child(icon_panel)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.add_theme_constant_override("separation", 2)
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
copy.add_child(_label(value, 20, Brand.text_color(dark), true))
|
||||
var caption_label := _label(caption, 12, Brand.muted_color(dark))
|
||||
caption_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.add_child(caption_label)
|
||||
inner.add_child(copy)
|
||||
|
||||
return _card(dark, inner)
|
||||
|
||||
|
||||
func _build_disclaimer(dark: bool, score: Dictionary) -> Label:
|
||||
var raw_accuracy := float(score.get("rawAccuracy", 0.0)) * 100.0
|
||||
var adjusted_accuracy := float(score.get("difficultyAdjustedAccuracy", 0.0)) * 100.0
|
||||
var label := _label(
|
||||
"%.1f%% raw accuracy · %.1f%% difficulty-adjusted accuracy\nThis transparent, unvalidated educational heuristic is not an official USMLE or NBME score and excludes CCS performance." % [raw_accuracy, adjusted_accuracy],
|
||||
12, Brand.muted_color(dark))
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
return label
|
||||
|
||||
|
||||
func _build_category_card(dark: bool, score: Dictionary) -> PanelContainer:
|
||||
var column := VBoxContainer.new()
|
||||
column.add_theme_constant_override("separation", 14)
|
||||
|
||||
var heading := HBoxContainer.new()
|
||||
heading.add_theme_constant_override("separation", 8)
|
||||
var heading_icon := TextureRect.new()
|
||||
heading_icon.texture = CATEGORY_ICON
|
||||
heading_icon.custom_minimum_size = Vector2(20, 20)
|
||||
heading_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
heading_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
heading_icon.modulate = Brand.ROYAL_BLUE
|
||||
heading.add_child(heading_icon)
|
||||
heading.add_child(_label("Performance by content area", 18, Brand.text_color(dark), true))
|
||||
column.add_child(heading)
|
||||
|
||||
var grid := GridContainer.new()
|
||||
grid.columns = 2
|
||||
grid.add_theme_constant_override("h_separation", 32)
|
||||
grid.add_theme_constant_override("v_separation", 14)
|
||||
var categories: Dictionary = score.get("categories", {})
|
||||
var index := 0
|
||||
for category_name in categories:
|
||||
var value: Dictionary = categories[category_name]
|
||||
grid.add_child(_category_row(dark, str(category_name), value, index))
|
||||
index += 1
|
||||
if categories.is_empty():
|
||||
column.add_child(_label("No content-area breakdown is available for this simulation.", 13, Brand.muted_color(dark)))
|
||||
else:
|
||||
main.show_screen("selection", "step3")
|
||||
column.add_child(grid)
|
||||
|
||||
return _card(dark, column)
|
||||
|
||||
|
||||
func _category_row(dark: bool, category_name: String, value: Dictionary, index: int) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var accent: Color = CATEGORY_PALETTE[index % CATEGORY_PALETTE.size()]
|
||||
|
||||
var dot := PanelContainer.new()
|
||||
dot.custom_minimum_size = Vector2(10, 10)
|
||||
dot.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
dot.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 5, true))
|
||||
row.add_child(dot)
|
||||
|
||||
var correct := int(value.get("correct", 0))
|
||||
var total := int(value.get("total", 0))
|
||||
var percent := int(round(100.0 * float(correct) / float(total))) if total > 0 else 0
|
||||
|
||||
var title := _label(category_name, 13, Brand.text_color(dark))
|
||||
title.custom_minimum_size.x = 200
|
||||
title.clip_text = true
|
||||
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
row.add_child(title)
|
||||
|
||||
var bar := ProgressBar.new()
|
||||
bar.show_percentage = false
|
||||
bar.min_value = 0.0
|
||||
bar.max_value = 100.0
|
||||
bar.value = percent
|
||||
bar.custom_minimum_size = Vector2(90, 7)
|
||||
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
_style_progress(bar, accent, dark)
|
||||
row.add_child(bar)
|
||||
|
||||
var detail := _label("%d%% %d/%d" % [percent, correct, total], 12, accent if dark else accent.darkened(0.15))
|
||||
detail.custom_minimum_size.x = 76
|
||||
detail.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
row.add_child(detail)
|
||||
return row
|
||||
|
||||
|
||||
func _build_actions() -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
|
||||
var review_button := Button.new()
|
||||
review_button.text = "Review Questions"
|
||||
review_button.icon = REVIEW_ICON
|
||||
review_button.custom_minimum_size = Vector2(220, 52)
|
||||
review_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
_style_gradient_button(review_button)
|
||||
review_button.pressed.connect(func(): main.show_screen("simulation_review", {"simulation": current_simulation, "return_screen": return_screen}))
|
||||
row.add_child(review_button)
|
||||
return row
|
||||
|
||||
|
||||
func _card(dark: bool, child: Control, accent: Color = Brand.ROYAL_BLUE, active: bool = false) -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var box := Brand.content_card_box(dark, active, accent)
|
||||
box.content_margin_left = 20
|
||||
box.content_margin_right = 20
|
||||
box.content_margin_top = 18
|
||||
box.content_margin_bottom = 18
|
||||
panel.add_theme_stylebox_override("panel", box)
|
||||
panel.add_child(child)
|
||||
return panel
|
||||
|
||||
|
||||
func _label(text: String, font_size: int, color: Color, _bold: bool = false) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", font_size)
|
||||
label.add_theme_color_override("font_color", color)
|
||||
return label
|
||||
|
||||
|
||||
func _style_progress(bar: ProgressBar, accent: Color, dark: bool) -> void:
|
||||
var background := StyleBoxFlat.new()
|
||||
background.bg_color = Color("26324a") if dark else Color("e2e8f0")
|
||||
background.set_corner_radius_all(4)
|
||||
var fill := StyleBoxFlat.new()
|
||||
fill.bg_color = accent
|
||||
fill.set_corner_radius_all(4)
|
||||
bar.add_theme_stylebox_override("background", background)
|
||||
bar.add_theme_stylebox_override("fill", fill)
|
||||
|
||||
|
||||
func _style_gradient_button(button: Button) -> void:
|
||||
Brand.apply_font(button)
|
||||
button.add_theme_font_size_override("font_size", 15)
|
||||
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
|
||||
button.add_theme_stylebox_override("hover", Brand.gradient_button_box())
|
||||
button.add_theme_stylebox_override("pressed", Brand.gradient_button_box())
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
|
||||
|
||||
func _clear(container: Control) -> void:
|
||||
for child in container.get_children():
|
||||
child.queue_free()
|
||||
container.remove_child(child)
|
||||
child.free()
|
||||
|
||||
@@ -13,7 +13,7 @@ func _initialize() -> void:
|
||||
_test_eliminated_choice_uses_rich_text_strikeout()
|
||||
_test_eliminated_choice_that_is_also_locked_wrong_still_strikes_through()
|
||||
_test_explanation_highlighting_has_no_status_heading()
|
||||
_test_resolved_followup_has_no_header_or_verdict_prefix()
|
||||
_test_resolved_followup_stays_expanded_and_toggles()
|
||||
_test_review_content_normalization()
|
||||
_test_followup_attempt_order_is_stable()
|
||||
_test_review_followup_is_optional_and_single_attempt()
|
||||
@@ -22,6 +22,7 @@ func _initialize() -> void:
|
||||
_test_feedback_context_excludes_study_content()
|
||||
_test_feedback_form_validation()
|
||||
_test_revisitable_navigation_prefers_unanswered_questions()
|
||||
_test_timer_text_avoids_redundant_frame_updates()
|
||||
_test_submit_styles_keep_stable_geometry()
|
||||
_test_core_timer_counts_up_and_remains_visible()
|
||||
await _test_player_runtime_construction()
|
||||
@@ -177,7 +178,7 @@ func _test_explanation_highlighting_has_no_status_heading() -> void:
|
||||
player.free()
|
||||
|
||||
|
||||
func _test_resolved_followup_has_no_header_or_verdict_prefix() -> void:
|
||||
func _test_resolved_followup_stays_expanded_and_toggles() -> void:
|
||||
var player = load("res://scripts/ui/question_player.gd").new()
|
||||
var panel := PanelContainer.new()
|
||||
var content := VBoxContainer.new()
|
||||
@@ -191,17 +192,38 @@ func _test_resolved_followup_has_no_header_or_verdict_prefix() -> void:
|
||||
player.presented_choices = [{"id": "B", "text": "Wrong answer"}]
|
||||
player.followup_states["B"] = {
|
||||
"choice_id": "B", "choice_text": "Wrong answer", "prompt": "Why?",
|
||||
"selected_option_text": "Finding", "was_correct": false,
|
||||
"teaching_point": "Incorrect. Review the finding.", "resolved": true
|
||||
"choices": [{"id": "A", "text": "Selected finding"}, {"id": "B", "text": "Correct finding"}],
|
||||
"correct_choice_id": "B", "selected_option_id": "A",
|
||||
"selected_option_text": "A. Selected finding", "was_correct": false,
|
||||
"teaching_point": "Incorrect. Review the finding.", "resolved": true, "collapsed": false,
|
||||
"has_authored_choices": true
|
||||
}
|
||||
player._render_followup_state("B")
|
||||
_assert(content.find_child("FollowupToggle", true, false) == null, "a resolved follow-up should not render a collapse toggle")
|
||||
var explanation_text := ""
|
||||
for child in content.get_children():
|
||||
if child is Label:
|
||||
explanation_text += child.text
|
||||
_assert(not explanation_text.contains("Why this is"), "a resolved follow-up should not repeat a 'Why this is...' heading")
|
||||
_assert(not explanation_text.begins_with("Incorrect."), "a resolved follow-up explanation should not repeat the verdict prefix")
|
||||
var toggle := content.find_child("FollowupToggle", true, false) as Button
|
||||
_assert(toggle != null, "a resolved follow-up should render a minimize toggle")
|
||||
_assert(toggle != null and toggle.text == "Minimize", "a newly resolved follow-up should remain expanded")
|
||||
_assert(content.find_child("FollowupPrompt", true, false) != null, "the resolved follow-up should retain its prompt")
|
||||
_assert(content.find_child("FollowupOption_A", true, false) != null, "the resolved follow-up should retain its selected option")
|
||||
_assert(content.find_child("FollowupOption_B", true, false) != null, "the resolved follow-up should retain its correct option")
|
||||
var verdict := content.find_child("FollowupVerdict", true, false) as Label
|
||||
_assert(verdict != null and verdict.text == "Incorrect", "the resolved follow-up should show whether the answer was correct")
|
||||
if toggle != null:
|
||||
toggle.pressed.emit()
|
||||
_assert(bool(player.followup_states["B"].get("collapsed", false)), "the minimize toggle should collapse the follow-up")
|
||||
_assert(content.find_child("FollowupPrompt", true, false) == null, "a collapsed follow-up should hide its prompt")
|
||||
toggle = content.find_child("FollowupToggle", true, false) as Button
|
||||
_assert(toggle != null and toggle.text == "Expand", "a collapsed follow-up should render an expand toggle")
|
||||
if toggle != null:
|
||||
toggle.pressed.emit()
|
||||
_assert(not bool(player.followup_states["B"].get("collapsed", true)), "the expand toggle should restore the follow-up")
|
||||
_assert(content.find_child("FollowupPrompt", true, false) != null, "expanding should restore the follow-up prompt")
|
||||
var correct_state: Dictionary = player.followup_states["B"]
|
||||
correct_state["selected_option_id"] = "B"
|
||||
correct_state["was_correct"] = true
|
||||
player.followup_states["B"] = correct_state
|
||||
player._render_followup_state("B")
|
||||
verdict = content.find_child("FollowupVerdict", true, false) as Label
|
||||
_assert(verdict != null and verdict.text == "Correct", "a correct follow-up response should show a correct verdict")
|
||||
player.choice_buttons[0].free()
|
||||
player.choice_percent_labels[0].free()
|
||||
player.choice_top_labels[0].free()
|
||||
@@ -341,14 +363,37 @@ func _test_revisitable_navigation_prefers_unanswered_questions() -> void:
|
||||
session.answers[1] = {"selected_choice_id": "B"}
|
||||
session.current_index = 2
|
||||
_assert(player._next_revisitable_index() == 0, "skip should wrap rather than reload the same unanswered question")
|
||||
session.answers = [{"selected_choice_id": "A"}, null, {"selected_choice_id": "C"}]
|
||||
session.current_index = 2
|
||||
_assert(player._next_pending_simulation_index() == 1, "simulation should cycle an earlier skipped question back")
|
||||
session.answers[1] = {"selected_choice_id": "B"}
|
||||
_assert(player._next_pending_simulation_index() == -1, "simulation should submit only after every question has an answer")
|
||||
session.answers[2] = {"selected_choice_id": ""}
|
||||
session.current_index = 2
|
||||
_assert(player._next_pending_simulation_index() == 2, "the only skipped simulation question should remain pending")
|
||||
player.free()
|
||||
session.active_questions = prior_questions
|
||||
session.answers = prior_answers
|
||||
session.current_index = prior_index
|
||||
|
||||
|
||||
func _test_timer_text_avoids_redundant_frame_updates() -> void:
|
||||
var player = load("res://scripts/ui/question_player.gd").new()
|
||||
player.timer_label = Label.new()
|
||||
player._set_timer_display(65)
|
||||
_assert(player.timer_label.text == "01:05", "timer should render a changed second")
|
||||
player.timer_label.text = "unchanged"
|
||||
player._set_timer_display(65)
|
||||
_assert(player.timer_label.text == "unchanged", "timer should not rewrite its label every frame")
|
||||
player._set_timer_display(66)
|
||||
_assert(player.timer_label.text == "01:06", "timer should render the next changed second")
|
||||
player.timer_label.free()
|
||||
player.free()
|
||||
|
||||
|
||||
func _test_player_runtime_construction() -> void:
|
||||
var host := Control.new()
|
||||
host.size = Vector2(1280, 720)
|
||||
root.add_child(host)
|
||||
var player = load("res://scripts/ui/question_player.gd").new()
|
||||
player.main = host
|
||||
@@ -380,7 +425,29 @@ func _test_player_runtime_construction() -> void:
|
||||
_assert(player.theme_button.icon in [Brand.SUN_ICON, Brand.MOON_ICON], "question theme toggle should use the shared sun or moon icon")
|
||||
_assert(player.choice_badges.size() == 5, "question player should construct an answer badge for every visible choice slot")
|
||||
_assert(player.choice_followup_panels[2].get_parent() == player.choice_rows[2], "distractor remediation should remain nested inside its answer choice")
|
||||
var long_choice := "Transfer to a neurocritical-care center, use a titratable agent to avoid severe hypertension and blood-pressure variability without causing hypotension, start enteral nimodipine, and secure the aneurysm as early as feasible."
|
||||
player.choice_buttons[0].text = long_choice
|
||||
await process_frame
|
||||
await process_frame
|
||||
_assert(player.choice_buttons[0].autowrap_mode == TextServer.AUTOWRAP_WORD_SMART, "long answer choices should use word-smart wrapping")
|
||||
_assert(player.choice_buttons[0].size.y > 68.0, "a wrapped answer choice should grow taller than the standard answer row")
|
||||
_assert(player.choice_buttons[0].size.x <= player.question_panel.size.x, "long answer choices should not widen the question player")
|
||||
_assert(player.next_button.get_global_rect().end.x <= player.get_global_rect().end.x + 0.5, "long answer choices should never push Submit beyond the viewport")
|
||||
var prior_session_kind: String = root.get_node("BlockSessionService").session_kind
|
||||
_assert(root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "A", "isSubmitted": null}), "legacy answered simulation responses should restore as submitted")
|
||||
_assert(not root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "A", "isSubmitted": false}), "new simulation drafts should remain editable after an in-session autosave")
|
||||
_assert(not root.get_node("BlockSessionService")._simulation_response_is_submitted({"choiceId": "", "isSubmitted": null}), "legacy skipped simulation responses should remain revisitable")
|
||||
root.get_node("BlockSessionService").session_kind = "simulation"
|
||||
player.presented_choices = [{"id": "A", "text": "First"}, {"id": "B", "text": "Second"}]
|
||||
player.current_answer = {"selected_choice_id": "A", "is_submitted": true}
|
||||
player.selected_choice = "A"
|
||||
player._apply_saved_choice_state()
|
||||
_assert(player.choice_buttons[0].disabled and player.choice_buttons[1].disabled, "resumed submitted simulation answers should lock every choice")
|
||||
_assert(not player.next_button.disabled, "a resumed submitted answer should still allow navigation to the next question")
|
||||
player._select_choice(1)
|
||||
_assert(player.selected_choice == "A", "a resumed submitted simulation answer should not be changeable")
|
||||
player.current_answer = {}
|
||||
player.selected_choice = ""
|
||||
for session_kind in ["block", "core", "bookmark", "review", "simulation"]:
|
||||
root.get_node("BlockSessionService").session_kind = session_kind
|
||||
player._refresh_toolbar_state()
|
||||
|
||||