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
@@ -14,6 +14,19 @@ public sealed class CreditsController(CreditStore credits, ExamSimulationStore s
[HttpGet]
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")]
public async Task<IActionResult> Purchase([FromBody] CreditPurchaseRequest request)
{
@@ -9,7 +9,10 @@ namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/exam-simulations")]
[Authorize(Roles = "USER,SUPER")]
public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase
public class ExamSimulationsController(
ExamSimulationStore simulations,
CreditStore credits,
MasteryAwardService masteryAwards) : ControllerBase
{
[HttpGet("readiness")]
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
@@ -132,7 +135,23 @@ public class ExamSimulationsController(ExamSimulationStore simulations, CreditSt
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound();
try { return Ok(await simulations.ToResponseAsync(await simulations.SubmitBlockAsync(simulation))); }
try
{
var userId = UserId();
await masteryAwards.PrepareAsync(userId);
simulation = await simulations.SubmitBlockAsync(simulation);
var response = await simulations.ToResponseAsync(simulation);
if (simulation.State == ExamSimulationStates.Completed && !simulation.IsSample)
{
var result = await credits.AwardAsync(userId, new CreditAwardRequest(
$"simulation:{simulation.Id}", 10, CreditReasonCodes.ExamSimulationComplete,
"Completed a Step 3 exam simulation", "exam_simulation", simulation.Id ?? "", false));
if (result.Award is not null) response.CreditAwards.Add(result.Award);
response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
response.Credits = await credits.GetStatusAsync(userId);
}
return Ok(response);
}
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
@@ -20,6 +20,7 @@ public class QuestionsController : ControllerBase
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
private readonly MasteryAwardService _masteryAwards;
public QuestionsController(
QuestionStore questions,
@@ -30,7 +31,8 @@ public class QuestionsController : ControllerBase
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue,
CreditStore credits)
CreditStore credits,
MasteryAwardService masteryAwards)
{
_questions = questions;
_attempts = attempts;
@@ -41,6 +43,7 @@ public class QuestionsController : ControllerBase
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_credits = credits;
_masteryAwards = masteryAwards;
}
[HttpGet("systems")]
@@ -152,6 +155,8 @@ public class QuestionsController : ControllerBase
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await _masteryAwards.PrepareAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
userId,
@@ -178,8 +183,18 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit)
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
var creditAwards = new List<CreditAward>();
if (record.Inserted && record.Attempt.IsCorrect)
{
var amount = CreditEconomy.FirstCorrectAmount(question.Difficulty);
var difficult = question.Difficulty >= 4;
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
$"answer:{record.Attempt.Id}", amount,
difficult ? CreditReasonCodes.DifficultCorrect : CreditReasonCodes.FirstCorrect,
difficult ? $"Difficulty {question.Difficulty} first correct answer" : "First correct answer",
"question_attempt", record.Attempt.Id ?? ""));
if (award.Award is not null) creditAwards.Add(award.Award);
}
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -205,6 +220,7 @@ public class QuestionsController : ControllerBase
throw;
}
creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId));
return Ok(new FirstQuestionAttemptResponse
{
QuestionExternalId = question.ExternalId,
@@ -213,7 +229,8 @@ public class QuestionsController : ControllerBase
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating,
Credits = await _credits.GetStatusAsync(userId)
Credits = await _credits.GetStatusAsync(userId),
CreditAwards = creditAwards
});
}
@@ -14,7 +14,9 @@ public sealed class ReviewQueueController(
QuestionStore questions,
AnswerStatisticsStore statistics,
RatingStore ratings,
QuestionBookmarkStore bookmarks) : ControllerBase
QuestionBookmarkStore bookmarks,
CreditStore credits,
MasteryAwardService masteryAwards) : ControllerBase
{
[HttpPost("next")]
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
@@ -71,6 +73,7 @@ public sealed class ReviewQueueController(
return BadRequest("presentedChoiceIds must include valid selected and correct choices");
}
await masteryAwards.PrepareAsync(userId);
var result = await queue.RecordReviewAsync(
userId,
req.ClientAttemptId,
@@ -81,12 +84,27 @@ public sealed class ReviewQueueController(
choiceId == question.CorrectChoiceId,
req.DurationSeconds);
if (result is null) return Conflict("review cycle is no longer active");
var awards = new List<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
{
QuestionExternalId = question.ExternalId,
IsCorrect = result.Attempt.IsCorrect,
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
PendingCount = checked((int)await queue.CountPendingAsync(userId))
PendingCount = checked((int)await queue.CountPendingAsync(userId)),
Credits = await credits.GetStatusAsync(userId),
CreditAwards = awards
});
}