Add server-authoritative RZ Coin rewards

This commit is contained in:
hz
2026-08-01 01:28:38 -05:00
parent 8c439c6294
commit ddc36a8048
24 changed files with 670 additions and 104 deletions
+24 -1
View File
@@ -8,10 +8,25 @@ public sealed class CreditEconomyTests
[Fact] [Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit() public void EarningPolicy_UsesTheAgreedRollingLimit()
{ {
Assert.Equal(20, CreditEconomy.RollingEarningLimit); Assert.Equal(30, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow); 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] [Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers() public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{ {
@@ -21,6 +36,14 @@ public sealed class CreditEconomyTests
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.AppliedOperations))); 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] [Fact]
public void CatalogHasStableCoinOnlyUtilityPrices() public void CatalogHasStableCoinOnlyUtilityPrices()
{ {
@@ -14,6 +14,19 @@ public sealed class CreditsController(CreditStore credits, ExamSimulationStore s
[HttpGet] [HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId())); public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
[HttpGet("ledger")]
public async Task<IActionResult> 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")] [HttpPost("purchases")]
public async Task<IActionResult> Purchase([FromBody] CreditPurchaseRequest request) public async Task<IActionResult> Purchase([FromBody] CreditPurchaseRequest request)
{ {
@@ -9,7 +9,10 @@ namespace QuestionsApi.Controllers;
[ApiController] [ApiController]
[Route("api/exam-simulations")] [Route("api/exam-simulations")]
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase public class ExamSimulationsController(
ExamSimulationStore simulations,
CreditStore credits,
MasteryAwardService masteryAwards) : ControllerBase
{ {
[HttpGet("readiness")] [HttpGet("readiness")]
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
@@ -132,7 +135,23 @@ public class ExamSimulationsController(ExamSimulationStore simulations, CreditSt
{ {
var simulation = await simulations.GetOwnedAsync(id, UserId()); var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound(); 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); } catch (InvalidOperationException exception) { return Conflict(exception.Message); }
} }
@@ -20,6 +20,7 @@ public class QuestionsController : ControllerBase
private readonly QuestionBookmarkStore _bookmarks; private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue; private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits; private readonly CreditStore _credits;
private readonly MasteryAwardService _masteryAwards;
public QuestionsController( public QuestionsController(
QuestionStore questions, QuestionStore questions,
@@ -30,7 +31,8 @@ public class QuestionsController : ControllerBase
ServiceMetrics metrics, ServiceMetrics metrics,
QuestionBookmarkStore bookmarks, QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue, ReviewQueueStore reviewQueue,
CreditStore credits) CreditStore credits,
MasteryAwardService masteryAwards)
{ {
_questions = questions; _questions = questions;
_attempts = attempts; _attempts = attempts;
@@ -41,6 +43,7 @@ public class QuestionsController : ControllerBase
_bookmarks = bookmarks; _bookmarks = bookmarks;
_reviewQueue = reviewQueue; _reviewQueue = reviewQueue;
_credits = credits; _credits = credits;
_masteryAwards = masteryAwards;
} }
[HttpGet("systems")] [HttpGet("systems")]
@@ -152,6 +155,8 @@ public class QuestionsController : ControllerBase
if (string.IsNullOrWhiteSpace(userId)) if (string.IsNullOrWhiteSpace(userId))
return Unauthorized(); return Unauthorized();
await _masteryAwards.PrepareAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync( var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId, question.ExternalId,
userId, userId,
@@ -178,8 +183,18 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate(); _metrics.AttemptDuplicate();
} }
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit) var creditAwards = new List<CreditAward>();
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? ""); 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 ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync( var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -205,6 +220,7 @@ public class QuestionsController : ControllerBase
throw; throw;
} }
creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId));
return Ok(new FirstQuestionAttemptResponse return Ok(new FirstQuestionAttemptResponse
{ {
QuestionExternalId = question.ExternalId, QuestionExternalId = question.ExternalId,
@@ -213,7 +229,8 @@ public class QuestionsController : ControllerBase
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo, QuestionElo = rating.QuestionElo,
Rating = rating, Rating = rating,
Credits = await _credits.GetStatusAsync(userId) Credits = await _credits.GetStatusAsync(userId),
CreditAwards = creditAwards
}); });
} }
@@ -14,7 +14,9 @@ public sealed class ReviewQueueController(
QuestionStore questions, QuestionStore questions,
AnswerStatisticsStore statistics, AnswerStatisticsStore statistics,
RatingStore ratings, RatingStore ratings,
QuestionBookmarkStore bookmarks) : ControllerBase QuestionBookmarkStore bookmarks,
CreditStore credits,
MasteryAwardService masteryAwards) : ControllerBase
{ {
[HttpPost("next")] [HttpPost("next")]
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req) public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
@@ -71,6 +73,7 @@ public sealed class ReviewQueueController(
return BadRequest("presentedChoiceIds must include valid selected and correct choices"); return BadRequest("presentedChoiceIds must include valid selected and correct choices");
} }
await masteryAwards.PrepareAsync(userId);
var result = await queue.RecordReviewAsync( var result = await queue.RecordReviewAsync(
userId, userId,
req.ClientAttemptId, req.ClientAttemptId,
@@ -81,12 +84,27 @@ public sealed class ReviewQueueController(
choiceId == question.CorrectChoiceId, choiceId == question.CorrectChoiceId,
req.DurationSeconds); req.DurationSeconds);
if (result is null) return Conflict("review cycle is no longer active"); if (result is null) return Conflict("review cycle is no longer active");
var awards = new List<CreditAward>();
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 return Ok(new ReviewQueueAttemptResponse
{ {
QuestionExternalId = question.ExternalId, QuestionExternalId = question.ExternalId,
IsCorrect = result.Attempt.IsCorrect, IsCorrect = result.Attempt.IsCorrect,
QueueState = result.Item?.State ?? ReviewQueueStates.Pending, 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
}); });
} }
+73 -1
View File
@@ -5,7 +5,7 @@ namespace QuestionsApi.Models;
public static class CreditEconomy public static class CreditEconomy
{ {
public const int RollingEarningLimit = 20; public const int RollingEarningLimit = 30;
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24); public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
public const string ExamTokenSku = "exam_token_1"; public const string ExamTokenSku = "exam_token_1";
public const string QBankResetTokenSku = "qbank_reset_1"; public const string QBankResetTokenSku = "qbank_reset_1";
@@ -34,6 +34,9 @@ public static class CreditEconomy
public static RzStoreProduct? FindProduct(string sku) => public static RzStoreProduct? FindProduct(string sku) =>
Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal)); 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 public static class SlotMachineEconomy
@@ -92,6 +95,65 @@ public sealed class RzStoreProduct
public sealed class CreditEvent public sealed class CreditEvent
{ {
public string OperationId { get; set; } = ""; 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<CreditLedgerEntryResponse> 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; } public DateTime CreatedUtc { get; set; }
} }
@@ -122,6 +184,7 @@ public sealed class UserCreditAccount
public int QBankResetTokens { get; set; } public int QBankResetTokens { get; set; }
public int BankCycle { get; set; } public int BankCycle { get; set; }
public bool InitialExamGrantApplied { get; set; } public bool InitialExamGrantApplied { get; set; }
public bool MasteryAwardBaselineApplied { get; set; }
public List<CreditEvent> RecentEarnings { get; set; } = []; public List<CreditEvent> RecentEarnings { get; set; } = [];
public List<CreditPurchaseEvent> Purchases { get; set; } = []; public List<CreditPurchaseEvent> Purchases { get; set; } = [];
public List<SpinEvent> Spins { get; set; } = []; public List<SpinEvent> Spins { get; set; } = [];
@@ -142,6 +205,15 @@ public sealed class CreditStatusResponse
public IReadOnlyList<RzStoreProduct> Products { get; set; } = CreditEconomy.Products; public IReadOnlyList<RzStoreProduct> 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 sealed class CreditPurchaseRequest
{ {
public string Sku { get; set; } = ""; public string Sku { get; set; } = "";
@@ -117,6 +117,8 @@ public class ExamSimulationStateResponse
public Dictionary<string, int> BlueprintTargets { get; set; } = []; public Dictionary<string, int> BlueprintTargets { get; set; } = [];
public Dictionary<string, int> BlueprintActual { get; set; } = []; public Dictionary<string, int> BlueprintActual { get; set; } = [];
public List<QuestionResponse> ReviewQuestions { get; set; } = []; public List<QuestionResponse> ReviewQuestions { get; set; } = [];
public CreditStatusResponse? Credits { get; set; }
public List<CreditAward> CreditAwards { get; set; } = [];
} }
public class ExamSimulationReadinessResponse public class ExamSimulationReadinessResponse
@@ -146,6 +146,7 @@ public class FirstQuestionAttemptResponse
public int QuestionElo { get; set; } = 1000; public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; } public RatingChangeResponse? Rating { get; set; }
public CreditStatusResponse? Credits { get; set; } public CreditStatusResponse? Credits { get; set; }
public List<CreditAward> CreditAwards { get; set; } = [];
} }
public class SkipQuestionResponse public class SkipQuestionResponse
@@ -81,6 +81,8 @@ public sealed class ReviewQueueAttemptResponse
public bool IsCorrect { get; set; } public bool IsCorrect { get; set; }
public string QueueState { get; set; } = ReviewQueueStates.Pending; public string QueueState { get; set; } = ReviewQueueStates.Pending;
public int PendingCount { get; set; } public int PendingCount { get; set; }
public CreditStatusResponse? Credits { get; set; }
public List<CreditAward> CreditAwards { get; set; } = [];
} }
public sealed class ReviewQueueRecordResult public sealed class ReviewQueueRecordResult
+2
View File
@@ -90,6 +90,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
builder.Services.AddSingleton<MongoDatabaseProvider>(); builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>(); builder.Services.AddSingleton<CreditStore>();
builder.Services.AddSingleton<MasteryAwardService>();
builder.Services.AddHostedService<CreditLedgerReconciliationService>();
builder.Services.AddSingleton<QuestionStore>(); builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>(); builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>(); builder.Services.AddSingleton<AnswerStatisticsStore>();
@@ -0,0 +1,17 @@
namespace QuestionsApi.Services;
public sealed class CreditLedgerReconciliationService(
CreditStore credits,
ILogger<CreditLedgerReconciliationService> 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);
}
}
}
+211 -29
View File
@@ -7,43 +7,167 @@ namespace QuestionsApi.Services;
public sealed class CreditStore public sealed class CreditStore
{ {
private readonly IMongoCollection<UserCreditAccount> _accounts; private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly IMongoCollection<CreditLedgerEntry> _ledger;
private readonly TimeProvider _clock; 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<UserCreditAccount>("UserCreditAccounts"); _accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_ledger = mongo.Database.GetCollection<CreditLedgerEntry>("CreditLedgerEntries");
_clock = clock; _clock = clock;
_metrics = metrics;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>( _accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId), Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" })); new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
_ledger.Indexes.CreateOne(new CreateIndexModel<CreditLedgerEntry>(
Builders<CreditLedgerEntry>.IndexKeys.Ascending(value => value.UserId)
.Descending(value => value.CreatedUtc).Descending(value => value.Id),
new CreateIndexOptions { Name = "credit_ledger_user_time" }));
} }
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId) public async Task<CreditAwardResult> 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; var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now); await EnsureAndCleanAsync(userId, now);
var entryId = LedgerId(userId, request.OperationId);
var filters = Builders<UserCreditAccount>.Filter; var entry = await _ledger.Find(value => value.Id == entryId).FirstOrDefaultAsync();
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>( if (entry is not null && (entry.ReasonCode != request.ReasonCode || entry.SourceId != request.SourceId))
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray throw new InvalidOperationException("The credit operation ID was already used for another event.");
if (entry is null)
{ {
new BsonDocument("$size", "$RecentEarnings"), var alreadyApplied = await _accounts.Find(value => value.UserId == userId
CreditEconomy.RollingEarningLimit && value.AppliedOperations.Contains(request.OperationId)).AnyAsync();
}))); if (alreadyApplied) return new CreditAwardResult { Status = await GetStatusAsync(userId) };
var filter = filters.Eq(value => value.UserId, userId) & entry = new CreditLedgerEntry
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, 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 CreatedUtc = now
}) };
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<UserCreditAccount>.Filter.Eq(value => value.UserId, userId)
& Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [request.OperationId]);
if (request.SubjectToRollingCap && amount > 0)
filter &= RollingEarnedAtMost(CreditEconomy.RollingEarningLimit - amount);
var update = Builders<UserCreditAccount>.Update
.AddToSet(value => value.AppliedOperations, request.OperationId)
.Set(value => value.UpdatedUtc, now); .Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; 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<string> operationIds)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
var update = Builders<UserCreditAccount>.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<bool> IsMasteryBaselineAppliedAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).MasteryAwardBaselineApplied;
}
public async Task<HashSet<string>> 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<CreditLedgerPage> GetLedgerAsync(string userId, int limit, DateTime? before)
{
limit = Math.Clamp(limit, 1, 50);
var filter = Builders<CreditLedgerEntry>.Filter.Eq(value => value.UserId, userId)
& Builders<CreditLedgerEntry>.Filter.Eq(value => value.State, "applied")
& Builders<CreditLedgerEntry>.Filter.Ne(value => value.Amount, 0);
if (before.HasValue) filter &= Builders<CreditLedgerEntry>.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<CreditStatusResponse> GetStatusAsync(string userId) public async Task<CreditStatusResponse> GetStatusAsync(string userId)
@@ -68,9 +192,12 @@ public sealed class CreditStore
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync(); var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId); var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId);
if (prior is not null) if (prior is not null)
return Response(prior.Sku == product.Sku {
? CreditOperationResult.Succeeded if (prior.Sku != product.Sku)
: CreditOperationResult.OperationConflict, existing); return Response(CreditOperationResult.OperationConflict, existing);
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
return Response(CreditOperationResult.Succeeded, existing);
}
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) & var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) & Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
@@ -95,14 +222,21 @@ public sealed class CreditStore
{ {
ReturnDocument = ReturnDocument.After 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(); account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId); prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId);
if (prior is not null) if (prior is not null)
return Response(prior.Sku == product.Sku {
? CreditOperationResult.Succeeded if (prior.Sku != product.Sku)
: CreditOperationResult.OperationConflict, account); return Response(CreditOperationResult.OperationConflict, account);
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
return Response(CreditOperationResult.Succeeded, account);
}
return Response(CreditOperationResult.InsufficientCredits, account); return Response(CreditOperationResult.InsufficientCredits, account);
} }
@@ -223,6 +357,7 @@ public sealed class CreditStore
.SetOnInsert(value => value.QBankResetTokens, 0) .SetOnInsert(value => value.QBankResetTokens, 0)
.SetOnInsert(value => value.BankCycle, 0) .SetOnInsert(value => value.BankCycle, 0)
.SetOnInsert(value => value.InitialExamGrantApplied, true) .SetOnInsert(value => value.InitialExamGrantApplied, true)
.SetOnInsert(value => value.MasteryAwardBaselineApplied, false)
.SetOnInsert(value => value.RecentEarnings, []) .SetOnInsert(value => value.RecentEarnings, [])
.SetOnInsert(value => value.Purchases, []) .SetOnInsert(value => value.Purchases, [])
.SetOnInsert(value => value.Spins, []) .SetOnInsert(value => value.Spins, [])
@@ -273,9 +408,9 @@ public sealed class CreditStore
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new() private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
{ {
Credits = account.Credits, Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count, EarnedInRollingWindow = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)),
RemainingEarningCapacity = Math.Max( RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count), 0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount))),
ExamTokens = account.ExamTokens, ExamTokens = account.ExamTokens,
QBankResetTokens = account.QBankResetTokens, QBankResetTokens = account.QBankResetTokens,
BankCycle = account.BankCycle BankCycle = account.BankCycle
@@ -297,4 +432,51 @@ public sealed class CreditStore
CoinsWon = spinEvent?.CoinsWon ?? 0, CoinsWon = spinEvent?.CoinsWon ?? 0,
Status = ToStatus(account) 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<UserCreditAccount> RollingEarnedAtMost(int maximum) =>
new BsonDocumentFilterDefinition<UserCreditAccount>(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<CreditLedgerEntry>.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
};
} }
@@ -0,0 +1,86 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class MasteryAwardService
{
private readonly IMongoCollection<QuestionAttempt> _attempts;
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
private readonly IMongoCollection<ReviewQueueItem> _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<QuestionAttempt>("QuestionAttempts");
_reviews = mongo.Database.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
_queue = mongo.Database.GetCollection<ReviewQueueItem>("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<List<CreditAward>> AwardPromotionsAsync(string userId)
{
var awards = new List<CreditAward>();
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<List<DashboardSystemPerformance>> 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<string> BaselineOperations(DashboardSystemPerformance system) =>
EarnedStates(system.MasteryState).Select(state => Operation(system.System, state));
private static IEnumerable<string> 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")
};
}
@@ -17,6 +17,10 @@ public sealed class ServiceMetrics
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicAttempts = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicAttempts = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new();
private long _dynamicTargetGapTotal; private long _dynamicTargetGapTotal;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditAwards = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCoins = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditDuplicates = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCapped = new();
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated); public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates); public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
@@ -43,6 +47,13 @@ public sealed class ServiceMetrics
_dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1); _dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1);
if (correct) _dynamicCorrect.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() public string RenderPrometheus()
{ {
@@ -63,6 +74,17 @@ public sealed class ServiceMetrics
AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier)); AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier));
} }
AppendCounter(output, "step3_dynamic_target_gap_points_total", Interlocked.Read(ref _dynamicTargetGapTotal)); 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.AppendLine("# TYPE step3_http_request_duration_seconds summary");
output.Append("step3_http_request_duration_seconds_count ") output.Append("step3_http_request_duration_seconds_count ")
.Append(Interlocked.Read(ref _httpRequests)) .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(); 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();
} }
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
"ExternalIdentities", "ExternalAuthTransactions", "ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts" "UserCreditAccounts", "CreditLedgerEntries"
]; ];
public UserProfileStore(MongoDatabaseProvider mongo) public UserProfileStore(MongoDatabaseProvider mongo)
+4 -4
View File
@@ -155,10 +155,10 @@ social notifications.
### Phase 1: foundation ### Phase 1: foundation
1. Define the mastery model and expose topic-level progress. - [x] Define the mastery model and expose topic-level progress.
2. Add server-authoritative RZ Coin awards for approved learning events. - [x] Add server-authoritative RZ Coin awards for approved learning events.
3. Add user-selected session goals and an end-of-session summary. - [ ] Add user-selected session goals and an end-of-session summary.
4. Add daily missions with one replacement per day. - [ ] Add daily missions with one replacement per day.
### Phase 2: progression ### Phase 2: progression
+14 -11
View File
@@ -1,19 +1,22 @@
# Question-completion credits # Question-completion credits
This branch introduces credit earning only. It does not impose a question RZ Coin is awarded by the API for verified learning outcomes. The client displays
limit, redeem credits, sell products, activate passes, consume exam tokens, or award results but cannot choose award amounts or reason codes.
reset QBank progress.
- A successfully stored first answer earns one credit regardless of whether it - A first correct answer earns 1 coin at difficulty 1-3, 2 at difficulty 4,
is correct. and 3 at difficulty 5.
- A repeated answer does not earn another credit. - A correct scheduled review earns 1 coin, or 3 when it recovers a miss after
- A skipped question does not earn credit. at least 24 hours.
- At most 20 credits can be earned in a rolling 24-hour window. - 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. - Historical attempts are not backfilled.
- `GET /api/credits` returns the authenticated user's balance and current - `GET /api/credits` returns the authenticated user's balance and current
rolling-window capacity. rolling-window capacity.
- The first-attempt response also includes the updated credit status. - The first-attempt response also includes the updated credit status.
The API is authoritative. Credit grants use the immutable attempt ID as their The API is authoritative. Immutable attempt, review, simulation, and mastery
idempotency key, so retrying the same completed question cannot grant another identifiers make grants idempotent. Awards and purchases appear in the user's
credit. Credit accounts are included in account export and deletion. coin ledger. Credit accounts are included in account export and deletion.
+49
View File
@@ -5,6 +5,8 @@ signal credits_failed(message: String)
signal store_operation_completed(message: String) signal store_operation_completed(message: String)
signal spin_resolved(reward_key: String, reward_label: String, coins_won: int) signal spin_resolved(reward_key: String, reward_label: String, coins_won: int)
signal spin_config_loaded 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 EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits" const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
@@ -21,9 +23,13 @@ var loading := false
var request: HTTPRequest var request: HTTPRequest
var action_request: HTTPRequest var action_request: HTTPRequest
var spin_config_request: HTTPRequest var spin_config_request: HTTPRequest
var ledger_request: HTTPRequest
var request_token := "" var request_token := ""
var api_url := DEFAULT_URL var api_url := DEFAULT_URL
var pending_store_action := "" var pending_store_action := ""
var ledger: Array = []
var ledger_next_before := ""
var ledger_append := false
func _ready() -> void: func _ready() -> void:
@@ -42,6 +48,10 @@ func _ready() -> void:
spin_config_request.timeout = 20.0 spin_config_request.timeout = 20.0
spin_config_request.request_completed.connect(_on_spin_config_completed) spin_config_request.request_completed.connect(_on_spin_config_completed)
add_child(spin_config_request) 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) AuthState.auth_changed.connect(_on_auth_changed)
@@ -67,6 +77,43 @@ func apply_status(parsed: Dictionary) -> void:
credits_changed.emit() 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: func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
loading = false loading = false
if response_code == 401: if response_code == 401:
@@ -173,6 +220,8 @@ func _on_auth_changed() -> void:
bank_cycle = 0 bank_cycle = 0
products = [] products = []
spin_rewards = [] spin_rewards = []
ledger = []
ledger_next_before = ""
loaded = false loaded = false
loading = false loading = false
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
@@ -196,6 +196,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
last_failed_action = action last_failed_action = action
request_failed.emit("The simulation API returned an invalid response.") request_failed.emit("The simulation API returned an invalid response.")
return return
CreditsService.apply_award_response(parsed)
if action == "readiness": if action == "readiness":
readiness = parsed readiness = parsed
readiness_changed.emit() readiness_changed.emit()
+1 -3
View File
@@ -545,9 +545,7 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
var rating: Dictionary = parsed.get("rating", {}) var rating: Dictionary = parsed.get("rating", {})
_update_question_elo(question_id, question_elo) _update_question_elo(question_id, question_elo)
_apply_rating_change(rating) _apply_rating_change(rating)
var credits_status: Variant = parsed.get("credits", null) CreditsService.apply_award_response(parsed)
if typeof(credits_status) == TYPE_DICTIONARY:
CreditsService.apply_status(credits_status)
choice_percentages_updated.emit(question_id, percentages) choice_percentages_updated.emit(question_id, percentages)
question_rating_updated.emit(question_id, question_elo, rating) question_rating_updated.emit(question_id, question_elo, rating)
+1
View File
@@ -190,6 +190,7 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack
var synced_question_id := "" var synced_question_id := ""
var synced_is_correct := false var synced_is_correct := false
if typeof(parsed) == TYPE_DICTIONARY: if typeof(parsed) == TYPE_DICTIONARY:
CreditsService.apply_award_response(parsed)
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count))) 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_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false))) synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false)))
+26
View File
@@ -38,12 +38,38 @@ func _ready() -> void:
theme_service = get_node("/root/UIThemeService") theme_service = get_node("/root/UIThemeService")
Style.apply_app_theme(self) Style.apply_app_theme(self)
_build_ui() _build_ui()
CreditsService.award_received.connect(_show_coin_award)
theme_service.theme_changed.connect(_on_theme_changed) theme_service.theme_changed.connect(_on_theme_changed)
resized.connect(_update_responsive_layout) resized.connect(_update_responsive_layout)
_update_responsive_layout() _update_responsive_layout()
show_screen(("legal_consent" if AuthState.requires_legal_acceptance else "dashboard") if AuthState.is_logged_in else "login") 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: func _update_responsive_layout() -> void:
compact_shell = size.x < 900.0 compact_shell = size.x < 900.0
if sidebar != null: if sidebar != null:
-43
View File
@@ -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 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_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 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 FEEDBACK_FADE_SECONDS := 0.45
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25 const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
const TEXT_SCALE_MIN := 0.85 const TEXT_SCALE_MIN := 0.85
@@ -1650,15 +1649,6 @@ func _submit_or_next() -> void:
_update_question_elo_label(true) _update_question_elo_label(true)
_show_answer_review() _show_answer_review()
_trigger_correct_feedback() _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 return
_lock_wrong_choice(attempted_choice) _lock_wrong_choice(attempted_choice)
_show_wrong_answer_followup(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)) 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: func _update_answer_feedback(delta: float) -> void:
if shake_time > 0.0: if shake_time > 0.0:
shake_time = max(0.0, shake_time - delta) shake_time = max(0.0, shake_time - delta)
+53 -1
View File
@@ -39,6 +39,8 @@ var footer_icon: TextureRect
var footer_heading: Label var footer_heading: Label
var footer_description: Label var footer_description: Label
var pending_sku := "" var pending_sku := ""
var ledger_list: VBoxContainer
var ledger_more_button: Button
func _ready() -> void: func _ready() -> void:
@@ -76,20 +78,70 @@ func _ready() -> void:
confirm_dialog.confirmed.connect(_confirm_purchase) confirm_dialog.confirmed.connect(_confirm_purchase)
add_child(confirm_dialog) add_child(confirm_dialog)
_build_ledger()
_build_footer() _build_footer()
CreditsService.credits_changed.connect(_refresh) CreditsService.credits_changed.connect(_refresh)
CreditsService.credits_failed.connect(_show_error) CreditsService.credits_failed.connect(_show_error)
CreditsService.store_operation_completed.connect(_show_message) CreditsService.store_operation_completed.connect(_show_message)
CreditsService.ledger_changed.connect(_render_ledger)
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_theme_controls(); _refresh()) UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_theme_controls(); _refresh())
_refresh() _refresh()
func on_show(_payload: Variant = null) -> void: func on_show(_payload: Variant = null) -> void:
CreditsService.refresh() CreditsService.refresh()
CreditsService.load_ledger()
_refresh() _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: func _build_header() -> void:
var title := Label.new() var title := Label.new()
title.text = "RZ Coin Shop" 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)) footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
copy.add_child(footer_heading) copy.add_child(footer_heading)
footer_description = Label.new() 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_override("font", Brand.font)
footer_description.add_theme_font_size_override("font_size", 12) footer_description.add_theme_font_size_override("font_size", 12)
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark)) footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))