Add server-authoritative RZ Coin rewards
This commit is contained in:
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user