Files
step3/api/QuestionsApi/Controllers/CreditsController.cs
T
hz 1860e6ede6 Add an RZ Coin slot machine
Adds a spend-coins-for-a-random-reward mini-game reachable from the
shop. Backend mirrors the store's existing atomic/idempotent purchase
pattern (SpinAsync in CreditStore.cs) with a server-authoritative,
weighted reward roll and a rolling per-user spin-rate guard; odds are
served from GET /api/credits/spin-config so the client never hardcodes
numbers that could drift from the real payout table. The mockup's
"Streak Protector" reward was swapped for a real Exam Simulation Token
since streaks aren't currently a persisted, protectable concept.

Also fixes two latent bugs surfaced while building this:
- credits_service.gd's operation-id builder referenced a nonexistent
  AuthState.user_id (should be .username), throwing on every credit
  action.
- _post() silently no-op'd with no signal when signed out, which
  combined with a GDScript closure gotcha (lambdas capture outer
  locals by value, so reassigning inside one never reached the
  caller) meant a spin attempted while unauthenticated could hang
  forever instead of surfacing an error.
2026-07-30 00:29:49 -05:00

80 lines
3.8 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using QuestionsApi.Models;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/credits")]
[Authorize(Roles = "USER,SUPER")]
public sealed class CreditsController(CreditStore credits, ExamSimulationStore simulations) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
[HttpPost("purchases")]
public async Task<IActionResult> Purchase([FromBody] CreditPurchaseRequest request)
{
var product = CreditEconomy.FindProduct(request.Sku);
if (product is null) return BadRequest(new { code = "unknown_sku", message = "Unknown store product." });
if (!ValidOperationId(request.OperationId))
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
var result = await credits.PurchaseAsync(UserId(), product, request.OperationId);
return result.Result switch
{
CreditOperationResult.Succeeded => Ok(result.Status),
CreditOperationResult.InsufficientCredits => Conflict(new
{ code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }),
_ => Conflict(new
{ code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status })
};
}
[HttpPost("qbank-reset")]
public async Task<IActionResult> ResetQBank([FromBody] QBankResetRequest request)
{
if (request.Confirmation != "RESET_QBANK")
return BadRequest(new { code = "confirmation_required", message = "Enter RESET_QBANK to confirm." });
if (!ValidOperationId(request.OperationId))
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
if (await simulations.FindActiveAsync(UserId()) is not null)
return Conflict(new { code = "active_exam", message = "Finish or end the active exam before resetting the QBank." });
var result = await credits.ApplyQBankResetAsync(UserId(), request.OperationId);
return result.Result == CreditOperationResult.Succeeded
? Ok(result.Status)
: Conflict(new
{ code = "reset_token_required", message = "A QBank Reset Token is required.", credits = result.Status });
}
[HttpGet("spin-config")]
public IActionResult SpinConfig() => Ok(new SpinConfigResponse());
[HttpPost("spin")]
public async Task<IActionResult> Spin([FromBody] SpinRequest request)
{
if (!ValidOperationId(request.OperationId))
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
var result = await credits.SpinAsync(UserId(), request.OperationId);
return result.Result switch
{
CreditOperationResult.Succeeded => Ok(result),
CreditOperationResult.InsufficientCredits => Conflict(new
{ code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }),
CreditOperationResult.RateLimited => Conflict(new
{ code = "rate_limited", message = "Too many spins - try again later.", credits = result.Status }),
_ => Conflict(new
{ code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status })
};
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
private static bool ValidOperationId(string value) =>
!string.IsNullOrWhiteSpace(value) && value.Length <= 100;
}