From ddc36a804805817b155c9af7d654c691b888c39f Mon Sep 17 00:00:00 2001 From: hz Date: Sat, 1 Aug 2026 01:28:38 -0500 Subject: [PATCH] Add server-authoritative RZ Coin rewards --- api/QuestionsApi.Tests/CreditEconomyTests.cs | 25 +- .../Controllers/CreditsController.cs | 13 + .../Controllers/ExamSimulationsController.cs | 23 +- .../Controllers/QuestionsController.cs | 25 +- .../Controllers/ReviewQueueController.cs | 22 +- api/QuestionsApi/Models/CreditModels.cs | 74 +++++- api/QuestionsApi/Models/ExamSimulation.cs | 2 + api/QuestionsApi/Models/QuestionResponse.cs | 1 + api/QuestionsApi/Models/ReviewQueueModels.cs | 2 + api/QuestionsApi/Program.cs | 2 + .../CreditLedgerReconciliationService.cs | 17 ++ api/QuestionsApi/Services/CreditStore.cs | 244 +++++++++++++++--- .../Services/MasteryAwardService.cs | 86 ++++++ api/QuestionsApi/Services/ServiceMetrics.cs | 25 ++ api/QuestionsApi/Services/UserProfileStore.cs | 2 +- docs/QUESTION_BANK_GAMIFICATION.md | 8 +- docs/QUESTION_COMPLETION_CREDITS.md | 25 +- scripts/autoload/credits_service.gd | 49 ++++ scripts/autoload/exam_simulation_service.gd | 1 + scripts/autoload/question_bank_service.gd | 4 +- scripts/autoload/review_queue_service.gd | 1 + scripts/ui/main.gd | 26 ++ scripts/ui/question_player.gd | 43 --- scripts/ui/rz_store_screen.gd | 54 +++- 24 files changed, 670 insertions(+), 104 deletions(-) create mode 100644 api/QuestionsApi/Services/CreditLedgerReconciliationService.cs create mode 100644 api/QuestionsApi/Services/MasteryAwardService.cs diff --git a/api/QuestionsApi.Tests/CreditEconomyTests.cs b/api/QuestionsApi.Tests/CreditEconomyTests.cs index d1a2959..0b49617 100644 --- a/api/QuestionsApi.Tests/CreditEconomyTests.cs +++ b/api/QuestionsApi.Tests/CreditEconomyTests.cs @@ -8,10 +8,25 @@ public sealed class CreditEconomyTests [Fact] public void EarningPolicy_UsesTheAgreedRollingLimit() { - Assert.Equal(20, CreditEconomy.RollingEarningLimit); + Assert.Equal(30, CreditEconomy.RollingEarningLimit); Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow); } + [Theory] + [InlineData(1, 1)] + [InlineData(3, 1)] + [InlineData(4, 2)] + [InlineData(5, 3)] + public void FirstCorrectAwardsScaleWithStoredDifficulty(int difficulty, int expected) => + Assert.Equal(expected, CreditEconomy.FirstCorrectAmount(difficulty)); + + [Fact] + public void DelayedRecoveryReplacesTheNormalReviewAward() + { + Assert.Equal(1, CreditEconomy.ReviewAmount(false)); + Assert.Equal(3, CreditEconomy.ReviewAmount(true)); + } + [Fact] public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers() { @@ -21,6 +36,14 @@ public sealed class CreditEconomyTests Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.AppliedOperations))); } + [Fact] + public void PublicLedgerEntry_DoesNotExposeUserOrOperationIdentifiers() + { + Assert.Null(typeof(CreditLedgerEntryResponse).GetProperty(nameof(CreditLedgerEntry.UserId))); + Assert.Null(typeof(CreditLedgerEntryResponse).GetProperty(nameof(CreditLedgerEntry.OperationId))); + Assert.Null(typeof(CreditLedgerEntryResponse).GetProperty(nameof(CreditLedgerEntry.SourceId))); + } + [Fact] public void CatalogHasStableCoinOnlyUtilityPrices() { diff --git a/api/QuestionsApi/Controllers/CreditsController.cs b/api/QuestionsApi/Controllers/CreditsController.cs index f1bc69a..7d6dac6 100644 --- a/api/QuestionsApi/Controllers/CreditsController.cs +++ b/api/QuestionsApi/Controllers/CreditsController.cs @@ -14,6 +14,19 @@ public sealed class CreditsController(CreditStore credits, ExamSimulationStore s [HttpGet] public async Task Get() => Ok(await credits.GetStatusAsync(UserId())); + [HttpGet("ledger")] + public async Task Ledger([FromQuery] int limit = 20, [FromQuery] string? before = null) + { + DateTime? cursor = null; + if (!string.IsNullOrWhiteSpace(before)) + { + if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) + return BadRequest(new { code = "invalid_cursor", message = "The ledger cursor is invalid." }); + cursor = parsed; + } + return Ok(await credits.GetLedgerAsync(UserId(), limit, cursor)); + } + [HttpPost("purchases")] public async Task Purchase([FromBody] CreditPurchaseRequest request) { diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index e7286c4..ba8eeb7 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -9,7 +9,10 @@ namespace QuestionsApi.Controllers; [ApiController] [Route("api/exam-simulations")] [Authorize(Roles = "USER,SUPER")] -public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase +public class ExamSimulationsController( + ExamSimulationStore simulations, + CreditStore credits, + MasteryAwardService masteryAwards) : ControllerBase { [HttpGet("readiness")] public async Task Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); @@ -132,7 +135,23 @@ public class ExamSimulationsController(ExamSimulationStore simulations, CreditSt { var simulation = await simulations.GetOwnedAsync(id, UserId()); if (simulation is null) return NotFound(); - try { return Ok(await simulations.ToResponseAsync(await simulations.SubmitBlockAsync(simulation))); } + try + { + var userId = UserId(); + await masteryAwards.PrepareAsync(userId); + simulation = await simulations.SubmitBlockAsync(simulation); + var response = await simulations.ToResponseAsync(simulation); + if (simulation.State == ExamSimulationStates.Completed && !simulation.IsSample) + { + var result = await credits.AwardAsync(userId, new CreditAwardRequest( + $"simulation:{simulation.Id}", 10, CreditReasonCodes.ExamSimulationComplete, + "Completed a Step 3 exam simulation", "exam_simulation", simulation.Id ?? "", false)); + if (result.Award is not null) response.CreditAwards.Add(result.Award); + response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId)); + response.Credits = await credits.GetStatusAsync(userId); + } + return Ok(response); + } catch (InvalidOperationException exception) { return Conflict(exception.Message); } } diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 8c33190..354e3cf 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -20,6 +20,7 @@ public class QuestionsController : ControllerBase private readonly QuestionBookmarkStore _bookmarks; private readonly ReviewQueueStore _reviewQueue; private readonly CreditStore _credits; + private readonly MasteryAwardService _masteryAwards; public QuestionsController( QuestionStore questions, @@ -30,7 +31,8 @@ public class QuestionsController : ControllerBase ServiceMetrics metrics, QuestionBookmarkStore bookmarks, ReviewQueueStore reviewQueue, - CreditStore credits) + CreditStore credits, + MasteryAwardService masteryAwards) { _questions = questions; _attempts = attempts; @@ -41,6 +43,7 @@ public class QuestionsController : ControllerBase _bookmarks = bookmarks; _reviewQueue = reviewQueue; _credits = credits; + _masteryAwards = masteryAwards; } [HttpGet("systems")] @@ -152,6 +155,8 @@ public class QuestionsController : ControllerBase if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + await _masteryAwards.PrepareAsync(userId); + var record = await _attempts.RecordFirstAttemptAsync( question.ExternalId, userId, @@ -178,8 +183,18 @@ public class QuestionsController : ControllerBase _metrics.AttemptDuplicate(); } await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); - if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit) - await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? ""); + var creditAwards = new List(); + if (record.Inserted && record.Attempt.IsCorrect) + { + var amount = CreditEconomy.FirstCorrectAmount(question.Difficulty); + var difficult = question.Difficulty >= 4; + var award = await _credits.AwardAsync(userId, new CreditAwardRequest( + $"answer:{record.Attempt.Id}", amount, + difficult ? CreditReasonCodes.DifficultCorrect : CreditReasonCodes.FirstCorrect, + difficult ? $"Difficulty {question.Difficulty} first correct answer" : "First correct answer", + "question_attempt", record.Attempt.Id ?? "")); + if (award.Award is not null) creditAwards.Add(award.Award); + } var ratingOwner = Guid.NewGuid().ToString("N"); var shouldRate = await _attempts.TryAcquireRatingLeaseAsync( @@ -205,6 +220,7 @@ public class QuestionsController : ControllerBase throw; } + creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId)); return Ok(new FirstQuestionAttemptResponse { QuestionExternalId = question.ExternalId, @@ -213,7 +229,8 @@ public class QuestionsController : ControllerBase await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), QuestionElo = rating.QuestionElo, Rating = rating, - Credits = await _credits.GetStatusAsync(userId) + Credits = await _credits.GetStatusAsync(userId), + CreditAwards = creditAwards }); } diff --git a/api/QuestionsApi/Controllers/ReviewQueueController.cs b/api/QuestionsApi/Controllers/ReviewQueueController.cs index 71904d0..32b3768 100644 --- a/api/QuestionsApi/Controllers/ReviewQueueController.cs +++ b/api/QuestionsApi/Controllers/ReviewQueueController.cs @@ -14,7 +14,9 @@ public sealed class ReviewQueueController( QuestionStore questions, AnswerStatisticsStore statistics, RatingStore ratings, - QuestionBookmarkStore bookmarks) : ControllerBase + QuestionBookmarkStore bookmarks, + CreditStore credits, + MasteryAwardService masteryAwards) : ControllerBase { [HttpPost("next")] public async Task Next([FromBody] ReviewQueueNextRequest req) @@ -71,6 +73,7 @@ public sealed class ReviewQueueController( return BadRequest("presentedChoiceIds must include valid selected and correct choices"); } + await masteryAwards.PrepareAsync(userId); var result = await queue.RecordReviewAsync( userId, req.ClientAttemptId, @@ -81,12 +84,27 @@ public sealed class ReviewQueueController( choiceId == question.CorrectChoiceId, req.DurationSeconds); if (result is null) return Conflict("review cycle is no longer active"); + var awards = new List(); + if (result.Attempt.IsCorrect) + { + var delayed = result.Item is not null + && result.Attempt.CreatedUtc - result.Item.CreatedUtc >= TimeSpan.FromHours(24); + var award = await credits.AwardAsync(userId, new CreditAwardRequest( + $"review:{result.Attempt.Id}", CreditEconomy.ReviewAmount(delayed), + delayed ? CreditReasonCodes.DelayedRecovery : CreditReasonCodes.ScheduledReview, + delayed ? "Delayed recovery of a missed question" : "Correct scheduled review", + "question_review", result.Attempt.Id)); + if (award.Award is not null) awards.Add(award.Award); + } + awards.AddRange(await masteryAwards.AwardPromotionsAsync(userId)); return Ok(new ReviewQueueAttemptResponse { QuestionExternalId = question.ExternalId, IsCorrect = result.Attempt.IsCorrect, QueueState = result.Item?.State ?? ReviewQueueStates.Pending, - PendingCount = checked((int)await queue.CountPendingAsync(userId)) + PendingCount = checked((int)await queue.CountPendingAsync(userId)), + Credits = await credits.GetStatusAsync(userId), + CreditAwards = awards }); } diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs index 6a663ae..3e3431a 100644 --- a/api/QuestionsApi/Models/CreditModels.cs +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -5,7 +5,7 @@ namespace QuestionsApi.Models; public static class CreditEconomy { - public const int RollingEarningLimit = 20; + public const int RollingEarningLimit = 30; public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24); public const string ExamTokenSku = "exam_token_1"; public const string QBankResetTokenSku = "qbank_reset_1"; @@ -34,6 +34,9 @@ public static class CreditEconomy public static RzStoreProduct? FindProduct(string sku) => Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal)); + + public static int FirstCorrectAmount(int difficulty) => difficulty >= 5 ? 3 : difficulty >= 4 ? 2 : 1; + public static int ReviewAmount(bool delayedRecovery) => delayedRecovery ? 3 : 1; } public static class SlotMachineEconomy @@ -92,6 +95,65 @@ public sealed class RzStoreProduct public sealed class CreditEvent { public string OperationId { get; set; } = ""; + public int CoinAmount { get; set; } = 1; + public DateTime CreatedUtc { get; set; } +} + +public static class CreditReasonCodes +{ + public const string FirstCorrect = "qbank_first_correct"; + public const string DifficultCorrect = "qbank_difficult_correct"; + public const string ScheduledReview = "scheduled_review_correct"; + public const string DelayedRecovery = "delayed_recovery"; + public const string MasteryPracticing = "mastery_practicing"; + public const string MasteryStrong = "mastery_strong"; + public const string MasteryMastered = "mastery_mastered"; + public const string ExamSimulationComplete = "exam_simulation_complete"; + public const string Purchase = "purchase"; +} + +public sealed class CreditLedgerEntry +{ + [BsonId] + public string Id { get; set; } = ""; + public string UserId { get; set; } = ""; + public string OperationId { get; set; } = ""; + public int RequestedAmount { get; set; } + public int Amount { get; set; } + public string ReasonCode { get; set; } = ""; + public string Description { get; set; } = ""; + public string SourceType { get; set; } = ""; + public string SourceId { get; set; } = ""; + public bool SubjectToRollingCap { get; set; } + public string State { get; set; } = "pending"; + public DateTime CreatedUtc { get; set; } + public DateTime? AppliedUtc { get; set; } +} + +public sealed class CreditAward +{ + public int Amount { get; set; } + public string ReasonCode { get; set; } = ""; + public string Description { get; set; } = ""; +} + +public sealed class CreditAwardResult +{ + public CreditAward? Award { get; set; } + public CreditStatusResponse Status { get; set; } = new(); +} + +public sealed class CreditLedgerPage +{ + public List Items { get; set; } = []; + public DateTime? NextBefore { get; set; } +} + +public sealed class CreditLedgerEntryResponse +{ + public int Amount { get; set; } + public string ReasonCode { get; set; } = ""; + public string Description { get; set; } = ""; public DateTime CreatedUtc { get; set; } } @@ -122,6 +184,7 @@ public sealed class UserCreditAccount public int QBankResetTokens { get; set; } public int BankCycle { get; set; } public bool InitialExamGrantApplied { get; set; } + public bool MasteryAwardBaselineApplied { get; set; } public List RecentEarnings { get; set; } = []; public List Purchases { get; set; } = []; public List Spins { get; set; } = []; @@ -142,6 +205,15 @@ public sealed class CreditStatusResponse public IReadOnlyList Products { get; set; } = CreditEconomy.Products; } +public sealed record CreditAwardRequest( + string OperationId, + int Amount, + string ReasonCode, + string Description, + string SourceType, + string SourceId, + bool SubjectToRollingCap = true); + public sealed class CreditPurchaseRequest { public string Sku { get; set; } = ""; diff --git a/api/QuestionsApi/Models/ExamSimulation.cs b/api/QuestionsApi/Models/ExamSimulation.cs index 4dcbff3..a80271a 100644 --- a/api/QuestionsApi/Models/ExamSimulation.cs +++ b/api/QuestionsApi/Models/ExamSimulation.cs @@ -117,6 +117,8 @@ public class ExamSimulationStateResponse public Dictionary BlueprintTargets { get; set; } = []; public Dictionary BlueprintActual { get; set; } = []; public List ReviewQuestions { get; set; } = []; + public CreditStatusResponse? Credits { get; set; } + public List CreditAwards { get; set; } = []; } public class ExamSimulationReadinessResponse diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 96e93c6..56f0345 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -146,6 +146,7 @@ public class FirstQuestionAttemptResponse public int QuestionElo { get; set; } = 1000; public RatingChangeResponse? Rating { get; set; } public CreditStatusResponse? Credits { get; set; } + public List CreditAwards { get; set; } = []; } public class SkipQuestionResponse diff --git a/api/QuestionsApi/Models/ReviewQueueModels.cs b/api/QuestionsApi/Models/ReviewQueueModels.cs index 60c4541..ec4948c 100644 --- a/api/QuestionsApi/Models/ReviewQueueModels.cs +++ b/api/QuestionsApi/Models/ReviewQueueModels.cs @@ -81,6 +81,8 @@ public sealed class ReviewQueueAttemptResponse public bool IsCorrect { get; set; } public string QueueState { get; set; } = ReviewQueueStates.Pending; public int PendingCount { get; set; } + public CreditStatusResponse? Credits { get; set; } + public List CreditAwards { get; set; } = []; } public sealed class ReviewQueueRecordResult diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 6f2c9cf..d581faf 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -90,6 +90,8 @@ builder.Services.Configure(options => builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/Services/CreditLedgerReconciliationService.cs b/api/QuestionsApi/Services/CreditLedgerReconciliationService.cs new file mode 100644 index 0000000..ca6574f --- /dev/null +++ b/api/QuestionsApi/Services/CreditLedgerReconciliationService.cs @@ -0,0 +1,17 @@ +namespace QuestionsApi.Services; + +public sealed class CreditLedgerReconciliationService( + CreditStore credits, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try { await credits.ReconcilePendingAsync(stoppingToken); } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } + catch (Exception exception) { logger.LogError(exception, "Credit ledger reconciliation failed."); } + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } +} diff --git a/api/QuestionsApi/Services/CreditStore.cs b/api/QuestionsApi/Services/CreditStore.cs index 2a71fe1..71dcb64 100644 --- a/api/QuestionsApi/Services/CreditStore.cs +++ b/api/QuestionsApi/Services/CreditStore.cs @@ -7,43 +7,167 @@ namespace QuestionsApi.Services; public sealed class CreditStore { private readonly IMongoCollection _accounts; + private readonly IMongoCollection _ledger; private readonly TimeProvider _clock; + private readonly ServiceMetrics _metrics; - public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock) + public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock, ServiceMetrics metrics) { _accounts = mongo.Database.GetCollection("UserCreditAccounts"); + _ledger = mongo.Database.GetCollection("CreditLedgerEntries"); _clock = clock; + _metrics = metrics; _accounts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Unique = true, Name = "uq_credit_user" })); + _ledger.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId) + .Descending(value => value.CreatedUtc).Descending(value => value.Id), + new CreateIndexOptions { Name = "credit_ledger_user_time" })); } - public async Task AwardForFirstAnswerAsync(string userId, string attemptId) + public async Task AwardAsync(string userId, CreditAwardRequest request) { - if (string.IsNullOrWhiteSpace(attemptId)) return false; + if (string.IsNullOrWhiteSpace(request.OperationId) || request.Amount <= 0) + return new CreditAwardResult { Status = await GetStatusAsync(userId) }; var now = _clock.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); - - var filters = Builders.Filter; - var belowCap = new BsonDocumentFilterDefinition( - new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray + var entryId = LedgerId(userId, request.OperationId); + var entry = await _ledger.Find(value => value.Id == entryId).FirstOrDefaultAsync(); + if (entry is not null && (entry.ReasonCode != request.ReasonCode || entry.SourceId != request.SourceId)) + throw new InvalidOperationException("The credit operation ID was already used for another event."); + if (entry is null) + { + var alreadyApplied = await _accounts.Find(value => value.UserId == userId + && value.AppliedOperations.Contains(request.OperationId)).AnyAsync(); + if (alreadyApplied) return new CreditAwardResult { Status = await GetStatusAsync(userId) }; + entry = new CreditLedgerEntry { - 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.Update - .Inc(value => value.Credits, 1) - .Push(value => value.RecentEarnings, new CreditEvent - { - OperationId = attemptId, + Id = entryId, UserId = userId, OperationId = request.OperationId, + RequestedAmount = request.Amount, Amount = request.SubjectToRollingCap ? 0 : request.Amount, + ReasonCode = request.ReasonCode, + Description = request.Description, SourceType = request.SourceType, + SourceId = request.SourceId, SubjectToRollingCap = request.SubjectToRollingCap, CreatedUtc = now - }) - .Set(value => value.UpdatedUtc, now); - return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; + }; + try { await _ledger.InsertOneAsync(entry); } + catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) + { entry = await _ledger.Find(value => value.Id == entryId).FirstAsync(); } + } + if (entry.State == "applied") + { + _metrics.CreditDuplicate(request.ReasonCode); + return new CreditAwardResult { Status = await GetStatusAsync(userId) }; + } + + var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + if (account.AppliedOperations.Contains(request.OperationId)) + { + var appliedAmount = AppliedAmount(account, entry); + entry.Amount = appliedAmount; + await MarkLedgerAppliedAsync(entry, appliedAmount, now); + return new CreditAwardResult { Status = await GetStatusAsync(userId) }; + } + var amount = request.Amount; + var appliedNow = false; + while (true) + { + var earned = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)); + amount = request.SubjectToRollingCap + ? Math.Min(request.Amount, Math.Max(0, CreditEconomy.RollingEarningLimit - earned)) + : request.Amount; + var filter = Builders.Filter.Eq(value => value.UserId, userId) + & Builders.Filter.AnyNin(value => value.AppliedOperations, [request.OperationId]); + if (request.SubjectToRollingCap && amount > 0) + filter &= RollingEarnedAtMost(CreditEconomy.RollingEarningLimit - amount); + var update = Builders.Update + .AddToSet(value => value.AppliedOperations, request.OperationId) + .Set(value => value.UpdatedUtc, now); + if (amount > 0) + { + update = update.Inc(value => value.Credits, amount); + if (request.SubjectToRollingCap) + update = update.Push(value => value.RecentEarnings, + new CreditEvent { OperationId = request.OperationId, CoinAmount = amount, CreatedUtc = now }); + } + if ((await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1) + { + appliedNow = true; + break; + } + account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + if (account.AppliedOperations.Contains(request.OperationId)) + { + amount = AppliedAmount(account, entry); + break; + } + } + entry.Amount = amount; + await MarkLedgerAppliedAsync(entry, amount, now); + if (appliedNow && amount > 0) _metrics.CreditAward(request.ReasonCode, amount); + if (appliedNow && request.SubjectToRollingCap && amount < request.Amount) + _metrics.CreditCapped(request.ReasonCode); + var status = await GetStatusAsync(userId); + return appliedNow ? Result(entry, status) : new CreditAwardResult { Status = status }; + } + + public async Task InitializeMasteryBaselineAsync(string userId, IEnumerable operationIds) + { + await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); + var update = Builders.Update + .Set(value => value.MasteryAwardBaselineApplied, true); + foreach (var operationId in operationIds.Distinct(StringComparer.Ordinal)) + update = update.AddToSet(value => value.AppliedOperations, operationId); + await _accounts.UpdateOneAsync( + value => value.UserId == userId && !value.MasteryAwardBaselineApplied, update); + } + + public async Task IsMasteryBaselineAppliedAsync(string userId) + { + await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); + return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).MasteryAwardBaselineApplied; + } + + public async Task> GetAppliedOperationsAsync(string userId) + { + await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); + var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + return account.AppliedOperations.ToHashSet(StringComparer.Ordinal); + } + + public async Task GetLedgerAsync(string userId, int limit, DateTime? before) + { + limit = Math.Clamp(limit, 1, 50); + var filter = Builders.Filter.Eq(value => value.UserId, userId) + & Builders.Filter.Eq(value => value.State, "applied") + & Builders.Filter.Ne(value => value.Amount, 0); + if (before.HasValue) filter &= Builders.Filter.Lt(value => value.CreatedUtc, before.Value); + var entries = await _ledger.Find(filter).SortByDescending(value => value.CreatedUtc) + .ThenByDescending(value => value.Id).Limit(limit + 1).ToListAsync(); + var hasMore = entries.Count > limit; + if (hasMore) entries.RemoveAt(entries.Count - 1); + return new CreditLedgerPage + { + Items = entries.Select(value => new CreditLedgerEntryResponse + { + Amount = value.Amount, ReasonCode = value.ReasonCode, + Description = value.Description, CreatedUtc = value.CreatedUtc + }).ToList(), + NextBefore = hasMore && entries.Count > 0 ? entries[^1].CreatedUtc : null + }; + } + + public async Task ReconcilePendingAsync(CancellationToken cancellationToken = default) + { + var pending = await _ledger.Find(value => value.State == "pending") + .SortBy(value => value.CreatedUtc).Limit(100).ToListAsync(cancellationToken); + foreach (var entry in pending) + { + cancellationToken.ThrowIfCancellationRequested(); + await AwardAsync(entry.UserId, new CreditAwardRequest( + entry.OperationId, entry.RequestedAmount, entry.ReasonCode, entry.Description, + entry.SourceType, entry.SourceId, entry.SubjectToRollingCap)); + } } public async Task GetStatusAsync(string userId) @@ -68,9 +192,12 @@ public sealed class CreditStore 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); + { + if (prior.Sku != product.Sku) + return Response(CreditOperationResult.OperationConflict, existing); + await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc); + return Response(CreditOperationResult.Succeeded, existing); + } var filter = Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Gte(value => value.Credits, product.CoinPrice) & @@ -95,14 +222,21 @@ public sealed class CreditStore { ReturnDocument = ReturnDocument.After }); - if (account is not null) return Response(CreditOperationResult.Succeeded, account); + if (account is not null) + { + await RecordPurchaseLedgerAsync(userId, product, operationId, now); + 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); + { + if (prior.Sku != product.Sku) + return Response(CreditOperationResult.OperationConflict, account); + await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc); + return Response(CreditOperationResult.Succeeded, account); + } return Response(CreditOperationResult.InsufficientCredits, account); } @@ -223,6 +357,7 @@ public sealed class CreditStore .SetOnInsert(value => value.QBankResetTokens, 0) .SetOnInsert(value => value.BankCycle, 0) .SetOnInsert(value => value.InitialExamGrantApplied, true) + .SetOnInsert(value => value.MasteryAwardBaselineApplied, false) .SetOnInsert(value => value.RecentEarnings, []) .SetOnInsert(value => value.Purchases, []) .SetOnInsert(value => value.Spins, []) @@ -273,9 +408,9 @@ public sealed class CreditStore private static CreditStatusResponse ToStatus(UserCreditAccount account) => new() { Credits = account.Credits, - EarnedInRollingWindow = account.RecentEarnings.Count, + EarnedInRollingWindow = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)), RemainingEarningCapacity = Math.Max( - 0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count), + 0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount))), ExamTokens = account.ExamTokens, QBankResetTokens = account.QBankResetTokens, BankCycle = account.BankCycle @@ -297,4 +432,51 @@ public sealed class CreditStore CoinsWon = spinEvent?.CoinsWon ?? 0, Status = ToStatus(account) }; + + private static string LedgerId(string userId, string operationId) => $"{userId}:{operationId}"; + + private static int AppliedAmount(UserCreditAccount account, CreditLedgerEntry entry) => + entry.SubjectToRollingCap + ? account.RecentEarnings.FirstOrDefault(value => value.OperationId == entry.OperationId)?.CoinAmount ?? 0 + : entry.RequestedAmount; + + private static FilterDefinition RollingEarnedAtMost(int maximum) => + new BsonDocumentFilterDefinition(new BsonDocument("$expr", + new BsonDocument("$lte", new BsonArray + { + new BsonDocument("$sum", new BsonDocument("$map", new BsonDocument + { + { "input", new BsonDocument("$ifNull", new BsonArray { "$RecentEarnings", new BsonArray() }) }, + { "as", "earning" }, + { "in", new BsonDocument("$ifNull", new BsonArray { "$$earning.CoinAmount", 1 }) } + })), + maximum + }))); + + private Task MarkLedgerAppliedAsync(CreditLedgerEntry entry, int amount, DateTime now) => + _ledger.UpdateOneAsync(value => value.Id == entry.Id, + Builders.Update.Set(value => value.Amount, amount) + .Set(value => value.State, "applied").Set(value => value.AppliedUtc, now)); + + private Task RecordPurchaseLedgerAsync(string userId, RzStoreProduct product, string operationId, DateTime createdUtc) => + _ledger.ReplaceOneAsync( + value => value.Id == LedgerId(userId, $"purchase:{operationId}"), + new CreditLedgerEntry + { + Id = LedgerId(userId, $"purchase:{operationId}"), UserId = userId, + OperationId = $"purchase:{operationId}", RequestedAmount = -product.CoinPrice, + Amount = -product.CoinPrice, ReasonCode = CreditReasonCodes.Purchase, + Description = $"Purchased {product.Name}", SourceType = "store_product", + SourceId = product.Sku, State = "applied", CreatedUtc = createdUtc, AppliedUtc = createdUtc + }, + new ReplaceOptions { IsUpsert = true }); + + private static CreditAwardResult Result(CreditLedgerEntry entry, CreditStatusResponse status) => new() + { + Award = entry.Amount <= 0 ? null : new CreditAward + { + Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description + }, + Status = status + }; } diff --git a/api/QuestionsApi/Services/MasteryAwardService.cs b/api/QuestionsApi/Services/MasteryAwardService.cs new file mode 100644 index 0000000..a23291e --- /dev/null +++ b/api/QuestionsApi/Services/MasteryAwardService.cs @@ -0,0 +1,86 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class MasteryAwardService +{ + private readonly IMongoCollection _attempts; + private readonly IMongoCollection _reviews; + private readonly IMongoCollection _queue; + private readonly QuestionStore _questions; + private readonly CreditStore _credits; + private readonly TimeProvider _clock; + + public MasteryAwardService(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock) + { + _attempts = mongo.Database.GetCollection("QuestionAttempts"); + _reviews = mongo.Database.GetCollection("QuestionReviewAttempts"); + _queue = mongo.Database.GetCollection("ReviewQueueItems"); + _questions = questions; + _credits = credits; + _clock = clock; + } + + public async Task PrepareAsync(string userId) + { + if (await _credits.IsMasteryBaselineAppliedAsync(userId)) return; + var states = await CurrentStatesAsync(userId); + await _credits.InitializeMasteryBaselineAsync(userId, states.SelectMany(BaselineOperations)); + } + + public async Task> AwardPromotionsAsync(string userId) + { + var awards = new List(); + var applied = await _credits.GetAppliedOperationsAsync(userId); + foreach (var system in await CurrentStatesAsync(userId)) + { + foreach (var state in EarnedStates(system.MasteryState)) + { + var spec = AwardSpec(state); + if (applied.Contains(Operation(system.System, state))) continue; + var result = await _credits.AwardAsync(userId, new CreditAwardRequest( + Operation(system.System, state), spec.Amount, spec.Reason, spec.Description, + "step3_system", system.System, false)); + if (result.Award is not null) awards.Add(result.Award); + applied.Add(Operation(system.System, state)); + } + } + return awards; + } + + private async Task> CurrentStatesAsync(string userId) + { + var attempts = await _attempts.Find(value => value.UserId == userId).ToListAsync(); + var reviews = await _reviews.Find(value => value.UserId == userId).ToListAsync(); + var queue = await _queue.Find(value => value.UserId == userId).ToListAsync(); + var ids = attempts.Select(value => value.QuestionExternalId) + .Concat(reviews.Select(value => value.QuestionExternalId)) + .Concat(queue.Select(value => value.QuestionExternalId)); + var questions = await _questions.GetByExternalIdsAsync(ids); + return MasteryCalculator.Calculate(attempts, reviews, queue, questions, _clock.GetUtcNow().UtcDateTime); + } + + private static IEnumerable BaselineOperations(DashboardSystemPerformance system) => + EarnedStates(system.MasteryState).Select(state => Operation(system.System, state)); + + private static IEnumerable EarnedStates(string state) + { + if (state is MasteryStates.Practicing or MasteryStates.Strong or MasteryStates.Mastered) + yield return MasteryStates.Practicing; + if (state is MasteryStates.Strong or MasteryStates.Mastered) + yield return MasteryStates.Strong; + if (state is MasteryStates.Mastered) + yield return MasteryStates.Mastered; + } + + private static string Operation(string system, string state) => + $"mastery:{system.ToLowerInvariant().Replace(' ', '_')}:{state}"; + + private static (int Amount, string Reason, string Description) AwardSpec(string state) => state switch + { + MasteryStates.Practicing => (3, CreditReasonCodes.MasteryPracticing, "System advanced to Practicing"), + MasteryStates.Strong => (5, CreditReasonCodes.MasteryStrong, "System advanced to Strong"), + _ => (10, CreditReasonCodes.MasteryMastered, "System advanced to Mastered") + }; +} diff --git a/api/QuestionsApi/Services/ServiceMetrics.cs b/api/QuestionsApi/Services/ServiceMetrics.cs index f1365d3..00985d1 100644 --- a/api/QuestionsApi/Services/ServiceMetrics.cs +++ b/api/QuestionsApi/Services/ServiceMetrics.cs @@ -17,6 +17,10 @@ public sealed class ServiceMetrics private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicAttempts = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicCorrect = new(); private long _dynamicTargetGapTotal; + private readonly System.Collections.Concurrent.ConcurrentDictionary _creditAwards = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _creditCoins = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _creditDuplicates = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _creditCapped = new(); public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated); public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates); @@ -43,6 +47,13 @@ public sealed class ServiceMetrics _dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1); if (correct) _dynamicCorrect.AddOrUpdate(tier, 1, (_, value) => value + 1); } + public void CreditAward(string reason, int amount) + { + _creditAwards.AddOrUpdate(reason, 1, (_, value) => value + 1); + _creditCoins.AddOrUpdate(reason, amount, (_, value) => value + amount); + } + public void CreditDuplicate(string reason) => _creditDuplicates.AddOrUpdate(reason, 1, (_, value) => value + 1); + public void CreditCapped(string reason) => _creditCapped.AddOrUpdate(reason, 1, (_, value) => value + 1); public string RenderPrometheus() { @@ -63,6 +74,17 @@ public sealed class ServiceMetrics AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier)); } AppendCounter(output, "step3_dynamic_target_gap_points_total", Interlocked.Read(ref _dynamicTargetGapTotal)); + output.AppendLine("# TYPE step3_credit_awards_total counter"); + output.AppendLine("# TYPE step3_credit_coins_awarded_total counter"); + output.AppendLine("# TYPE step3_credit_award_duplicates_total counter"); + output.AppendLine("# TYPE step3_credit_award_capped_total counter"); + foreach (var reason in _creditAwards.Keys.Concat(_creditDuplicates.Keys).Concat(_creditCapped.Keys).Distinct().Order()) + { + AppendReasonSample(output, "step3_credit_awards_total", reason, _creditAwards.GetValueOrDefault(reason)); + AppendReasonSample(output, "step3_credit_coins_awarded_total", reason, _creditCoins.GetValueOrDefault(reason)); + AppendReasonSample(output, "step3_credit_award_duplicates_total", reason, _creditDuplicates.GetValueOrDefault(reason)); + AppendReasonSample(output, "step3_credit_award_capped_total", reason, _creditCapped.GetValueOrDefault(reason)); + } output.AppendLine("# TYPE step3_http_request_duration_seconds summary"); output.Append("step3_http_request_duration_seconds_count ") .Append(Interlocked.Read(ref _httpRequests)) @@ -91,4 +113,7 @@ public sealed class ServiceMetrics { output.Append(name).Append("{tier=\"").Append(tier.Replace("\"", "")).Append("\"} ").Append(value).AppendLine(); } + + private static void AppendReasonSample(StringBuilder output, string name, string reason, long value) => + output.Append(name).Append("{reason=\"").Append(reason.Replace("\"", "")).Append("\"} ").Append(value).AppendLine(); } diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 50f9afe..030652c 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -20,7 +20,7 @@ public sealed class UserProfileStore "ExternalIdentities", "ExternalAuthTransactions", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", - "UserCreditAccounts" + "UserCreditAccounts", "CreditLedgerEntries" ]; public UserProfileStore(MongoDatabaseProvider mongo) diff --git a/docs/QUESTION_BANK_GAMIFICATION.md b/docs/QUESTION_BANK_GAMIFICATION.md index fe88b21..f96f0c5 100644 --- a/docs/QUESTION_BANK_GAMIFICATION.md +++ b/docs/QUESTION_BANK_GAMIFICATION.md @@ -155,10 +155,10 @@ social notifications. ### Phase 1: foundation -1. Define the mastery model and expose topic-level progress. -2. Add server-authoritative RZ Coin awards for approved learning events. -3. Add user-selected session goals and an end-of-session summary. -4. Add daily missions with one replacement per day. +- [x] Define the mastery model and expose topic-level progress. +- [x] Add server-authoritative RZ Coin awards for approved learning events. +- [ ] Add user-selected session goals and an end-of-session summary. +- [ ] Add daily missions with one replacement per day. ### Phase 2: progression diff --git a/docs/QUESTION_COMPLETION_CREDITS.md b/docs/QUESTION_COMPLETION_CREDITS.md index 93f2dc3..6f9a130 100644 --- a/docs/QUESTION_COMPLETION_CREDITS.md +++ b/docs/QUESTION_COMPLETION_CREDITS.md @@ -1,19 +1,22 @@ # 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. +RZ Coin is awarded by the API for verified learning outcomes. The client displays +award results but cannot choose award amounts or reason codes. -- 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. +- A first correct answer earns 1 coin at difficulty 1-3, 2 at difficulty 4, + and 3 at difficulty 5. +- A correct scheduled review earns 1 coin, or 3 when it recovers a miss after + at least 24 hours. +- First-time system promotions to Practicing, Strong, and Mastered earn 3, 5, + and 10 coins. Completing a real exam simulation earns 10 coins. +- Incorrect answers, skips, repeated submissions, and sample simulations earn nothing. +- Repeatable question and review rewards are capped at 30 coins in a rolling + 24-hour window; milestone and simulation awards are outside that cap. - 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. +The API is authoritative. Immutable attempt, review, simulation, and mastery +identifiers make grants idempotent. Awards and purchases appear in the user's +coin ledger. Credit accounts are included in account export and deletion. diff --git a/scripts/autoload/credits_service.gd b/scripts/autoload/credits_service.gd index f38b93e..a7681e2 100644 --- a/scripts/autoload/credits_service.gd +++ b/scripts/autoload/credits_service.gd @@ -5,6 +5,8 @@ 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 +signal award_received(amount: int, reason: String) +signal ledger_changed const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_URL := "https://us3q.ranaze.com/api/credits" @@ -21,9 +23,13 @@ var loading := false var request: HTTPRequest var action_request: HTTPRequest var spin_config_request: HTTPRequest +var ledger_request: HTTPRequest var request_token := "" var api_url := DEFAULT_URL var pending_store_action := "" +var ledger: Array = [] +var ledger_next_before := "" +var ledger_append := false func _ready() -> void: @@ -42,6 +48,10 @@ func _ready() -> void: spin_config_request.timeout = 20.0 spin_config_request.request_completed.connect(_on_spin_config_completed) add_child(spin_config_request) + ledger_request = HTTPRequest.new() + ledger_request.timeout = 20.0 + ledger_request.request_completed.connect(_on_ledger_completed) + add_child(ledger_request) AuthState.auth_changed.connect(_on_auth_changed) @@ -67,6 +77,43 @@ func apply_status(parsed: Dictionary) -> void: credits_changed.emit() +func apply_award_response(parsed: Dictionary) -> void: + var status: Variant = parsed.get("credits", null) + if typeof(status) == TYPE_DICTIONARY: + apply_status(status) + for award_variant in parsed.get("creditAwards", []): + if typeof(award_variant) != TYPE_DICTIONARY: + continue + var award: Dictionary = award_variant + var amount := int(award.get("amount", 0)) + if amount > 0: + award_received.emit(amount, str(award.get("description", "Learning reward"))) + + +func load_ledger(before: String = "") -> void: + if not AuthState.can_send_authenticated_request() or ledger_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + ledger_append = not before.is_empty() + var suffix := "/ledger?limit=20" + if not before.is_empty(): + suffix += "&before=%s" % before.uri_encode() + ledger_request.request(api_url + suffix, PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]), HTTPClient.METHOD_GET) + + +func _on_ledger_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + credits_failed.emit("Coin history could not be loaded.") + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + return + var items: Array = parsed.get("items", []) + ledger = ledger + items if ledger_append else items + var cursor: Variant = parsed.get("nextBefore", null) + ledger_next_before = "" if cursor == null else str(cursor) + ledger_changed.emit() + + func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: loading = false if response_code == 401: @@ -173,6 +220,8 @@ func _on_auth_changed() -> void: bank_cycle = 0 products = [] spin_rewards = [] + ledger = [] + ledger_next_before = "" loaded = false loading = false if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index cb328fb..5ec0e21 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -196,6 +196,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri last_failed_action = action request_failed.emit("The simulation API returned an invalid response.") return + CreditsService.apply_award_response(parsed) if action == "readiness": readiness = parsed readiness_changed.emit() diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index e93957b..a55900a 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -545,9 +545,7 @@ 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) + CreditsService.apply_award_response(parsed) choice_percentages_updated.emit(question_id, percentages) question_rating_updated.emit(question_id, question_elo, rating) diff --git a/scripts/autoload/review_queue_service.gd b/scripts/autoload/review_queue_service.gd index 115dd14..7bc79f6 100644 --- a/scripts/autoload/review_queue_service.gd +++ b/scripts/autoload/review_queue_service.gd @@ -190,6 +190,7 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack var synced_question_id := "" var synced_is_correct := false if typeof(parsed) == TYPE_DICTIONARY: + CreditsService.apply_award_response(parsed) pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count))) synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", ""))) synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false))) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 553934b..2a12b2d 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -38,12 +38,38 @@ func _ready() -> void: theme_service = get_node("/root/UIThemeService") Style.apply_app_theme(self) _build_ui() + CreditsService.award_received.connect(_show_coin_award) theme_service.theme_changed.connect(_on_theme_changed) resized.connect(_update_responsive_layout) _update_responsive_layout() show_screen(("legal_consent" if AuthState.requires_legal_acceptance else "dashboard") if AuthState.is_logged_in else "login") +func _show_coin_award(amount: int, reason: String) -> void: + var toast := PanelContainer.new() + toast.z_index = 200 + toast.mouse_filter = Control.MOUSE_FILTER_IGNORE + toast.set_anchors_preset(Control.PRESET_TOP_RIGHT) + toast.position = Vector2(-360, 76) + toast.custom_minimum_size = Vector2(340, 52) + toast.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.AMBER)) + var label := Label.new() + label.text = "+%d RZ Coins ยท %s" % [amount, reason] + 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(UIThemeService.is_dark_mode())) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + toast.add_child(label) + add_child(toast) + toast.modulate.a = 0.0 + var tween := create_tween() + tween.tween_property(toast, "modulate:a", 1.0, 0.18) + tween.tween_interval(2.8) + tween.tween_property(toast, "modulate:a", 0.0, 0.25) + tween.tween_callback(toast.queue_free) + + func _update_responsive_layout() -> void: compact_shell = size.x < 900.0 if sidebar != null: diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 9e62cf9..d1eebb9 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -16,7 +16,6 @@ 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 @@ -1650,15 +1649,6 @@ 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) @@ -2263,39 +2253,6 @@ 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) diff --git a/scripts/ui/rz_store_screen.gd b/scripts/ui/rz_store_screen.gd index f0231cf..bf522aa 100644 --- a/scripts/ui/rz_store_screen.gd +++ b/scripts/ui/rz_store_screen.gd @@ -39,6 +39,8 @@ var footer_icon: TextureRect var footer_heading: Label var footer_description: Label var pending_sku := "" +var ledger_list: VBoxContainer +var ledger_more_button: Button func _ready() -> void: @@ -76,20 +78,70 @@ func _ready() -> void: confirm_dialog.confirmed.connect(_confirm_purchase) add_child(confirm_dialog) + _build_ledger() _build_footer() CreditsService.credits_changed.connect(_refresh) CreditsService.credits_failed.connect(_show_error) CreditsService.store_operation_completed.connect(_show_message) + CreditsService.ledger_changed.connect(_render_ledger) UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_theme_controls(); _refresh()) _refresh() func on_show(_payload: Variant = null) -> void: CreditsService.refresh() + CreditsService.load_ledger() _refresh() +func _build_ledger() -> void: + var heading := Label.new() + heading.text = "Coin history" + heading.add_theme_font_override("font", Brand.font) + heading.add_theme_font_size_override("font_size", 18) + add_child(heading) + ledger_list = VBoxContainer.new() + ledger_list.add_theme_constant_override("separation", 6) + add_child(ledger_list) + ledger_more_button = Button.new() + ledger_more_button.text = "Load more" + ledger_more_button.custom_minimum_size = Vector2(130, 40) + ledger_more_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + ledger_more_button.pressed.connect(func(): CreditsService.load_ledger(CreditsService.ledger_next_before)) + add_child(ledger_more_button) + + +func _render_ledger() -> void: + if ledger_list == null: + return + for child in ledger_list.get_children(): + child.queue_free() + var dark := UIThemeService.is_dark_mode() + if CreditsService.ledger.is_empty(): + var empty := Label.new() + empty.text = "Learning rewards and purchases will appear here." + empty.add_theme_color_override("font_color", Brand.muted_color(dark)) + ledger_list.add_child(empty) + for item_variant in CreditsService.ledger: + if typeof(item_variant) != TYPE_DICTIONARY: + continue + var item: Dictionary = item_variant + var row := HBoxContainer.new() + var description := Label.new() + description.text = str(item.get("description", "RZ Coin activity")) + description.size_flags_horizontal = Control.SIZE_EXPAND_FILL + description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + row.add_child(description) + var amount := int(item.get("amount", 0)) + var amount_label := Label.new() + amount_label.text = "%+d" % amount + amount_label.add_theme_color_override("font_color", Brand.SUCCESS if amount > 0 else Brand.MAGENTA) + row.add_child(amount_label) + ledger_list.add_child(row) + ledger_more_button.visible = not CreditsService.ledger_next_before.is_empty() + + func _build_header() -> void: var title := Label.new() title.text = "RZ Coin Shop" @@ -258,7 +310,7 @@ func _build_footer() -> void: 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.text = "Earn coins for first-correct answers, difficult questions, delayed reviews, mastery milestones, and completed exam simulations." 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))