Merge pull request 'Main' (#16) from main into production
Reviewed-on: admin/step3#16
@@ -77,6 +77,23 @@ Promotion is idempotent and does not delete or implicitly unpublish PRD content.
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
## Rz Coin Store attempt-cycle migration
|
||||
|
||||
The QBank reset preserves every attempt and increments a user's eligibility cycle. After deploying
|
||||
the store-capable API to an environment, inspect and then apply the one-time index migration before
|
||||
using QBank reset tokens:
|
||||
|
||||
```bash
|
||||
kubectl exec deployment/step3-api -n step3-uat -- \
|
||||
dotnet QuestionsApi.dll migrate-question-attempt-cycles
|
||||
kubectl exec deployment/step3-api -n step3-uat -- \
|
||||
dotnet QuestionsApi.dll migrate-question-attempt-cycles --apply --confirm MIGRATE_ATTEMPT_CYCLES
|
||||
```
|
||||
|
||||
Repeat with namespace `step3-prd` after the tested revision is promoted to `production`. The dry run
|
||||
reports missing cycle fields and both index states. Apply backfills existing attempts to cycle zero,
|
||||
creates the three-field unique index, and only then removes the legacy two-field unique index.
|
||||
|
||||
Check both internal and public readiness after every rollout:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,5 +17,28 @@ public sealed class CreditEconomyTests
|
||||
{
|
||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
|
||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
|
||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.Purchases)));
|
||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.AppliedOperations)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CatalogHasStableCoinOnlyUtilityPrices()
|
||||
{
|
||||
var exam = CreditEconomy.FindProduct(CreditEconomy.ExamTokenSku);
|
||||
var reset = CreditEconomy.FindProduct(CreditEconomy.QBankResetTokenSku);
|
||||
|
||||
Assert.NotNull(exam);
|
||||
Assert.Equal(100, exam.CoinPrice);
|
||||
Assert.Equal(RzStoreGrant.ExamToken, exam.Grant);
|
||||
Assert.NotNull(reset);
|
||||
Assert.Equal(300, reset.CoinPrice);
|
||||
Assert.Equal(RzStoreGrant.QBankResetToken, reset.Grant);
|
||||
Assert.Equal(2, CreditEconomy.Products.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownSkuIsRejectedByCatalog()
|
||||
{
|
||||
Assert.Null(CreditEconomy.FindProduct("cash_purchase"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Services;
|
||||
using QuestionsApi.Models;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
@@ -8,13 +9,71 @@ namespace QuestionsApi.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/credits")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class CreditsController(CreditStore credits) : ControllerBase
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/exam-simulations")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase
|
||||
{
|
||||
[HttpGet("readiness")]
|
||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||
@@ -17,12 +17,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var userId = UserId();
|
||||
if (await simulations.FindActiveAsync(userId) is not null)
|
||||
return Conflict(new { code = "active_exam", message = "An active Step 3 simulation already exists." });
|
||||
var operationId = $"exam-create:{Guid.NewGuid():N}";
|
||||
var bypass = User.IsInRole("SUPER");
|
||||
if (!bypass && !await credits.TryConsumeExamTokenAsync(userId, operationId))
|
||||
return Conflict(new
|
||||
{
|
||||
code = "exam_token_required",
|
||||
message = "An Exam Simulation Token is required.",
|
||||
credits = await credits.GetStatusAsync(userId)
|
||||
});
|
||||
try
|
||||
{
|
||||
var simulation = await simulations.CreateAsync(UserId());
|
||||
var simulation = await simulations.CreateAsync(userId);
|
||||
return Ok(await simulations.ToResponseAsync(simulation));
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
|
||||
return Conflict(new { code = "exam_creation_failed", message = exception.Message });
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("preview-results")]
|
||||
|
||||
@@ -157,7 +157,8 @@ public class QuestionsController : ControllerBase
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||
false,
|
||||
presentedChoiceIds,
|
||||
req.DurationSeconds);
|
||||
req.DurationSeconds,
|
||||
await _credits.GetBankCycleAsync(userId));
|
||||
if (record.Inserted)
|
||||
{
|
||||
_metrics.AttemptCreated();
|
||||
@@ -276,7 +277,8 @@ public class QuestionsController : ControllerBase
|
||||
userId,
|
||||
"",
|
||||
false,
|
||||
true);
|
||||
true,
|
||||
bankCycle: await _credits.GetBankCycleAsync(userId));
|
||||
if (record.Inserted)
|
||||
_metrics.AttemptCreated();
|
||||
else
|
||||
|
||||
@@ -7,6 +7,86 @@ public static class CreditEconomy
|
||||
{
|
||||
public const int RollingEarningLimit = 20;
|
||||
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
|
||||
public const string ExamTokenSku = "exam_token_1";
|
||||
public const string QBankResetTokenSku = "qbank_reset_1";
|
||||
public const int ExamTokenPrice = 100;
|
||||
public const int QBankResetTokenPrice = 300;
|
||||
|
||||
public static readonly IReadOnlyList<RzStoreProduct> Products =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Sku = ExamTokenSku,
|
||||
Name = "Exam Simulation Token",
|
||||
Description = "Starts one new full exam simulation.",
|
||||
CoinPrice = ExamTokenPrice,
|
||||
Grant = RzStoreGrant.ExamToken
|
||||
},
|
||||
new()
|
||||
{
|
||||
Sku = QBankResetTokenSku,
|
||||
Name = "QBank Reset Token",
|
||||
Description = "Makes every QBank question eligible again without erasing progress.",
|
||||
CoinPrice = QBankResetTokenPrice,
|
||||
Grant = RzStoreGrant.QBankResetToken
|
||||
}
|
||||
];
|
||||
|
||||
public static RzStoreProduct? FindProduct(string sku) =>
|
||||
Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public static class SlotMachineEconomy
|
||||
{
|
||||
public const int SpinCost = 0; // TODO: restore to 50 once testing is done
|
||||
public const int RollingSpinLimit = 200;
|
||||
public static readonly TimeSpan SpinWindow = TimeSpan.FromHours(24);
|
||||
|
||||
public static readonly IReadOnlyList<SpinReward> Rewards =
|
||||
[
|
||||
new() { Key = "jackpot", Label = "10,000 RZ Coins", Description = "Jackpot!", Grant = RzStoreGrant.Coins, CoinAmount = 10000, Probability = 0.005 },
|
||||
new() { Key = "epic", Label = "2,000 RZ Coins", Description = "Epic Reward", Grant = RzStoreGrant.Coins, CoinAmount = 2000, Probability = 0.02 },
|
||||
new() { Key = "great", Label = "500 RZ Coins", Description = "Great Reward", Grant = RzStoreGrant.Coins, CoinAmount = 500, Probability = 0.10 },
|
||||
new() { Key = "exam_token", Label = "Exam Simulation Token", Description = "Starts one new full exam simulation.", Grant = RzStoreGrant.ExamToken, Probability = 0.15 },
|
||||
new() { Key = "qbank_reset", Label = "QBank Reset Token", Description = "Makes every QBank question eligible again.", Grant = RzStoreGrant.QBankResetToken, Probability = 0.20 },
|
||||
new() { Key = "nice", Label = "100 RZ Coins", Description = "Nice!", Grant = RzStoreGrant.Coins, CoinAmount = 100, Probability = 0.30 },
|
||||
new() { Key = "good_luck", Label = "50 RZ Coins", Description = "Good luck!", Grant = RzStoreGrant.Coins, CoinAmount = 50, Probability = 0.225 },
|
||||
];
|
||||
|
||||
public static SpinReward RollReward(double roll)
|
||||
{
|
||||
var cumulative = 0.0;
|
||||
foreach (var reward in Rewards)
|
||||
{
|
||||
cumulative += reward.Probability;
|
||||
if (roll < cumulative) return reward;
|
||||
}
|
||||
return Rewards[^1];
|
||||
}
|
||||
|
||||
public static SpinReward? FindReward(string key) =>
|
||||
Rewards.FirstOrDefault(value => value.Key.Equals(key, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public enum RzStoreGrant { ExamToken, QBankResetToken, Coins }
|
||||
|
||||
public sealed class SpinReward
|
||||
{
|
||||
public string Key { get; set; } = "";
|
||||
public string Label { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public RzStoreGrant Grant { get; set; }
|
||||
public int CoinAmount { get; set; }
|
||||
public double Probability { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RzStoreProduct
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public int CoinPrice { get; set; }
|
||||
public RzStoreGrant Grant { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreditEvent
|
||||
@@ -15,6 +95,22 @@ public sealed class CreditEvent
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreditPurchaseEvent
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public int CoinCost { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SpinEvent
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
public string RewardKey { get; set; } = "";
|
||||
public int CoinsWon { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserCreditAccount
|
||||
{
|
||||
[BsonId]
|
||||
@@ -22,7 +118,15 @@ public sealed class UserCreditAccount
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public int Credits { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int QBankResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public bool InitialExamGrantApplied { get; set; }
|
||||
public List<CreditEvent> RecentEarnings { get; set; } = [];
|
||||
public List<CreditPurchaseEvent> Purchases { get; set; } = [];
|
||||
public List<SpinEvent> Spins { get; set; } = [];
|
||||
public List<CreditEvent> RecentSpins { get; set; } = [];
|
||||
public List<string> AppliedOperations { get; set; } = [];
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -32,4 +136,48 @@ public sealed class CreditStatusResponse
|
||||
public int EarnedInRollingWindow { get; set; }
|
||||
public int RemainingEarningCapacity { get; set; }
|
||||
public int RollingWindowHours { get; set; } = 24;
|
||||
public int ExamTokens { get; set; }
|
||||
public int QBankResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public IReadOnlyList<RzStoreProduct> Products { get; set; } = CreditEconomy.Products;
|
||||
}
|
||||
|
||||
public sealed class CreditPurchaseRequest
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class QBankResetRequest
|
||||
{
|
||||
public string Confirmation { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class SpinRequest
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum CreditOperationResult { Succeeded, InsufficientCredits, InsufficientTokens, OperationConflict, RateLimited }
|
||||
|
||||
public sealed class CreditOperationResponse
|
||||
{
|
||||
public CreditOperationResult Result { get; set; }
|
||||
public CreditStatusResponse Status { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SpinConfigResponse
|
||||
{
|
||||
public int SpinCost { get; set; } = SlotMachineEconomy.SpinCost;
|
||||
public IReadOnlyList<SpinReward> Rewards { get; set; } = SlotMachineEconomy.Rewards;
|
||||
}
|
||||
|
||||
public sealed class SpinResponse
|
||||
{
|
||||
public CreditOperationResult Result { get; set; }
|
||||
public string RewardKey { get; set; } = "";
|
||||
public string RewardLabel { get; set; } = "";
|
||||
public int CoinsWon { get; set; }
|
||||
public CreditStatusResponse Status { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public class QuestionAttempt
|
||||
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public int BankCycle { get; set; }
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public bool TracksPresentation { get; set; }
|
||||
|
||||
@@ -55,6 +55,17 @@ if (args.FirstOrDefault() == "flag-media-questions")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "migrate-question-attempt-cycles")
|
||||
{
|
||||
var migrationConfiguration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
Environment.ExitCode = await QuestionAttemptCycleMigrationCommand.RunAsync(
|
||||
migrationConfiguration, args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
|
||||
@@ -51,15 +51,167 @@ public sealed class CreditStore
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
return new CreditStatusResponse
|
||||
{
|
||||
Credits = account.Credits,
|
||||
EarnedInRollingWindow = account.RecentEarnings.Count,
|
||||
RemainingEarningCapacity = Math.Max(
|
||||
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
|
||||
};
|
||||
return ToStatus(account);
|
||||
}
|
||||
|
||||
public async Task<int> GetBankCycleAsync(string userId)
|
||||
{
|
||||
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
|
||||
}
|
||||
|
||||
public async Task<CreditOperationResponse> PurchaseAsync(
|
||||
string userId, RzStoreProduct product, string operationId)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (prior is not null)
|
||||
return Response(prior.Sku == product.Sku
|
||||
? CreditOperationResult.Succeeded
|
||||
: CreditOperationResult.OperationConflict, existing);
|
||||
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
|
||||
Builders<UserCreditAccount>.Filter.Not(
|
||||
Builders<UserCreditAccount>.Filter.ElemMatch(
|
||||
value => value.Purchases, purchase => purchase.OperationId == operationId));
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.Credits, -product.CoinPrice)
|
||||
.Push(value => value.Purchases, new CreditPurchaseEvent
|
||||
{
|
||||
OperationId = operationId,
|
||||
Sku = product.Sku,
|
||||
CoinCost = product.CoinPrice,
|
||||
CreatedUtc = now
|
||||
})
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
update = product.Grant == RzStoreGrant.ExamToken
|
||||
? update.Inc(value => value.ExamTokens, 1)
|
||||
: update.Inc(value => value.QBankResetTokens, 1);
|
||||
var account = await _accounts.FindOneAndUpdateAsync(
|
||||
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
|
||||
|
||||
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (prior is not null)
|
||||
return Response(prior.Sku == product.Sku
|
||||
? CreditOperationResult.Succeeded
|
||||
: CreditOperationResult.OperationConflict, account);
|
||||
return Response(CreditOperationResult.InsufficientCredits, account);
|
||||
}
|
||||
|
||||
public async Task<CreditOperationResponse> ApplyQBankResetAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Gt(value => value.QBankResetTokens, 0) &
|
||||
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.QBankResetTokens, -1)
|
||||
.Inc(value => value.BankCycle, 1)
|
||||
.AddToSet(value => value.AppliedOperations, operationId)
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
var account = await _accounts.FindOneAndUpdateAsync(
|
||||
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
|
||||
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
if (account.AppliedOperations.Contains(operationId))
|
||||
return Response(CreditOperationResult.Succeeded, account);
|
||||
return Response(CreditOperationResult.InsufficientTokens, account);
|
||||
}
|
||||
|
||||
public async Task<SpinResponse> SpinAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
var priorSpin = existing.Spins.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (priorSpin is not null)
|
||||
return SpinResult(CreditOperationResult.Succeeded, existing, priorSpin);
|
||||
|
||||
var reward = SlotMachineEconomy.RollReward(Random.Shared.NextDouble());
|
||||
var netCreditDelta = reward.Grant == RzStoreGrant.Coins
|
||||
? reward.CoinAmount - SlotMachineEconomy.SpinCost
|
||||
: -SlotMachineEconomy.SpinCost;
|
||||
var spinEvent = new SpinEvent
|
||||
{
|
||||
OperationId = operationId,
|
||||
RewardKey = reward.Key,
|
||||
CoinsWon = reward.Grant == RzStoreGrant.Coins ? reward.CoinAmount : 0,
|
||||
CreatedUtc = now
|
||||
};
|
||||
|
||||
var belowSpinCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
|
||||
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
|
||||
{
|
||||
new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })),
|
||||
SlotMachineEconomy.RollingSpinLimit
|
||||
})));
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) &
|
||||
Builders<UserCreditAccount>.Filter.Not(
|
||||
Builders<UserCreditAccount>.Filter.ElemMatch(
|
||||
value => value.Spins, spin => spin.OperationId == operationId)) &
|
||||
belowSpinCap;
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.Credits, netCreditDelta)
|
||||
.Push(value => value.Spins, spinEvent)
|
||||
.Push(value => value.RecentSpins, new CreditEvent { OperationId = operationId, CreatedUtc = now })
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
update = reward.Grant switch
|
||||
{
|
||||
RzStoreGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
|
||||
RzStoreGrant.QBankResetToken => update.Inc(value => value.QBankResetTokens, 1),
|
||||
_ => update
|
||||
};
|
||||
var account = await _accounts.FindOneAndUpdateAsync(
|
||||
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
if (account is not null) return SpinResult(CreditOperationResult.Succeeded, account, spinEvent);
|
||||
|
||||
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
priorSpin = account.Spins.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (priorSpin is not null) return SpinResult(CreditOperationResult.Succeeded, account, priorSpin);
|
||||
if (account.Credits < SlotMachineEconomy.SpinCost)
|
||||
return SpinResult(CreditOperationResult.InsufficientCredits, account, null);
|
||||
return SpinResult(CreditOperationResult.RateLimited, account, null);
|
||||
}
|
||||
|
||||
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Gt(value => value.ExamTokens, 0) &
|
||||
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.ExamTokens, -1)
|
||||
.AddToSet(value => value.AppliedOperations, operationId)
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public Task RefundExamTokenAsync(string userId, string operationId) =>
|
||||
_accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.AnyEq(value => value.AppliedOperations, operationId),
|
||||
Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.ExamTokens, 1)
|
||||
.Pull(value => value.AppliedOperations, operationId)
|
||||
.Set(value => value.UpdatedUtc, _clock.GetUtcNow().UtcDateTime));
|
||||
|
||||
private async Task EnsureAndCleanAsync(string userId, DateTime now)
|
||||
{
|
||||
await _accounts.UpdateOneAsync(
|
||||
@@ -67,18 +219,82 @@ public sealed class CreditStore
|
||||
Builders<UserCreditAccount>.Update
|
||||
.SetOnInsert(value => value.UserId, userId)
|
||||
.SetOnInsert(value => value.Credits, 0)
|
||||
.SetOnInsert(value => value.ExamTokens, 1)
|
||||
.SetOnInsert(value => value.QBankResetTokens, 0)
|
||||
.SetOnInsert(value => value.BankCycle, 0)
|
||||
.SetOnInsert(value => value.InitialExamGrantApplied, true)
|
||||
.SetOnInsert(value => value.RecentEarnings, [])
|
||||
.SetOnInsert(value => value.Purchases, [])
|
||||
.SetOnInsert(value => value.Spins, [])
|
||||
.SetOnInsert(value => value.RecentSpins, [])
|
||||
.SetOnInsert(value => value.AppliedOperations, [])
|
||||
.SetOnInsert(value => value.UpdatedUtc, now),
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Ne(value => value.InitialExamGrantApplied, true),
|
||||
Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.ExamTokens, 1)
|
||||
.Set(value => value.InitialExamGrantApplied, true));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Spins), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.Spins, []));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.RecentSpins, []));
|
||||
await _accounts.UpdateOneAsync(
|
||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
|
||||
Builders<UserCreditAccount>.Update.Set(value => value.AppliedOperations, []));
|
||||
var cutoff = now - CreditEconomy.EarningWindow;
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserCreditAccount>.Update.PullFilter(
|
||||
value => value.RecentEarnings,
|
||||
earning => earning.CreatedUtc <= cutoff));
|
||||
var spinCutoff = now - SlotMachineEconomy.SpinWindow;
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserCreditAccount>.Update.PullFilter(
|
||||
value => value.RecentSpins,
|
||||
spin => spin.CreatedUtc <= spinCutoff));
|
||||
}
|
||||
|
||||
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
|
||||
{
|
||||
Credits = account.Credits,
|
||||
EarnedInRollingWindow = account.RecentEarnings.Count,
|
||||
RemainingEarningCapacity = Math.Max(
|
||||
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count),
|
||||
ExamTokens = account.ExamTokens,
|
||||
QBankResetTokens = account.QBankResetTokens,
|
||||
BankCycle = account.BankCycle
|
||||
};
|
||||
|
||||
private static CreditOperationResponse Response(
|
||||
CreditOperationResult result, UserCreditAccount account) => new()
|
||||
{
|
||||
Result = result,
|
||||
Status = ToStatus(account)
|
||||
};
|
||||
|
||||
private static SpinResponse SpinResult(
|
||||
CreditOperationResult result, UserCreditAccount account, SpinEvent? spinEvent) => new()
|
||||
{
|
||||
Result = result,
|
||||
RewardKey = spinEvent?.RewardKey ?? "",
|
||||
RewardLabel = spinEvent is null ? "" : SlotMachineEconomy.FindReward(spinEvent.RewardKey)?.Label ?? "",
|
||||
CoinsWon = spinEvent?.CoinsWon ?? 0,
|
||||
Status = ToStatus(account)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public class ExamSimulationStore
|
||||
private readonly IMongoCollection<Step3Outcome> _outcomes;
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly QuestionAttemptStore _attempts;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly AnswerStatisticsStore _statistics;
|
||||
private readonly RatingStore _ratings;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
@@ -22,6 +23,7 @@ public class ExamSimulationStore
|
||||
MongoDatabaseProvider mongo,
|
||||
QuestionStore questions,
|
||||
QuestionAttemptStore attempts,
|
||||
CreditStore credits,
|
||||
AnswerStatisticsStore statistics,
|
||||
RatingStore ratings,
|
||||
ServiceMetrics metrics,
|
||||
@@ -31,6 +33,7 @@ public class ExamSimulationStore
|
||||
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
_credits = credits;
|
||||
_statistics = statistics;
|
||||
_ratings = ratings;
|
||||
_metrics = metrics;
|
||||
@@ -68,7 +71,8 @@ public class ExamSimulationStore
|
||||
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
|
||||
if (bank.Count < RequiredQuestions)
|
||||
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required.");
|
||||
var seen = (await _attempts.GetUserQuestionIdsAsync(userId)).ToHashSet(StringComparer.Ordinal);
|
||||
var bankCycle = await _credits.GetBankCycleAsync(userId);
|
||||
var seen = (await _attempts.GetUserQuestionIdsAsync(userId, bankCycle)).ToHashSet(StringComparer.Ordinal);
|
||||
var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
|
||||
var targets = BuildTargets();
|
||||
var selected = SelectForm(bank, seen, targets);
|
||||
@@ -443,7 +447,10 @@ public class ExamSimulationStore
|
||||
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var choiceId = response?.ChoiceId ?? "";
|
||||
var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds);
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
item.QuestionExternalId, simulation.UserId, choiceId,
|
||||
choiceId == question.CorrectChoiceId, choiceId == "",
|
||||
item.PresentedChoiceIds, bankCycle: await _credits.GetBankCycleAsync(simulation.UserId));
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (!record.Inserted) continue;
|
||||
_metrics.AttemptCreated();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class QuestionAttemptCycleMigrationCommand
|
||||
{
|
||||
public const string Confirmation = "MIGRATE_ATTEMPT_CYCLES";
|
||||
public const string LegacyIndexName = "QuestionExternalId_1_UserId_1";
|
||||
public const string CycleIndexName = "uq_question_user_cycle";
|
||||
|
||||
public static async Task<int> RunAsync(
|
||||
IConfiguration configuration, string[] args, TextWriter output)
|
||||
{
|
||||
var apply = args.Contains("--apply", StringComparer.Ordinal);
|
||||
var confirmation = Option(args, "--confirm");
|
||||
var mongo = new MongoDatabaseProvider(configuration);
|
||||
var attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
var missingFilter = new BsonDocumentFilterDefinition<QuestionAttempt>(
|
||||
new BsonDocument(nameof(QuestionAttempt.BankCycle),
|
||||
new BsonDocument("$exists", false)));
|
||||
var missing = await attempts.CountDocumentsAsync(missingFilter);
|
||||
var indexes = (await attempts.Indexes.ListAsync()).ToList();
|
||||
var names = indexes.Select(value => value["name"].AsString).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
await output.WriteLineAsync(
|
||||
$"QuestionAttempts missing BankCycle: {missing}; legacy index: {names.Contains(LegacyIndexName)}; cycle index: {names.Contains(CycleIndexName)}.");
|
||||
if (!apply)
|
||||
{
|
||||
await output.WriteLineAsync(
|
||||
$"Dry run only. Re-run with --apply --confirm {Confirmation}.");
|
||||
return 0;
|
||||
}
|
||||
if (confirmation != Confirmation)
|
||||
{
|
||||
await output.WriteLineAsync($"Refusing migration: --confirm {Confirmation} is required.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (missing > 0)
|
||||
await attempts.UpdateManyAsync(
|
||||
missingFilter,
|
||||
Builders<QuestionAttempt>.Update.Set(value => value.BankCycle, 0));
|
||||
if (!names.Contains(CycleIndexName))
|
||||
await attempts.Indexes.CreateOneAsync(new CreateIndexModel<QuestionAttempt>(
|
||||
Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(value => value.QuestionExternalId)
|
||||
.Ascending(value => value.UserId)
|
||||
.Ascending(value => value.BankCycle),
|
||||
new CreateIndexOptions { Unique = true, Name = CycleIndexName }));
|
||||
if (names.Contains(LegacyIndexName))
|
||||
await attempts.Indexes.DropOneAsync(LegacyIndexName);
|
||||
|
||||
var remaining = await attempts.CountDocumentsAsync(missingFilter);
|
||||
await output.WriteLineAsync(
|
||||
$"Migration complete. Backfilled {missing}; missing BankCycle: {remaining}; dropped legacy index: {names.Contains(LegacyIndexName)}.");
|
||||
return remaining == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
private static string Option(string[] args, string name)
|
||||
{
|
||||
var index = Array.IndexOf(args, name);
|
||||
return index >= 0 && index + 1 < args.Length ? args[index + 1] : "";
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,11 @@ public class QuestionAttemptStore
|
||||
|
||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
.Ascending(a => a.UserId);
|
||||
.Ascending(a => a.UserId)
|
||||
.Ascending(a => a.BankCycle);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||
firstAttemptIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
|
||||
|
||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
@@ -38,12 +39,14 @@ public class QuestionAttemptStore
|
||||
bool isCorrect,
|
||||
bool isSkip = false,
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||
int durationSeconds = 0)
|
||||
int durationSeconds = 0,
|
||||
int bankCycle = 0)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
QuestionExternalId = questionExternalId,
|
||||
UserId = userId,
|
||||
BankCycle = bankCycle,
|
||||
ChoiceId = choiceId,
|
||||
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||
@@ -63,7 +66,8 @@ public class QuestionAttemptStore
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
var existing = await _col
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
|
||||
.Find(a => a.QuestionExternalId == questionExternalId &&
|
||||
a.UserId == userId && a.BankCycle == bankCycle)
|
||||
.FirstAsync();
|
||||
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
||||
}
|
||||
@@ -75,11 +79,17 @@ public class QuestionAttemptStore
|
||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null)
|
||||
{
|
||||
var filter = Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId);
|
||||
if (bankCycle.HasValue)
|
||||
filter &= Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value);
|
||||
return
|
||||
_col.Distinct(
|
||||
attempt => attempt.QuestionExternalId,
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
|
||||
filter)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 2v6.29a2 2 0 0 1-.5 1.32L3.9 17.8A2 2 0 0 0 5.4 21h13.2a2 2 0 0 0 1.5-3.2l-5.6-8.19A2 2 0 0 1 14 8.29V2" />
|
||||
<path d="M6.5 15h11" />
|
||||
<path d="M8.5 2h7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c2ud26rg3exo1"
|
||||
path="res://.godot/imported/flask-conical.svg-f4d9011dce33718bd3c2a0f719a8656a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/flask-conical.svg"
|
||||
dest_files=["res://.godot/imported/flask-conical.svg-f4d9011dce33718bd3c2a0f719a8656a.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 3h12l4 6-10 12L2 9Z" />
|
||||
<path d="M11 3 8 9l4 12 4-12-3-6" />
|
||||
<path d="M2 9h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://caty3viul0634"
|
||||
path="res://.godot/imported/gem.svg-33acfe3fd8cfb097f65cc061d34abd2e.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/gem.svg"
|
||||
dest_files=["res://.godot/imported/gem.svg-33acfe3fd8cfb097f65cc061d34abd2e.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cwt1kxtnhfgqo"
|
||||
path="res://.godot/imported/rotate-ccw.svg-72097dc869bb37466442f5645fc6d506.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/rotate-ccw.svg"
|
||||
dest_files=["res://.godot/imported/rotate-ccw.svg-72097dc869bb37466442f5645fc6d506.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://jwyvc10c44uu"
|
||||
path="res://.godot/imported/cabinet_frame.png-97e2b6084ba7b318c962ef3eba634aa6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/cabinet_frame.png"
|
||||
dest_files=["res://.godot/imported/cabinet_frame.png-97e2b6084ba7b318c962ef3eba634aa6.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cqrjyobyp5h45"
|
||||
path="res://.godot/imported/lever.png-08544c384e43fad84ca6f4c75fce0c0f.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/lever.png"
|
||||
dest_files=["res://.godot/imported/lever.png-08544c384e43fad84ca6f4c75fce0c0f.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bgp8erpmjoel7"
|
||||
path="res://.godot/imported/slot_coin.png-0cc970c575e374ae6106c943cfe43ee1.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_coin.png"
|
||||
dest_files=["res://.godot/imported/slot_coin.png-0cc970c575e374ae6106c943cfe43ee1.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bi1vl8p2q6bgn"
|
||||
path="res://.godot/imported/slot_coin_stack.png-f16acecc5a2cc4262b0bf33096e5a7d6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_coin_stack.png"
|
||||
dest_files=["res://.godot/imported/slot_coin_stack.png-f16acecc5a2cc4262b0bf33096e5a7d6.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dd00eb70k5wbn"
|
||||
path="res://.godot/imported/slot_gem.png-2012a867d096c962431a19f1d3e3a5c9.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_gem.png"
|
||||
dest_files=["res://.godot/imported/slot_gem.png-2012a867d096c962431a19f1d3e3a5c9.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dqmvdujjawr5f"
|
||||
path="res://.godot/imported/slot_gift.png-8717ecd0f5473181fb969b2aa00068db.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_gift.png"
|
||||
dest_files=["res://.godot/imported/slot_gift.png-8717ecd0f5473181fb969b2aa00068db.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 89 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c8r8uookc1ngx"
|
||||
path="res://.godot/imported/slot_potion.png-ecd877764f2c68367f1819752498d30a.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_potion.png"
|
||||
dest_files=["res://.godot/imported/slot_potion.png-ecd877764f2c68367f1819752498d30a.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://fsejopj5yup5"
|
||||
path="res://.godot/imported/slot_shield.png-f53f74d032415edb239232a0bab0bef4.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/slot_machine/slot_shield.png"
|
||||
dest_files=["res://.godot/imported/slot_shield.png-f53f74d032415edb239232a0bab0bef4.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -2,16 +2,28 @@ extends Node
|
||||
|
||||
signal credits_changed
|
||||
signal credits_failed(message: String)
|
||||
signal store_operation_completed(message: String)
|
||||
signal spin_resolved(reward_key: String, reward_label: String, coins_won: int)
|
||||
signal spin_config_loaded
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
|
||||
|
||||
var credits: int = 0
|
||||
var exam_tokens: int = 0
|
||||
var qbank_reset_tokens: int = 0
|
||||
var bank_cycle: int = 0
|
||||
var products: Array = []
|
||||
var spin_cost := 50
|
||||
var spin_rewards: Array = []
|
||||
var loaded := false
|
||||
var loading := false
|
||||
var request: HTTPRequest
|
||||
var action_request: HTTPRequest
|
||||
var spin_config_request: HTTPRequest
|
||||
var request_token := ""
|
||||
var api_url := DEFAULT_URL
|
||||
var pending_store_action := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -22,6 +34,14 @@ func _ready() -> void:
|
||||
request.timeout = 20.0
|
||||
request.request_completed.connect(_on_completed)
|
||||
add_child(request)
|
||||
action_request = HTTPRequest.new()
|
||||
action_request.timeout = 20.0
|
||||
action_request.request_completed.connect(_on_action_completed)
|
||||
add_child(action_request)
|
||||
spin_config_request = HTTPRequest.new()
|
||||
spin_config_request.timeout = 20.0
|
||||
spin_config_request.request_completed.connect(_on_spin_config_completed)
|
||||
add_child(spin_config_request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
|
||||
|
||||
@@ -39,6 +59,10 @@ func refresh() -> void:
|
||||
|
||||
func apply_status(parsed: Dictionary) -> void:
|
||||
credits = int(parsed.get("credits", credits))
|
||||
exam_tokens = int(parsed.get("examTokens", exam_tokens))
|
||||
qbank_reset_tokens = int(parsed.get("qBankResetTokens", qbank_reset_tokens))
|
||||
bank_cycle = int(parsed.get("bankCycle", bank_cycle))
|
||||
products = parsed.get("products", products)
|
||||
loaded = true
|
||||
credits_changed.emit()
|
||||
|
||||
@@ -58,9 +82,97 @@ func _on_completed(result: int, response_code: int, _headers: PackedStringArray,
|
||||
apply_status(parsed)
|
||||
|
||||
|
||||
func purchase(sku: String) -> void:
|
||||
pending_store_action = "purchase"
|
||||
_post("/purchases", {"sku": sku, "operationId": _operation_id("purchase")})
|
||||
|
||||
|
||||
func reset_qbank() -> void:
|
||||
pending_store_action = "reset"
|
||||
_post("/qbank-reset", {"confirmation": "RESET_QBANK", "operationId": _operation_id("reset")})
|
||||
|
||||
|
||||
func spin() -> void:
|
||||
pending_store_action = "spin"
|
||||
_post("/spin", {"operationId": _operation_id("spin")})
|
||||
|
||||
|
||||
func fetch_spin_config() -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
|
||||
spin_config_request.request(api_url + "/spin-config", headers, HTTPClient.METHOD_GET)
|
||||
|
||||
|
||||
func _on_spin_config_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return
|
||||
spin_cost = int(parsed.get("spinCost", spin_cost))
|
||||
spin_rewards = parsed.get("rewards", spin_rewards)
|
||||
spin_config_loaded.emit()
|
||||
|
||||
|
||||
func _post(path: String, payload: Dictionary) -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
pending_store_action = ""
|
||||
credits_failed.emit("You're signed out. Sign back in and try again.")
|
||||
return
|
||||
var headers := PackedStringArray([
|
||||
"Authorization: Bearer %s" % AuthState.access_token,
|
||||
"Content-Type: application/json"
|
||||
])
|
||||
var error := action_request.request(api_url + path, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if error != OK:
|
||||
credits_failed.emit("The Rz Coin Store could not complete that action.")
|
||||
|
||||
|
||||
func _on_action_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
credits_failed.emit("The Rz Coin Store could not complete that action.")
|
||||
return
|
||||
if response_code >= 200 and response_code < 300 and typeof(parsed) == TYPE_DICTIONARY:
|
||||
if pending_store_action == "spin":
|
||||
var status: Variant = parsed.get("status", {})
|
||||
if typeof(status) == TYPE_DICTIONARY:
|
||||
apply_status(status)
|
||||
pending_store_action = ""
|
||||
spin_resolved.emit(
|
||||
str(parsed.get("rewardKey", "")),
|
||||
str(parsed.get("rewardLabel", "")),
|
||||
int(parsed.get("coinsWon", 0)))
|
||||
return
|
||||
apply_status(parsed)
|
||||
if pending_store_action == "reset":
|
||||
ProfileService.reset_qbank_eligibility()
|
||||
pending_store_action = ""
|
||||
store_operation_completed.emit("Your Rz Coin Store balance was updated.")
|
||||
return
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
var status: Variant = parsed.get("credits", {})
|
||||
if typeof(status) == TYPE_DICTIONARY:
|
||||
apply_status(status)
|
||||
credits_failed.emit(str(parsed.get("message", "The store action could not be completed.")))
|
||||
else:
|
||||
credits_failed.emit("The store action could not be completed.")
|
||||
pending_store_action = ""
|
||||
|
||||
|
||||
func _operation_id(prefix: String) -> String:
|
||||
return "%s:%s:%d" % [prefix, str(AuthState.username), Time.get_ticks_usec()]
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
credits = 0
|
||||
exam_tokens = 0
|
||||
qbank_reset_tokens = 0
|
||||
bank_cycle = 0
|
||||
products = []
|
||||
spin_rewards = []
|
||||
loaded = false
|
||||
loading = false
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
|
||||
@@ -8,6 +8,7 @@ signal state_changed
|
||||
signal request_failed(message: String)
|
||||
signal outcome_changed
|
||||
signal history_changed
|
||||
signal exam_token_required
|
||||
|
||||
var api_url := DEFAULT_API_URL
|
||||
var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes"
|
||||
@@ -22,6 +23,7 @@ var is_loading := false
|
||||
var pending_action := ""
|
||||
var request_queue: Array = []
|
||||
var request_access_token := ""
|
||||
var last_failed_action := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -140,6 +142,7 @@ func _send_absolute(action: String, method: int, url: String, payload: Dictionar
|
||||
if err != OK:
|
||||
is_loading = false
|
||||
pending_action = ""
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation request could not be started.")
|
||||
|
||||
|
||||
@@ -152,11 +155,20 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
AuthState.handle_unauthorized(request_access_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
var server_message := body.get_string_from_utf8().strip_edges()
|
||||
var error_parsed: Variant = JSON.parse_string(server_message)
|
||||
if typeof(error_parsed) == TYPE_DICTIONARY:
|
||||
var credit_status: Variant = error_parsed.get("credits", {})
|
||||
if typeof(credit_status) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_status(credit_status)
|
||||
if str(error_parsed.get("code", "")) == "exam_token_required":
|
||||
exam_token_required.emit()
|
||||
server_message = str(error_parsed.get("message", server_message))
|
||||
if server_message.length() > 240:
|
||||
server_message = server_message.left(240)
|
||||
var message := "Simulation request failed with status %d." % response_code
|
||||
if not server_message.is_empty():
|
||||
message = "%s %s" % [message, server_message]
|
||||
last_failed_action = action
|
||||
request_failed.emit(message)
|
||||
return
|
||||
if response_code == 204:
|
||||
@@ -169,6 +181,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
last_failed_action = action
|
||||
request_failed.emit("The simulation API returned an invalid response.")
|
||||
return
|
||||
if action == "readiness":
|
||||
|
||||
@@ -130,6 +130,14 @@ func reset_qbank_memory() -> void:
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func reset_qbank_eligibility() -> void:
|
||||
_ensure_tracking_fields()
|
||||
profile["completed_question_ids"] = []
|
||||
profile["correct_question_ids"] = []
|
||||
save_profile()
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func apply_question_results(results: Dictionary) -> void:
|
||||
_ensure_daily_current()
|
||||
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
extends PanelContainer
|
||||
|
||||
signal store_requested
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg")
|
||||
const STAR_ICON := preload("res://assets/icons/lucide/star.svg")
|
||||
@@ -21,6 +22,9 @@ var _initialized := false
|
||||
|
||||
func _ready() -> void:
|
||||
size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
tooltip_text = "Open Rz Coin Store"
|
||||
gui_input.connect(_on_gui_input)
|
||||
_build_ui()
|
||||
refresh_theme_controls()
|
||||
_on_credits_changed()
|
||||
@@ -30,6 +34,11 @@ func _ready() -> void:
|
||||
resized.connect(func(): pivot_offset = size * 0.5)
|
||||
|
||||
|
||||
func _on_gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||
store_requested.emit()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 7)
|
||||
|
||||
@@ -207,6 +207,7 @@ func _build_ui() -> void:
|
||||
header.add_child(account_chevron_button)
|
||||
|
||||
credits_badge = CreditsBadge.new()
|
||||
credits_badge.store_requested.connect(func(): show_screen("rz_store"))
|
||||
header.add_child(credits_badge)
|
||||
|
||||
var screens_container := MarginContainer.new()
|
||||
@@ -355,6 +356,11 @@ func _build_ui() -> void:
|
||||
donations.main = self
|
||||
screens_container.add_child(donations)
|
||||
screens["donations"] = donations
|
||||
var rz_store := preload("res://scripts/ui/rz_store_screen.gd").new()
|
||||
rz_store.name = "RzCoinStore"
|
||||
rz_store.main = self
|
||||
screens_container.add_child(rz_store)
|
||||
screens["rz_store"] = rz_store
|
||||
|
||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||
feedback_dialog.name = "AppFeedbackDialog"
|
||||
@@ -433,6 +439,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
||||
container.add_child(HSeparator.new())
|
||||
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
|
||||
_add_nav_button(container, "performance", "Profile", "user-round", true)
|
||||
_add_nav_button(container, "rz_store", "RZ Shop", "star", true)
|
||||
_add_nav_button(container, "settings", "Settings", "settings", true)
|
||||
_add_nav_button(container, "logout", "Log out", "log-out", true)
|
||||
|
||||
@@ -476,6 +483,7 @@ func _on_navigation_pressed(key: String) -> void:
|
||||
"notes": show_screen("notes")
|
||||
"bookmarks": show_screen("bookmarks")
|
||||
"donations": show_screen("donations")
|
||||
"rz_store": show_screen("rz_store")
|
||||
"logout": _logout()
|
||||
|
||||
|
||||
|
||||
@@ -29,4 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
||||
key = "bookmarks"
|
||||
elif screen_name == "donations":
|
||||
key = "donations"
|
||||
elif screen_name == "rz_store":
|
||||
key = "rz_store"
|
||||
return key
|
||||
|
||||
@@ -4,6 +4,7 @@ const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
||||
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
|
||||
const SlotMachinePanel := preload("res://scripts/ui/rz_slot_machine_screen.gd")
|
||||
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
|
||||
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
|
||||
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
|
||||
@@ -60,6 +61,8 @@ var stem_plain_text := ""
|
||||
var explanation_plain_text := ""
|
||||
var highlight_drag_source := ""
|
||||
var timer_color_state := ""
|
||||
var last_timer_bar_value := -1.0
|
||||
var last_timer_display_seconds := -1
|
||||
var pending_correct_advance := false
|
||||
var question_text_scale := 1.0
|
||||
var presented_choices: Array = []
|
||||
@@ -92,6 +95,7 @@ var text_smaller_button: Button
|
||||
var text_larger_button: Button
|
||||
var labs_button: Button
|
||||
var calculator_button: Button
|
||||
var slot_machine_button: Button
|
||||
var exit_button: Button
|
||||
var clear_highlight_button: Button
|
||||
var flag_button: Button
|
||||
@@ -125,6 +129,7 @@ var labs_overlay: Control
|
||||
var labs_panel: PanelContainer
|
||||
var labs_content: VBoxContainer
|
||||
var calculator_panel
|
||||
var slot_machine_panel
|
||||
var report_overlay: Control
|
||||
var report_panel: PanelContainer
|
||||
var report_form_content: VBoxContainer
|
||||
@@ -345,6 +350,18 @@ func _ready() -> void:
|
||||
UISoundService.set_button_sound(calculator_button, "open")
|
||||
calculator_button.pressed.connect(_toggle_calculator)
|
||||
|
||||
slot_machine_button = Button.new()
|
||||
slot_machine_button.name = "SlotMachineButton"
|
||||
slot_machine_button.tooltip_text = "Open RZ Slot Machine"
|
||||
slot_machine_button.icon = preload("res://assets/icons/lucide/gem.svg")
|
||||
slot_machine_button.custom_minimum_size = Vector2(44, 44)
|
||||
slot_machine_button.add_theme_constant_override("icon_max_width", 22)
|
||||
slot_machine_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
slot_machine_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
slot_machine_button.focus_mode = Control.FOCUS_NONE
|
||||
UISoundService.set_button_sound(slot_machine_button, "open")
|
||||
slot_machine_button.pressed.connect(_toggle_slot_machine)
|
||||
|
||||
labs_button = Button.new()
|
||||
labs_button.tooltip_text = "Review lab results"
|
||||
labs_button.custom_minimum_size = Vector2(44, 44)
|
||||
@@ -430,6 +447,7 @@ func _ready() -> void:
|
||||
question_toolbar.add_child(text_smaller_button)
|
||||
question_toolbar.add_child(text_larger_button)
|
||||
question_toolbar.add_child(calculator_button)
|
||||
question_toolbar.add_child(slot_machine_button)
|
||||
question_toolbar.add_child(labs_button)
|
||||
question_toolbar.add_child(clear_highlight_button)
|
||||
question_toolbar.add_child(flag_button)
|
||||
@@ -723,6 +741,9 @@ func _ready() -> void:
|
||||
_build_labs_modal()
|
||||
calculator_panel = CalculatorPanel.new()
|
||||
add_child(calculator_panel)
|
||||
slot_machine_panel = SlotMachinePanel.new()
|
||||
slot_machine_panel.main = main
|
||||
add_child(slot_machine_panel)
|
||||
_build_report_modal()
|
||||
_build_media_viewer()
|
||||
resized.connect(_fit_modal_panels)
|
||||
@@ -808,10 +829,10 @@ func _refresh_responsive_layout() -> void:
|
||||
header_toolbar_host.reparent(target_parent)
|
||||
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
|
||||
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
# 12 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
|
||||
# rank/ELO + points badge, with 8px gaps between all 14 - anything less
|
||||
# 13 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
|
||||
# rank/ELO + points badge, with 8px gaps between all 15 - anything less
|
||||
# than this wraps onto a second row.
|
||||
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 960.0
|
||||
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 1020.0
|
||||
if size.x < 560.0:
|
||||
nav_palette.columns = 5
|
||||
else:
|
||||
@@ -1067,7 +1088,7 @@ func _apply_question_theme() -> void:
|
||||
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
||||
if navigator_heading != null:
|
||||
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
||||
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, slot_machine_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
|
||||
if control == null:
|
||||
continue
|
||||
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
|
||||
@@ -1083,6 +1104,8 @@ func _apply_question_theme() -> void:
|
||||
_style_theme_toggle_button(dark)
|
||||
if calculator_panel != null:
|
||||
calculator_panel.refresh_theme_controls()
|
||||
if slot_machine_panel != null:
|
||||
slot_machine_panel.refresh_theme_controls()
|
||||
if credits_badge != null:
|
||||
credits_badge.refresh_theme_controls()
|
||||
if skip_button != null:
|
||||
@@ -1133,6 +1156,8 @@ func on_hide() -> void:
|
||||
if calculator_panel != null:
|
||||
calculator_panel.close_panel()
|
||||
calculator_panel.reset_calculation()
|
||||
if slot_machine_panel != null:
|
||||
slot_machine_panel.close_panel()
|
||||
|
||||
|
||||
func _save_simulation_current_response() -> void:
|
||||
@@ -1168,6 +1193,11 @@ func _on_simulation_request_failed(message: String) -> void:
|
||||
if BlockSessionService.session_kind != "simulation":
|
||||
return
|
||||
simulation_submit_pending = false
|
||||
if ExamSimulationService.last_failed_action != "submit":
|
||||
# A per-question autosave failing shouldn't interrupt a timed exam with a
|
||||
# blocking "Retry" - the next answer/navigation naturally retries it, and
|
||||
# the final submit sends the complete, authoritative state regardless.
|
||||
return
|
||||
next_button.disabled = false
|
||||
next_button.text = "Retry"
|
||||
next_button.tooltip_text = message
|
||||
@@ -1268,6 +1298,8 @@ func _load_question() -> void:
|
||||
flagged = false
|
||||
selected_choice = ""
|
||||
current_answer = {}
|
||||
last_timer_bar_value = -1.0
|
||||
last_timer_display_seconds = -1
|
||||
flag_button.button_pressed = false
|
||||
_refresh_toolbar_state()
|
||||
_clear_answer_review()
|
||||
@@ -2094,7 +2126,10 @@ func _update_timer_bar() -> void:
|
||||
return
|
||||
var is_core_timer := BlockSessionService.session_kind == "core"
|
||||
var ratio := BlockSessionService.get_session_timer_ratio()
|
||||
timer_bar.value = ratio * 100.0
|
||||
var next_value := ratio * 100.0
|
||||
if last_timer_bar_value < 0.0 or absf(next_value - last_timer_bar_value) >= 0.1:
|
||||
last_timer_bar_value = next_value
|
||||
timer_bar.value = next_value
|
||||
if is_core_timer and timer_label.visible:
|
||||
_set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())))
|
||||
var color_state := "blue"
|
||||
@@ -2114,6 +2149,9 @@ func _set_timer_display(total_seconds: int) -> void:
|
||||
if timer_label == null:
|
||||
return
|
||||
var safe_seconds := maxi(0, total_seconds)
|
||||
if safe_seconds == last_timer_display_seconds:
|
||||
return
|
||||
last_timer_display_seconds = safe_seconds
|
||||
timer_label.text = "%02d:%02d" % [int(floor(float(safe_seconds) / 60.0)), safe_seconds % 60]
|
||||
|
||||
|
||||
@@ -2373,6 +2411,10 @@ func _refresh_toolbar_state() -> void:
|
||||
bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question")
|
||||
if calculator_button != null:
|
||||
calculator_button.visible = true
|
||||
if slot_machine_button != null:
|
||||
slot_machine_button.visible = Brand.is_uat_environment()
|
||||
if credits_badge != null:
|
||||
credits_badge.visible = BlockSessionService.session_kind != "simulation"
|
||||
_apply_question_theme()
|
||||
|
||||
|
||||
@@ -2381,6 +2423,11 @@ func _toggle_calculator() -> void:
|
||||
calculator_panel.toggle_panel()
|
||||
|
||||
|
||||
func _toggle_slot_machine() -> void:
|
||||
if slot_machine_panel != null:
|
||||
slot_machine_panel.toggle_panel()
|
||||
|
||||
|
||||
func _toggle_bookmark_current_question() -> void:
|
||||
var question := BlockSessionService.get_current_question()
|
||||
if not question.is_empty():
|
||||
@@ -3383,7 +3430,9 @@ func _style_choice_button_for_state(index: int, choice_id: String) -> void:
|
||||
var dark: bool = true if theme_service == null else theme_service.is_dark_mode()
|
||||
var state := "default"
|
||||
var correct_id := str(BlockSessionService.get_current_question().get("correct_choice_id", ""))
|
||||
var reviewing := pending_correct_advance or not current_answer.is_empty()
|
||||
# Exam simulations don't reveal right/wrong - you move on after submitting,
|
||||
# same as a real exam - so only "selected"/"eliminated" styling applies here.
|
||||
var reviewing := BlockSessionService.session_kind != "simulation" and (pending_correct_advance or not current_answer.is_empty())
|
||||
if reviewing and choice_id == correct_id:
|
||||
state = "correct"
|
||||
elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id != "" and choice_id != correct_id):
|
||||
@@ -3505,6 +3554,7 @@ func _build_followup_panel(_index: int, choice_id: String) -> void:
|
||||
"selected_option_text": "",
|
||||
"was_correct": false,
|
||||
"resolved": false,
|
||||
"collapsed": false,
|
||||
"has_authored_choices": bool(followup.get("has_authored_choices", false)),
|
||||
"is_main_correct": false
|
||||
}
|
||||
@@ -3576,6 +3626,7 @@ func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_se
|
||||
state["selected_option_text"] = selected_option_text
|
||||
state["was_correct"] = is_correct
|
||||
state["resolved"] = true
|
||||
state["collapsed"] = false
|
||||
followup_states[resolved_choice_id] = state
|
||||
_render_followup_state(resolved_choice_id)
|
||||
|
||||
@@ -3602,6 +3653,8 @@ func _render_followup_state(choice_id: String) -> void:
|
||||
_style_followup_card(index, state)
|
||||
if not bool(state.get("resolved", false)):
|
||||
_render_active_followup(index, state)
|
||||
elif bool(state.get("collapsed", false)):
|
||||
_build_resolved_followup_toggle(index, state, false)
|
||||
else:
|
||||
_build_resolved_followup(index, state)
|
||||
choice_followup_panels[index].visible = true
|
||||
@@ -3610,13 +3663,95 @@ func _render_followup_state(choice_id: String) -> void:
|
||||
|
||||
func _build_resolved_followup(index: int, state: Dictionary) -> void:
|
||||
var content := choice_followup_contents[index]
|
||||
_build_resolved_followup_toggle(index, state, true)
|
||||
|
||||
var prompt := Label.new()
|
||||
prompt.name = "FollowupPrompt"
|
||||
prompt.text = str(state.get("prompt", "What finding in the stem makes this answer less appropriate?"))
|
||||
prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_set_scaled_label(prompt, 14, Brand.text_color(_followup_is_dark()))
|
||||
content.add_child(prompt)
|
||||
|
||||
var selected_id := str(state.get("selected_option_id", ""))
|
||||
var correct_id := str(state.get("correct_choice_id", ""))
|
||||
var options: Array = state.get("choices", [])
|
||||
for option_index in range(options.size()):
|
||||
var option = options[option_index]
|
||||
if typeof(option) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var option_id := str(option.get("id", ""))
|
||||
var option_button := Button.new()
|
||||
option_button.name = "FollowupOption_%s" % option_id
|
||||
option_button.text = "%s. %s" % [_display_label_for_index(option_index), str(option.get("text", ""))]
|
||||
option_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
option_button.disabled = true
|
||||
option_button.custom_minimum_size = Vector2(0, 38)
|
||||
_set_scaled_button_font(option_button, 14)
|
||||
_style_review_followup_option(
|
||||
option_button,
|
||||
_followup_is_dark(),
|
||||
option_id,
|
||||
selected_id,
|
||||
correct_id,
|
||||
true
|
||||
)
|
||||
content.add_child(option_button)
|
||||
|
||||
var explanation := Label.new()
|
||||
explanation.name = "FollowupExplanation"
|
||||
explanation.text = _strip_verdict_prefix(_choice_review_explanation(str(state.get("choice_id", "")), state))
|
||||
explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark()))
|
||||
content.add_child(explanation)
|
||||
|
||||
|
||||
func _build_resolved_followup_toggle(index: int, state: Dictionary, expanded: bool) -> void:
|
||||
var content := choice_followup_contents[index]
|
||||
var dark := _followup_is_dark()
|
||||
var has_authored_choices := bool(state.get("has_authored_choices", not state.get("choices", []).is_empty()))
|
||||
var was_correct := bool(state.get("was_correct", false))
|
||||
var accent := Brand.SUCCESS if was_correct else Color("fb5f6d") if has_authored_choices else Brand.PURPLE
|
||||
var row := HBoxContainer.new()
|
||||
row.name = "FollowupResult"
|
||||
row.add_theme_constant_override("separation", 8)
|
||||
content.add_child(row)
|
||||
|
||||
var result := Label.new()
|
||||
result.name = "FollowupVerdict"
|
||||
result.text = ("Correct" if was_correct else "Incorrect") if has_authored_choices else "Review complete"
|
||||
result.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_set_scaled_label(result, 14, accent)
|
||||
row.add_child(result)
|
||||
|
||||
var toggle := Button.new()
|
||||
toggle.name = "FollowupToggle"
|
||||
toggle.flat = true
|
||||
toggle.text = "Minimize" if expanded else "Expand"
|
||||
toggle.tooltip_text = "Minimize follow-up review" if expanded else "Expand follow-up review"
|
||||
toggle.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg")
|
||||
toggle.icon_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
toggle.custom_minimum_size = Vector2(110, 30)
|
||||
toggle.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
toggle.add_theme_color_override("font_hover_color", accent)
|
||||
toggle.add_theme_color_override("icon_normal_color", Brand.text_color(dark))
|
||||
toggle.add_theme_color_override("icon_hover_color", accent)
|
||||
_set_scaled_button_font(toggle, 13)
|
||||
toggle.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", ""))))
|
||||
row.add_child(toggle)
|
||||
|
||||
|
||||
func _toggle_followup_state(choice_id: String) -> void:
|
||||
if not followup_states.has(choice_id):
|
||||
return
|
||||
var state: Dictionary = followup_states[choice_id]
|
||||
if not bool(state.get("resolved", false)):
|
||||
return
|
||||
state["collapsed"] = not bool(state.get("collapsed", false))
|
||||
followup_states[choice_id] = state
|
||||
_render_followup_state(choice_id)
|
||||
_apply_saved_choice_state()
|
||||
|
||||
|
||||
func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContainer:
|
||||
var body := PanelContainer.new()
|
||||
body.add_theme_stylebox_override("panel", _followup_body_box(_followup_is_dark(), is_correct))
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
extends PanelContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const CABINET_FRAME := preload("res://assets/slot_machine/cabinet_frame.png")
|
||||
const LEVER_TEXTURE := preload("res://assets/slot_machine/lever.png")
|
||||
const SLOT_COIN := preload("res://assets/slot_machine/slot_coin.png")
|
||||
const SLOT_GEM := preload("res://assets/slot_machine/slot_gem.png")
|
||||
const SLOT_POTION := preload("res://assets/slot_machine/slot_potion.png")
|
||||
const SLOT_SHIELD := preload("res://assets/slot_machine/slot_shield.png")
|
||||
const SLOT_COIN_STACK := preload("res://assets/slot_machine/slot_coin_stack.png")
|
||||
const SLOT_GIFT := preload("res://assets/slot_machine/slot_gift.png")
|
||||
const CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg")
|
||||
const REEL_SYMBOLS := [SLOT_COIN, SLOT_GEM, SLOT_POTION, SLOT_SHIELD, SLOT_COIN_STACK, SLOT_GIFT]
|
||||
# fraction-of-image rect of the cabinet's empty reel window, measured against cabinet_frame.png
|
||||
const WINDOW_RECT := Rect2(0.203, 0.246, 0.591, 0.604)
|
||||
const REWARD_ICONS := {
|
||||
"jackpot": {"texture": SLOT_COIN, "modulate": false},
|
||||
"epic": {"texture": SLOT_GEM, "modulate": false},
|
||||
"great": {"texture": SLOT_SHIELD, "modulate": false},
|
||||
"exam_token": {"texture": CLOCK_ICON, "modulate": true},
|
||||
"qbank_reset": {"texture": SLOT_GIFT, "modulate": false},
|
||||
"nice": {"texture": SLOT_COIN_STACK, "modulate": false},
|
||||
"good_luck": {"texture": SLOT_COIN, "modulate": false, "tint": true},
|
||||
}
|
||||
const MACHINE_SIZE := 380.0
|
||||
const CELL := 71.0
|
||||
const REEL_SEPARATION := 4
|
||||
|
||||
var main: Control
|
||||
|
||||
var drag_active := false
|
||||
var drag_offset := Vector2.ZERO
|
||||
var has_been_positioned := false
|
||||
|
||||
var machine_root: Control
|
||||
var lever_button: TextureButton
|
||||
var reel_states: Array = []
|
||||
var status_label: Label
|
||||
|
||||
var _spinning := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "RzSlotMachinePanel"
|
||||
visible = false
|
||||
top_level = true
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
custom_minimum_size = Vector2(440, 470)
|
||||
size = custom_minimum_size
|
||||
|
||||
_build_ui()
|
||||
|
||||
if not get_viewport().size_changed.is_connected(_on_viewport_size_changed):
|
||||
get_viewport().size_changed.connect(_on_viewport_size_changed)
|
||||
_on_viewport_size_changed()
|
||||
|
||||
CreditsService.credits_changed.connect(_refresh_spin_button)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls())
|
||||
refresh_theme_controls()
|
||||
_refresh_spin_button()
|
||||
|
||||
|
||||
func open_panel() -> void:
|
||||
visible = true
|
||||
move_to_front()
|
||||
if not has_been_positioned:
|
||||
var viewport_size := get_viewport_rect().size
|
||||
position = Vector2(maxf(12.0, viewport_size.x - size.x - 24.0), 72.0)
|
||||
has_been_positioned = true
|
||||
_clamp_to_viewport()
|
||||
CreditsService.refresh()
|
||||
CreditsService.fetch_spin_config()
|
||||
_refresh_spin_button()
|
||||
|
||||
|
||||
func close_panel() -> void:
|
||||
visible = false
|
||||
drag_active = false
|
||||
|
||||
|
||||
func toggle_panel() -> void:
|
||||
if visible:
|
||||
close_panel()
|
||||
else:
|
||||
open_panel()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
add_child(margin)
|
||||
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
margin.add_child(content)
|
||||
|
||||
_build_title_bar(content)
|
||||
_build_machine(content)
|
||||
|
||||
status_label = Label.new()
|
||||
status_label.add_theme_font_override("font", Brand.font)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
content.add_child(status_label)
|
||||
|
||||
|
||||
func _build_title_bar(content: VBoxContainer) -> void:
|
||||
var title_bar := HBoxContainer.new()
|
||||
title_bar.name = "SlotMachineTitleBar"
|
||||
title_bar.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
title_bar.custom_minimum_size.y = 34
|
||||
title_bar.gui_input.connect(_on_title_bar_gui_input)
|
||||
content.add_child(title_bar)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
title_bar.add_child(spacer)
|
||||
|
||||
var close := Button.new()
|
||||
close.name = "SlotMachineClose"
|
||||
close.text = "×"
|
||||
close.tooltip_text = "Close slot machine"
|
||||
close.custom_minimum_size = Vector2(36, 32)
|
||||
close.focus_mode = Control.FOCUS_NONE
|
||||
close.pressed.connect(close_panel)
|
||||
title_bar.add_child(close)
|
||||
|
||||
|
||||
func _build_machine(parent: Control) -> void:
|
||||
var center := CenterContainer.new()
|
||||
parent.add_child(center)
|
||||
|
||||
machine_root = Control.new()
|
||||
machine_root.custom_minimum_size = Vector2(MACHINE_SIZE, MACHINE_SIZE)
|
||||
center.add_child(machine_root)
|
||||
|
||||
var frame := TextureRect.new()
|
||||
frame.texture = CABINET_FRAME
|
||||
frame.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
frame.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
frame.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT
|
||||
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
machine_root.add_child(frame)
|
||||
|
||||
var window := Control.new()
|
||||
window.anchor_left = WINDOW_RECT.position.x
|
||||
window.anchor_top = WINDOW_RECT.position.y
|
||||
window.anchor_right = WINDOW_RECT.position.x + WINDOW_RECT.size.x
|
||||
window.anchor_bottom = WINDOW_RECT.position.y + WINDOW_RECT.size.y
|
||||
window.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
machine_root.add_child(window)
|
||||
|
||||
var reel_center := CenterContainer.new()
|
||||
reel_center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
window.add_child(reel_center)
|
||||
|
||||
var reels_grid := HBoxContainer.new()
|
||||
reels_grid.add_theme_constant_override("separation", REEL_SEPARATION)
|
||||
reel_center.add_child(reels_grid)
|
||||
|
||||
reel_states.clear()
|
||||
for _column_index in range(3):
|
||||
var clip := Control.new()
|
||||
clip.clip_contents = true
|
||||
clip.custom_minimum_size = Vector2(CELL, CELL * 3)
|
||||
reels_grid.add_child(clip)
|
||||
|
||||
var strip := VBoxContainer.new()
|
||||
strip.add_theme_constant_override("separation", 0)
|
||||
clip.add_child(strip)
|
||||
|
||||
var icons: Array = []
|
||||
for _row_index in range(4):
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
|
||||
icon.custom_minimum_size = Vector2(CELL, CELL)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
strip.add_child(icon)
|
||||
icons.append(icon)
|
||||
|
||||
reel_states.append({
|
||||
"strip": strip,
|
||||
"icons": icons,
|
||||
"step": CELL,
|
||||
"speed": 420.0,
|
||||
"spinning": false,
|
||||
"stop_requested": false,
|
||||
"target_texture": null,
|
||||
"recycles_after_target": -1,
|
||||
"done": true,
|
||||
})
|
||||
|
||||
lever_button = TextureButton.new()
|
||||
lever_button.texture_normal = LEVER_TEXTURE
|
||||
lever_button.ignore_texture_size = true
|
||||
lever_button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
|
||||
lever_button.anchor_left = 0.80
|
||||
lever_button.anchor_right = 1.06
|
||||
lever_button.anchor_top = 0.24
|
||||
lever_button.anchor_bottom = 0.88
|
||||
lever_button.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
lever_button.tooltip_text = "Pull to spin"
|
||||
lever_button.resized.connect(func(): lever_button.pivot_offset = Vector2(lever_button.size.x * 0.5, lever_button.size.y * 0.92))
|
||||
lever_button.pressed.connect(_on_lever_pulled)
|
||||
machine_root.add_child(lever_button)
|
||||
|
||||
|
||||
func refresh_theme_controls() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||||
|
||||
|
||||
func _refresh_spin_button() -> void:
|
||||
if lever_button == null:
|
||||
return
|
||||
lever_button.disabled = _spinning or CreditsService.credits < CreditsService.spin_cost
|
||||
|
||||
|
||||
func _on_lever_pulled() -> void:
|
||||
_play_lever_pull_animation()
|
||||
_on_spin_pressed()
|
||||
|
||||
|
||||
func _play_lever_pull_animation() -> void:
|
||||
var tween := create_tween()
|
||||
tween.tween_property(lever_button, "rotation_degrees", -20.0, 0.1).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
|
||||
tween.tween_property(lever_button, "rotation_degrees", 0.0, 0.3).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
|
||||
|
||||
|
||||
func _on_spin_pressed() -> void:
|
||||
if _spinning:
|
||||
return
|
||||
_spinning = true
|
||||
_refresh_spin_button()
|
||||
await _do_one_spin()
|
||||
_spinning = false
|
||||
_refresh_spin_button()
|
||||
|
||||
|
||||
func _do_one_spin() -> bool:
|
||||
for state in reel_states:
|
||||
state["stop_requested"] = false
|
||||
state["target_texture"] = null
|
||||
state["recycles_after_target"] = -1
|
||||
state["done"] = false
|
||||
_spin_reel(state)
|
||||
|
||||
var result := await _await_spin_result()
|
||||
|
||||
if not result.get("ok", false):
|
||||
for state in reel_states:
|
||||
state["target_texture"] = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
|
||||
state["stop_requested"] = true
|
||||
await _await_reels_done()
|
||||
status_label.text = str(result.get("message", "The spin could not be completed."))
|
||||
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
|
||||
return false
|
||||
|
||||
var reward_key := str(result.get("reward_key", ""))
|
||||
var reward_texture := _icon_for_reward(reward_key)
|
||||
for index in range(reel_states.size()):
|
||||
var state: Dictionary = reel_states[index]
|
||||
get_tree().create_timer(index * 0.25).timeout.connect(func():
|
||||
state["target_texture"] = reward_texture
|
||||
state["stop_requested"] = true)
|
||||
await _await_reels_done()
|
||||
|
||||
var reward_label := str(result.get("reward_label", ""))
|
||||
var coins_won := int(result.get("coins_won", 0))
|
||||
status_label.text = "You won: %s" % reward_label
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
if coins_won > 0:
|
||||
_play_coin_burst(coins_won)
|
||||
elif main != null:
|
||||
var credits_badge: Object = main.get("credits_badge")
|
||||
if credits_badge != null:
|
||||
credits_badge.bump()
|
||||
return true
|
||||
|
||||
|
||||
func _icon_for_reward(key: String) -> Texture2D:
|
||||
var spec: Dictionary = REWARD_ICONS.get(key, {})
|
||||
var texture: Texture2D = spec.get("texture", SLOT_COIN)
|
||||
return texture if texture != null else SLOT_COIN
|
||||
|
||||
|
||||
func _await_reels_done() -> void:
|
||||
while true:
|
||||
var all_done := true
|
||||
for state in reel_states:
|
||||
if not state.get("done", false):
|
||||
all_done = false
|
||||
break
|
||||
if all_done:
|
||||
return
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
func _await_spin_result() -> Dictionary:
|
||||
var result := {}
|
||||
var on_resolved := func(reward_key: String, reward_label: String, coins_won: int):
|
||||
result.merge({"ok": true, "reward_key": reward_key, "reward_label": reward_label, "coins_won": coins_won}, true)
|
||||
var on_failed := func(message: String):
|
||||
result.merge({"ok": false, "message": message}, true)
|
||||
CreditsService.spin_resolved.connect(on_resolved, CONNECT_ONE_SHOT)
|
||||
CreditsService.credits_failed.connect(on_failed, CONNECT_ONE_SHOT)
|
||||
CreditsService.spin()
|
||||
var deadline := Time.get_ticks_msec() + 20000
|
||||
while result.is_empty() and Time.get_ticks_msec() < deadline:
|
||||
await get_tree().process_frame
|
||||
if result.is_empty():
|
||||
result = {"ok": false, "message": "The spin timed out. Check your connection and try again."}
|
||||
if CreditsService.spin_resolved.is_connected(on_resolved):
|
||||
CreditsService.spin_resolved.disconnect(on_resolved)
|
||||
if CreditsService.credits_failed.is_connected(on_failed):
|
||||
CreditsService.credits_failed.disconnect(on_failed)
|
||||
return result
|
||||
|
||||
|
||||
func _spin_reel(state: Dictionary) -> void:
|
||||
state["spinning"] = true
|
||||
var strip: VBoxContainer = state["strip"]
|
||||
var icons: Array = state["icons"]
|
||||
var step: float = state["step"]
|
||||
var speed: float = state["speed"]
|
||||
while true:
|
||||
var current_speed: float = speed if state["recycles_after_target"] == -1 else speed * 0.45
|
||||
strip.position.y -= current_speed * get_process_delta_time()
|
||||
if strip.position.y <= -step:
|
||||
strip.position.y += step
|
||||
var front: TextureRect = icons.pop_front()
|
||||
strip.move_child(front, strip.get_child_count() - 1)
|
||||
icons.append(front)
|
||||
if state["stop_requested"] and state["recycles_after_target"] == -1:
|
||||
front.texture = state["target_texture"]
|
||||
state["recycles_after_target"] = 0
|
||||
else:
|
||||
front.texture = REEL_SYMBOLS[randi() % REEL_SYMBOLS.size()]
|
||||
if state["recycles_after_target"] >= 0:
|
||||
state["recycles_after_target"] += 1
|
||||
if state["recycles_after_target"] == 2:
|
||||
strip.position.y = 0.0
|
||||
break
|
||||
await get_tree().process_frame
|
||||
state["spinning"] = false
|
||||
state["done"] = true
|
||||
|
||||
|
||||
func _play_coin_burst(coins_won: int) -> void:
|
||||
if main == null:
|
||||
return
|
||||
var credits_badge: Control = main.get("credits_badge")
|
||||
if credits_badge == null or not is_instance_valid(credits_badge):
|
||||
return
|
||||
var origin := machine_root.get_global_rect().get_center()
|
||||
var target := credits_badge.get_global_rect().get_center()
|
||||
var coin_count := clampi(3 + coins_won / 500, 4, 10)
|
||||
var flight_time := 0.55
|
||||
var stagger := 0.035
|
||||
for index in range(coin_count):
|
||||
var coin := TextureRect.new()
|
||||
coin.texture = SLOT_COIN
|
||||
coin.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
coin.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
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 is_instance_valid(credits_badge):
|
||||
credits_badge.bump())
|
||||
|
||||
|
||||
func _on_title_bar_gui_input(event: InputEvent) -> void:
|
||||
var touch_event := event as InputEventScreenTouch
|
||||
if touch_event != null:
|
||||
drag_active = touch_event.pressed
|
||||
if drag_active:
|
||||
drag_offset = touch_event.position
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
var touch_drag := event as InputEventScreenDrag
|
||||
if touch_drag != null and drag_active:
|
||||
global_position += touch_drag.relative
|
||||
_clamp_to_viewport()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
var mouse_event := event as InputEventMouseButton
|
||||
if mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_LEFT:
|
||||
drag_active = mouse_event.pressed
|
||||
if drag_active:
|
||||
drag_offset = mouse_event.global_position - global_position
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
var motion := event as InputEventMouseMotion
|
||||
if motion != null and drag_active:
|
||||
global_position = motion.global_position - drag_offset
|
||||
_clamp_to_viewport()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _on_viewport_size_changed() -> void:
|
||||
_clamp_to_viewport()
|
||||
|
||||
|
||||
func _clamp_to_viewport() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
position.x = clampf(position.x, 12.0, maxf(12.0, viewport_size.x - size.x - 12.0))
|
||||
position.y = clampf(position.y, 12.0, maxf(12.0, viewport_size.y - size.y - 12.0))
|
||||
@@ -0,0 +1 @@
|
||||
uid://col1hauyr27rn
|
||||
@@ -0,0 +1,549 @@
|
||||
extends VBoxContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const ICON_BY_SKU := {
|
||||
"exam_token_1": "clock-3",
|
||||
"qbank_reset_1": "rotate-ccw",
|
||||
}
|
||||
const FEATURES_BY_SKU := {
|
||||
"exam_token_1": ["Full 6-block exam", "Timed & scored", "Performance analytics"],
|
||||
"qbank_reset_1": ["Reset eligibility", "Keep your stats", "Smart spaced retention"],
|
||||
}
|
||||
const POPULAR_SKUS := ["exam_token_1"]
|
||||
const TAB_DEFS := [
|
||||
{"label": "Featured", "icon": "star", "active": true},
|
||||
{"label": "Tokens", "icon": "clipboard-list", "active": false},
|
||||
{"label": "Boosts", "icon": "activity", "active": false},
|
||||
{"label": "Content", "icon": "book-open", "active": false},
|
||||
{"label": "Customization", "icon": "settings", "active": false},
|
||||
{"label": "Bundles", "icon": "clipboard-plus", "active": false},
|
||||
]
|
||||
|
||||
var main: Control
|
||||
var subtitle_label: Label
|
||||
var summary_card: PanelContainer
|
||||
var summary_icon_panel: PanelContainer
|
||||
var summary_icon: TextureRect
|
||||
var balance_value_label: Label
|
||||
var coin_caption_label: Label
|
||||
var inventory_label: Label
|
||||
var tab_controls: Array = []
|
||||
var section_heading: Label
|
||||
var section_subtitle: Label
|
||||
var products_grid: HFlowContainer
|
||||
var status_label: Label
|
||||
var confirm_dialog: ConfirmationDialog
|
||||
var footer_card: PanelContainer
|
||||
var footer_icon_panel: PanelContainer
|
||||
var footer_icon: TextureRect
|
||||
var footer_heading: Label
|
||||
var footer_description: Label
|
||||
var pending_sku := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 18)
|
||||
|
||||
_build_header()
|
||||
_build_summary_card()
|
||||
_build_tabs()
|
||||
|
||||
section_heading = Label.new()
|
||||
section_heading.text = "Featured"
|
||||
section_heading.add_theme_font_override("font", Brand.font)
|
||||
section_heading.add_theme_font_size_override("font_size", 18)
|
||||
add_child(section_heading)
|
||||
section_subtitle = Label.new()
|
||||
section_subtitle.text = "Hand-picked items to supercharge your Step 3 prep."
|
||||
section_subtitle.add_theme_font_override("font", Brand.font)
|
||||
section_subtitle.add_theme_font_size_override("font_size", 12)
|
||||
section_subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
add_child(section_subtitle)
|
||||
|
||||
products_grid = HFlowContainer.new()
|
||||
products_grid.add_theme_constant_override("h_separation", 14)
|
||||
products_grid.add_theme_constant_override("v_separation", 14)
|
||||
add_child(products_grid)
|
||||
|
||||
status_label = Label.new()
|
||||
status_label.add_theme_font_override("font", Brand.font)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(status_label)
|
||||
|
||||
confirm_dialog = ConfirmationDialog.new()
|
||||
confirm_dialog.confirmed.connect(_confirm_purchase)
|
||||
add_child(confirm_dialog)
|
||||
|
||||
_build_footer()
|
||||
|
||||
CreditsService.credits_changed.connect(_refresh)
|
||||
CreditsService.credits_failed.connect(_show_error)
|
||||
CreditsService.store_operation_completed.connect(_show_message)
|
||||
UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_theme_controls(); _refresh())
|
||||
_refresh()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
CreditsService.refresh()
|
||||
_refresh()
|
||||
|
||||
|
||||
func _build_header() -> void:
|
||||
var title := Label.new()
|
||||
title.text = "RZ Coin Shop"
|
||||
title.add_theme_font_override("font", Brand.font)
|
||||
title.add_theme_font_size_override("font_size", 30)
|
||||
add_child(title)
|
||||
subtitle_label = Label.new()
|
||||
subtitle_label.text = "Spend your RZ Coins on premium content and boosts."
|
||||
subtitle_label.add_theme_font_override("font", Brand.font)
|
||||
subtitle_label.add_theme_font_size_override("font_size", 14)
|
||||
subtitle_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
add_child(subtitle_label)
|
||||
|
||||
|
||||
func _build_summary_card() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
summary_card = PanelContainer.new()
|
||||
summary_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
|
||||
summary_card.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
add_child(summary_card)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
summary_card.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(row)
|
||||
|
||||
summary_icon_panel = PanelContainer.new()
|
||||
summary_icon_panel.custom_minimum_size = Vector2(40, 40)
|
||||
summary_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
summary_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 20))
|
||||
summary_icon = TextureRect.new()
|
||||
summary_icon.texture = load("res://assets/icons/lucide/star.svg")
|
||||
summary_icon.custom_minimum_size = Vector2(22, 22)
|
||||
summary_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
summary_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
summary_icon.modulate = Brand.AMBER
|
||||
summary_icon_panel.add_child(summary_icon)
|
||||
row.add_child(summary_icon_panel)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
copy.add_theme_constant_override("separation", 0)
|
||||
balance_value_label = Label.new()
|
||||
balance_value_label.add_theme_font_override("font", Brand.font)
|
||||
balance_value_label.add_theme_font_size_override("font_size", 22)
|
||||
balance_value_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
copy.add_child(balance_value_label)
|
||||
coin_caption_label = Label.new()
|
||||
coin_caption_label.text = "RZ Coins"
|
||||
coin_caption_label.add_theme_font_override("font", Brand.font)
|
||||
coin_caption_label.add_theme_font_size_override("font_size", 12)
|
||||
coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
copy.add_child(coin_caption_label)
|
||||
inventory_label = Label.new()
|
||||
inventory_label.add_theme_font_override("font", Brand.font)
|
||||
inventory_label.add_theme_font_size_override("font_size", 12)
|
||||
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
copy.add_child(inventory_label)
|
||||
row.add_child(copy)
|
||||
|
||||
|
||||
func _build_tabs() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
tab_controls.clear()
|
||||
|
||||
var tabs_row := HFlowContainer.new()
|
||||
tabs_row.add_theme_constant_override("h_separation", 20)
|
||||
tabs_row.add_theme_constant_override("v_separation", 8)
|
||||
add_child(tabs_row)
|
||||
|
||||
for tab_def_variant in TAB_DEFS:
|
||||
var tab_def: Dictionary = tab_def_variant
|
||||
var tab := VBoxContainer.new()
|
||||
tab.add_theme_constant_override("separation", 6)
|
||||
tabs_row.add_child(tab)
|
||||
|
||||
var content := HBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 6)
|
||||
tab.add_child(content)
|
||||
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/%s.svg" % str(tab_def.get("icon", "star")))
|
||||
icon.custom_minimum_size = Vector2(16, 16)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
content.add_child(icon)
|
||||
|
||||
var label := Label.new()
|
||||
label.text = str(tab_def.get("label", ""))
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
content.add_child(label)
|
||||
|
||||
var underline := ColorRect.new()
|
||||
underline.custom_minimum_size = Vector2(0, 2)
|
||||
tab.add_child(underline)
|
||||
|
||||
var active := bool(tab_def.get("active", false))
|
||||
tab_controls.append({"icon": icon, "label": label, "underline": underline, "active": active})
|
||||
|
||||
var divider := ColorRect.new()
|
||||
divider.custom_minimum_size = Vector2(0, 1)
|
||||
add_child(divider)
|
||||
tab_controls.append({"divider": divider})
|
||||
|
||||
_apply_tab_theme(dark)
|
||||
|
||||
|
||||
func _apply_tab_theme(dark: bool) -> void:
|
||||
for entry_variant in tab_controls:
|
||||
var entry: Dictionary = entry_variant
|
||||
if entry.has("divider"):
|
||||
(entry["divider"] as ColorRect).color = Color("2a365a") if dark else Brand.LIGHT_BORDER
|
||||
continue
|
||||
var active := bool(entry["active"])
|
||||
var color := Brand.ROYAL_BLUE if active else Brand.muted_color(dark)
|
||||
(entry["icon"] as TextureRect).modulate = color
|
||||
(entry["label"] as Label).add_theme_color_override("font_color", color)
|
||||
(entry["underline"] as ColorRect).color = Brand.ROYAL_BLUE if active else Color(0, 0, 0, 0)
|
||||
|
||||
|
||||
func _build_footer() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
footer_card = PanelContainer.new()
|
||||
footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
|
||||
add_child(footer_card)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
footer_card.add_child(margin)
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 14)
|
||||
margin.add_child(row)
|
||||
|
||||
footer_icon_panel = PanelContainer.new()
|
||||
footer_icon_panel.custom_minimum_size = Vector2(36, 36)
|
||||
footer_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))
|
||||
footer_icon = TextureRect.new()
|
||||
footer_icon.texture = load("res://assets/icons/lucide/activity.svg")
|
||||
footer_icon.custom_minimum_size = Vector2(18, 18)
|
||||
footer_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
footer_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
footer_icon.modulate = Brand.ROYAL_BLUE
|
||||
footer_icon_panel.add_child(footer_icon)
|
||||
row.add_child(footer_icon_panel)
|
||||
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
copy.add_theme_constant_override("separation", 2)
|
||||
footer_heading = Label.new()
|
||||
footer_heading.text = "How to earn RZ Coins"
|
||||
footer_heading.add_theme_font_override("font", Brand.font)
|
||||
footer_heading.add_theme_font_size_override("font_size", 15)
|
||||
footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
copy.add_child(footer_heading)
|
||||
footer_description = Label.new()
|
||||
footer_description.text = "Answer questions correctly on the first try, within the time limit."
|
||||
footer_description.add_theme_font_override("font", Brand.font)
|
||||
footer_description.add_theme_font_size_override("font_size", 12)
|
||||
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
footer_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.add_child(footer_description)
|
||||
row.add_child(copy)
|
||||
|
||||
|
||||
func _refresh_theme_controls() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
subtitle_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
summary_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER))
|
||||
summary_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 20))
|
||||
balance_value_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
_apply_tab_theme(dark)
|
||||
section_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
|
||||
footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))
|
||||
footer_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
footer_description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
balance_value_label.text = _format_thousands(CreditsService.credits)
|
||||
inventory_label.text = "%d exam token(s) · %d QBank reset token(s)" % [
|
||||
CreditsService.exam_tokens, CreditsService.qbank_reset_tokens]
|
||||
for child in products_grid.get_children():
|
||||
child.queue_free()
|
||||
for product_variant in CreditsService.products:
|
||||
if typeof(product_variant) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var product: Dictionary = product_variant
|
||||
var sku := str(product.get("sku", ""))
|
||||
var price := int(product.get("coinPrice", 0))
|
||||
products_grid.add_child(_build_item_card(
|
||||
dark,
|
||||
ICON_BY_SKU.get(sku, "star"),
|
||||
Brand.ROYAL_BLUE,
|
||||
str(product.get("name", "Store item")),
|
||||
str(product.get("description", "")),
|
||||
FEATURES_BY_SKU.get(sku, []),
|
||||
POPULAR_SKUS.has(sku),
|
||||
price,
|
||||
"Buy",
|
||||
CreditsService.credits < price,
|
||||
_ask_purchase.bind(sku, str(product.get("name", "item")), price)))
|
||||
if CreditsService.qbank_reset_tokens > 0:
|
||||
products_grid.add_child(_build_item_card(
|
||||
dark,
|
||||
"rotate-ccw",
|
||||
Brand.SUCCESS,
|
||||
"Use QBank Reset Token",
|
||||
"Question eligibility resets; ELO, history, bookmarks, notes, reviews, and earning limits are preserved.",
|
||||
FEATURES_BY_SKU.get("qbank_reset_1", []),
|
||||
false,
|
||||
-1,
|
||||
"Use token",
|
||||
false,
|
||||
_ask_reset))
|
||||
|
||||
|
||||
func _build_item_card(
|
||||
dark: bool,
|
||||
icon_name: String,
|
||||
accent: Color,
|
||||
title: String,
|
||||
description_text: String,
|
||||
features: Array,
|
||||
popular: bool,
|
||||
price: int,
|
||||
button_text: String,
|
||||
button_disabled: bool,
|
||||
on_press: Callable
|
||||
) -> PanelContainer:
|
||||
var card := _new_card(dark)
|
||||
var content: VBoxContainer = card.get_child(0).get_child(0)
|
||||
|
||||
var top_row := HBoxContainer.new()
|
||||
content.add_child(top_row)
|
||||
top_row.add_child(_card_icon(dark, icon_name, accent))
|
||||
var badge_spacer := Control.new()
|
||||
badge_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
top_row.add_child(badge_spacer)
|
||||
if popular:
|
||||
top_row.add_child(_popular_badge())
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = title
|
||||
name_label.add_theme_font_override("font", Brand.font)
|
||||
name_label.add_theme_font_size_override("font_size", 16)
|
||||
name_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
content.add_child(name_label)
|
||||
|
||||
var description := Label.new()
|
||||
description.text = description_text
|
||||
description.add_theme_font_override("font", Brand.font)
|
||||
description.add_theme_font_size_override("font_size", 12)
|
||||
description.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
description.custom_minimum_size = Vector2(190, 0)
|
||||
content.add_child(description)
|
||||
|
||||
if not features.is_empty():
|
||||
var bullets := VBoxContainer.new()
|
||||
bullets.add_theme_constant_override("separation", 4)
|
||||
content.add_child(bullets)
|
||||
for feature_text in features:
|
||||
bullets.add_child(_feature_row(dark, accent, str(feature_text)))
|
||||
|
||||
var divider := ColorRect.new()
|
||||
divider.custom_minimum_size = Vector2(0, 1)
|
||||
divider.color = Color("2a365a") if dark else Brand.LIGHT_BORDER
|
||||
content.add_child(divider)
|
||||
|
||||
var footer_row := HBoxContainer.new()
|
||||
footer_row.add_theme_constant_override("separation", 8)
|
||||
content.add_child(footer_row)
|
||||
if price >= 0:
|
||||
footer_row.add_child(_price_tag(dark, price))
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
footer_row.add_child(spacer)
|
||||
var button := Button.new()
|
||||
button.text = button_text
|
||||
button.custom_minimum_size = Vector2(64, 34)
|
||||
button.add_theme_font_override("font", Brand.font)
|
||||
_style_buy_button(button)
|
||||
button.disabled = button_disabled
|
||||
button.pressed.connect(on_press)
|
||||
footer_row.add_child(button)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
func _new_card(dark: bool) -> PanelContainer:
|
||||
var card := PanelContainer.new()
|
||||
card.custom_minimum_size = Vector2(230, 0)
|
||||
card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_top", 14)
|
||||
margin.add_theme_constant_override("margin_bottom", 14)
|
||||
card.add_child(margin)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(content)
|
||||
return card
|
||||
|
||||
|
||||
func _card_icon(dark: bool, icon_name: String, accent: Color) -> PanelContainer:
|
||||
var icon_panel := PanelContainer.new()
|
||||
icon_panel.custom_minimum_size = Vector2(48, 48)
|
||||
icon_panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 24))
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
||||
icon.custom_minimum_size = Vector2(24, 24)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = accent
|
||||
icon_panel.add_child(icon)
|
||||
return icon_panel
|
||||
|
||||
|
||||
func _popular_badge() -> PanelContainer:
|
||||
var badge := PanelContainer.new()
|
||||
badge.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = Brand.ROYAL_BLUE
|
||||
box.set_corner_radius_all(6)
|
||||
box.content_margin_left = 8
|
||||
box.content_margin_right = 8
|
||||
box.content_margin_top = 3
|
||||
box.content_margin_bottom = 3
|
||||
badge.add_theme_stylebox_override("panel", box)
|
||||
var label := Label.new()
|
||||
label.text = "Popular"
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 11)
|
||||
label.add_theme_color_override("font_color", Brand.DARK_TEXT)
|
||||
badge.add_child(label)
|
||||
return badge
|
||||
|
||||
|
||||
func _feature_row(dark: bool, accent: Color, text: String) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 6)
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/circle-check.svg")
|
||||
icon.custom_minimum_size = Vector2(14, 14)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = accent
|
||||
row.add_child(icon)
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", 12)
|
||||
label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
row.add_child(label)
|
||||
return row
|
||||
|
||||
|
||||
func _price_tag(dark: bool, price: int) -> HBoxContainer:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 4)
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/star.svg")
|
||||
icon.custom_minimum_size = Vector2(14, 14)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = Brand.AMBER
|
||||
row.add_child(icon)
|
||||
var label := Label.new()
|
||||
label.text = _format_thousands(price)
|
||||
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(dark))
|
||||
row.add_child(label)
|
||||
return row
|
||||
|
||||
|
||||
func _style_buy_button(button: Button) -> void:
|
||||
var normal := StyleBoxFlat.new()
|
||||
normal.bg_color = Brand.ROYAL_BLUE
|
||||
normal.set_corner_radius_all(8)
|
||||
var hover := StyleBoxFlat.new()
|
||||
hover.bg_color = Brand.SKY_BLUE
|
||||
hover.set_corner_radius_all(8)
|
||||
var disabled := StyleBoxFlat.new()
|
||||
disabled.bg_color = Color(Brand.ROYAL_BLUE, 0.35)
|
||||
disabled.set_corner_radius_all(8)
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", normal)
|
||||
button.add_theme_stylebox_override("disabled", disabled)
|
||||
button.add_theme_color_override("font_color", Brand.DARK_TEXT)
|
||||
button.add_theme_color_override("font_hover_color", Brand.DARK_TEXT)
|
||||
button.add_theme_color_override("font_disabled_color", Color(Brand.DARK_TEXT, 0.7))
|
||||
|
||||
|
||||
func _ask_purchase(sku: String, product_name: String, price: int) -> void:
|
||||
pending_sku = sku
|
||||
confirm_dialog.title = "Confirm purchase"
|
||||
confirm_dialog.dialog_text = "Buy %s for %d Rz Coins?" % [product_name, price]
|
||||
confirm_dialog.popup_centered()
|
||||
|
||||
|
||||
func _confirm_purchase() -> void:
|
||||
if pending_sku == "__reset__":
|
||||
CreditsService.reset_qbank()
|
||||
elif not pending_sku.is_empty():
|
||||
CreditsService.purchase(pending_sku)
|
||||
pending_sku = ""
|
||||
|
||||
|
||||
func _ask_reset() -> void:
|
||||
pending_sku = "__reset__"
|
||||
confirm_dialog.title = "Reset QBank eligibility"
|
||||
confirm_dialog.dialog_text = "Make all QBank questions eligible again? ELO, answer history, bookmarks, notes, reviews, and the rolling coin cap will be preserved."
|
||||
confirm_dialog.popup_centered()
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
|
||||
|
||||
|
||||
func _show_message(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
_refresh()
|
||||
|
||||
|
||||
func _format_thousands(value: int) -> String:
|
||||
var digits := str(absi(value))
|
||||
var grouped := ""
|
||||
var count := 0
|
||||
for index in range(digits.length() - 1, -1, -1):
|
||||
grouped = digits[index] + grouped
|
||||
count += 1
|
||||
if count % 3 == 0 and index != 0:
|
||||
grouped = "," + grouped
|
||||
return ("-" if value < 0 else "") + grouped
|
||||
@@ -0,0 +1 @@
|
||||
uid://cldpnvs2k6r3t
|
||||
@@ -137,6 +137,8 @@ func _connect_services() -> void:
|
||||
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
|
||||
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
|
||||
ExamSimulationService.request_failed.connect(_on_request_failed)
|
||||
if not ExamSimulationService.exam_token_required.is_connected(_on_exam_token_required):
|
||||
ExamSimulationService.exam_token_required.connect(_on_exam_token_required)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
@@ -538,6 +540,11 @@ func _on_request_failed(message: String) -> void:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_exam_token_required() -> void:
|
||||
pending_launch = false
|
||||
main.show_screen("rz_store")
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 940
|
||||
if compact_layout == use_compact:
|
||||
|
||||
@@ -13,7 +13,7 @@ func _initialize() -> void:
|
||||
_test_eliminated_choice_uses_rich_text_strikeout()
|
||||
_test_eliminated_choice_that_is_also_locked_wrong_still_strikes_through()
|
||||
_test_explanation_highlighting_has_no_status_heading()
|
||||
_test_resolved_followup_has_no_header_or_verdict_prefix()
|
||||
_test_resolved_followup_stays_expanded_and_toggles()
|
||||
_test_review_content_normalization()
|
||||
_test_followup_attempt_order_is_stable()
|
||||
_test_review_followup_is_optional_and_single_attempt()
|
||||
@@ -22,6 +22,7 @@ func _initialize() -> void:
|
||||
_test_feedback_context_excludes_study_content()
|
||||
_test_feedback_form_validation()
|
||||
_test_revisitable_navigation_prefers_unanswered_questions()
|
||||
_test_timer_text_avoids_redundant_frame_updates()
|
||||
_test_submit_styles_keep_stable_geometry()
|
||||
_test_core_timer_counts_up_and_remains_visible()
|
||||
await _test_player_runtime_construction()
|
||||
@@ -177,7 +178,7 @@ func _test_explanation_highlighting_has_no_status_heading() -> void:
|
||||
player.free()
|
||||
|
||||
|
||||
func _test_resolved_followup_has_no_header_or_verdict_prefix() -> void:
|
||||
func _test_resolved_followup_stays_expanded_and_toggles() -> void:
|
||||
var player = load("res://scripts/ui/question_player.gd").new()
|
||||
var panel := PanelContainer.new()
|
||||
var content := VBoxContainer.new()
|
||||
@@ -191,17 +192,38 @@ func _test_resolved_followup_has_no_header_or_verdict_prefix() -> void:
|
||||
player.presented_choices = [{"id": "B", "text": "Wrong answer"}]
|
||||
player.followup_states["B"] = {
|
||||
"choice_id": "B", "choice_text": "Wrong answer", "prompt": "Why?",
|
||||
"selected_option_text": "Finding", "was_correct": false,
|
||||
"teaching_point": "Incorrect. Review the finding.", "resolved": true
|
||||
"choices": [{"id": "A", "text": "Selected finding"}, {"id": "B", "text": "Correct finding"}],
|
||||
"correct_choice_id": "B", "selected_option_id": "A",
|
||||
"selected_option_text": "A. Selected finding", "was_correct": false,
|
||||
"teaching_point": "Incorrect. Review the finding.", "resolved": true, "collapsed": false,
|
||||
"has_authored_choices": true
|
||||
}
|
||||
player._render_followup_state("B")
|
||||
_assert(content.find_child("FollowupToggle", true, false) == null, "a resolved follow-up should not render a collapse toggle")
|
||||
var explanation_text := ""
|
||||
for child in content.get_children():
|
||||
if child is Label:
|
||||
explanation_text += child.text
|
||||
_assert(not explanation_text.contains("Why this is"), "a resolved follow-up should not repeat a 'Why this is...' heading")
|
||||
_assert(not explanation_text.begins_with("Incorrect."), "a resolved follow-up explanation should not repeat the verdict prefix")
|
||||
var toggle := content.find_child("FollowupToggle", true, false) as Button
|
||||
_assert(toggle != null, "a resolved follow-up should render a minimize toggle")
|
||||
_assert(toggle != null and toggle.text == "Minimize", "a newly resolved follow-up should remain expanded")
|
||||
_assert(content.find_child("FollowupPrompt", true, false) != null, "the resolved follow-up should retain its prompt")
|
||||
_assert(content.find_child("FollowupOption_A", true, false) != null, "the resolved follow-up should retain its selected option")
|
||||
_assert(content.find_child("FollowupOption_B", true, false) != null, "the resolved follow-up should retain its correct option")
|
||||
var verdict := content.find_child("FollowupVerdict", true, false) as Label
|
||||
_assert(verdict != null and verdict.text == "Incorrect", "the resolved follow-up should show whether the answer was correct")
|
||||
if toggle != null:
|
||||
toggle.pressed.emit()
|
||||
_assert(bool(player.followup_states["B"].get("collapsed", false)), "the minimize toggle should collapse the follow-up")
|
||||
_assert(content.find_child("FollowupPrompt", true, false) == null, "a collapsed follow-up should hide its prompt")
|
||||
toggle = content.find_child("FollowupToggle", true, false) as Button
|
||||
_assert(toggle != null and toggle.text == "Expand", "a collapsed follow-up should render an expand toggle")
|
||||
if toggle != null:
|
||||
toggle.pressed.emit()
|
||||
_assert(not bool(player.followup_states["B"].get("collapsed", true)), "the expand toggle should restore the follow-up")
|
||||
_assert(content.find_child("FollowupPrompt", true, false) != null, "expanding should restore the follow-up prompt")
|
||||
var correct_state: Dictionary = player.followup_states["B"]
|
||||
correct_state["selected_option_id"] = "B"
|
||||
correct_state["was_correct"] = true
|
||||
player.followup_states["B"] = correct_state
|
||||
player._render_followup_state("B")
|
||||
verdict = content.find_child("FollowupVerdict", true, false) as Label
|
||||
_assert(verdict != null and verdict.text == "Correct", "a correct follow-up response should show a correct verdict")
|
||||
player.choice_buttons[0].free()
|
||||
player.choice_percent_labels[0].free()
|
||||
player.choice_top_labels[0].free()
|
||||
@@ -355,6 +377,20 @@ func _test_revisitable_navigation_prefers_unanswered_questions() -> void:
|
||||
session.current_index = prior_index
|
||||
|
||||
|
||||
func _test_timer_text_avoids_redundant_frame_updates() -> void:
|
||||
var player = load("res://scripts/ui/question_player.gd").new()
|
||||
player.timer_label = Label.new()
|
||||
player._set_timer_display(65)
|
||||
_assert(player.timer_label.text == "01:05", "timer should render a changed second")
|
||||
player.timer_label.text = "unchanged"
|
||||
player._set_timer_display(65)
|
||||
_assert(player.timer_label.text == "unchanged", "timer should not rewrite its label every frame")
|
||||
player._set_timer_display(66)
|
||||
_assert(player.timer_label.text == "01:06", "timer should render the next changed second")
|
||||
player.timer_label.free()
|
||||
player.free()
|
||||
|
||||
|
||||
func _test_player_runtime_construction() -> void:
|
||||
var host := Control.new()
|
||||
root.add_child(host)
|
||||
|
||||