diff --git a/ENVIRONMENTS.md b/ENVIRONMENTS.md index 9fc5f31..f8e73d3 100644 --- a/ENVIRONMENTS.md +++ b/ENVIRONMENTS.md @@ -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 diff --git a/api/QuestionsApi.Tests/CreditEconomyTests.cs b/api/QuestionsApi.Tests/CreditEconomyTests.cs index c63772b..d1a2959 100644 --- a/api/QuestionsApi.Tests/CreditEconomyTests.cs +++ b/api/QuestionsApi.Tests/CreditEconomyTests.cs @@ -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")); } } diff --git a/api/QuestionsApi/Controllers/CreditsController.cs b/api/QuestionsApi/Controllers/CreditsController.cs index 198fe60..f1bc69a 100644 --- a/api/QuestionsApi/Controllers/CreditsController.cs +++ b/api/QuestionsApi/Controllers/CreditsController.cs @@ -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 Get() => Ok(await credits.GetStatusAsync(UserId())); + [HttpPost("purchases")] + public async Task 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 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 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; } diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index 60d52be..e7286c4 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -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 Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); @@ -17,12 +17,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro [HttpPost] public async Task 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")] diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 50b18d6..5489da7 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -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 diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs index 50f4aea..6a663ae 100644 --- a/api/QuestionsApi/Models/CreditModels.cs +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -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 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 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 RecentEarnings { get; set; } = []; + public List Purchases { get; set; } = []; + public List Spins { get; set; } = []; + public List RecentSpins { get; set; } = []; + public List 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 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 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(); } diff --git a/api/QuestionsApi/Models/QuestionAttempt.cs b/api/QuestionsApi/Models/QuestionAttempt.cs index 691c708..9fefbc8 100644 --- a/api/QuestionsApi/Models/QuestionAttempt.cs +++ b/api/QuestionsApi/Models/QuestionAttempt.cs @@ -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 PresentedChoiceIds { get; set; } = []; public bool TracksPresentation { get; set; } diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 7daeda7..c511ed3 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -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(options => diff --git a/api/QuestionsApi/Services/CreditStore.cs b/api/QuestionsApi/Services/CreditStore.cs index 5e6ee86..2a71fe1 100644 --- a/api/QuestionsApi/Services/CreditStore.cs +++ b/api/QuestionsApi/Services/CreditStore.cs @@ -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 GetBankCycleAsync(string userId) + { + await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); + return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle; + } + + public async Task 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.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Gte(value => value.Credits, product.CoinPrice) & + Builders.Filter.Not( + Builders.Filter.ElemMatch( + value => value.Purchases, purchase => purchase.OperationId == operationId)); + var update = Builders.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 + { + 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 ApplyQBankResetAsync(string userId, string operationId) + { + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var filter = Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Gt(value => value.QBankResetTokens, 0) & + Builders.Filter.AnyNin(value => value.AppliedOperations, [operationId]); + var update = Builders.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 + { + 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 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( + new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray + { + new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })), + SlotMachineEconomy.RollingSpinLimit + }))); + var filter = Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) & + Builders.Filter.Not( + Builders.Filter.ElemMatch( + value => value.Spins, spin => spin.OperationId == operationId)) & + belowSpinCap; + var update = Builders.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 + { + 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 TryConsumeExamTokenAsync(string userId, string operationId) + { + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var filter = Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Gt(value => value.ExamTokens, 0) & + Builders.Filter.AnyNin(value => value.AppliedOperations, [operationId]); + var update = Builders.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.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.AnyEq(value => value.AppliedOperations, operationId), + Builders.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.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.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Ne(value => value.InitialExamGrantApplied, true), + Builders.Update + .Inc(value => value.ExamTokens, 1) + .Set(value => value.InitialExamGrantApplied, true)); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false), Builders.Update.Set(value => value.RecentEarnings, [])); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.Purchases), false), + Builders.Update.Set(value => value.Purchases, [])); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.Spins), false), + Builders.Update.Set(value => value.Spins, [])); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false), + Builders.Update.Set(value => value.RecentSpins, [])); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false), + Builders.Update.Set(value => value.AppliedOperations, [])); var cutoff = now - CreditEconomy.EarningWindow; await _accounts.UpdateOneAsync( value => value.UserId == userId, Builders.Update.PullFilter( value => value.RecentEarnings, earning => earning.CreatedUtc <= cutoff)); + var spinCutoff = now - SlotMachineEconomy.SpinWindow; + await _accounts.UpdateOneAsync( + value => value.UserId == userId, + Builders.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) + }; } diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index 27ea73e..037ab15 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -13,6 +13,7 @@ public class ExamSimulationStore private readonly IMongoCollection _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("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(); diff --git a/api/QuestionsApi/Services/QuestionAttemptCycleMigrationCommand.cs b/api/QuestionsApi/Services/QuestionAttemptCycleMigrationCommand.cs new file mode 100644 index 0000000..aa4377e --- /dev/null +++ b/api/QuestionsApi/Services/QuestionAttemptCycleMigrationCommand.cs @@ -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 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("QuestionAttempts"); + var missingFilter = new BsonDocumentFilterDefinition( + 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.Update.Set(value => value.BankCycle, 0)); + if (!names.Contains(CycleIndexName)) + await attempts.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.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] : ""; + } +} diff --git a/api/QuestionsApi/Services/QuestionAttemptStore.cs b/api/QuestionsApi/Services/QuestionAttemptStore.cs index c2921d2..54ef52f 100644 --- a/api/QuestionsApi/Services/QuestionAttemptStore.cs +++ b/api/QuestionsApi/Services/QuestionAttemptStore.cs @@ -17,10 +17,11 @@ public class QuestionAttemptStore var firstAttemptIndex = Builders.IndexKeys .Ascending(a => a.QuestionExternalId) - .Ascending(a => a.UserId); + .Ascending(a => a.UserId) + .Ascending(a => a.BankCycle); _col.Indexes.CreateOne(new CreateIndexModel( firstAttemptIndex, - new CreateIndexOptions { Unique = true })); + new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" })); var questionIndex = Builders.IndexKeys.Ascending(a => a.QuestionExternalId); _col.Indexes.CreateOne(new CreateIndexModel(questionIndex)); @@ -38,12 +39,14 @@ public class QuestionAttemptStore bool isCorrect, bool isSkip = false, IReadOnlyCollection? 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.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc)) .ToListAsync(); - public Task> GetUserQuestionIdsAsync(string userId) => + public Task> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) + { + var filter = Builders.Filter.Eq(attempt => attempt.UserId, userId); + if (bankCycle.HasValue) + filter &= Builders.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value); + return _col.Distinct( attempt => attempt.QuestionExternalId, - Builders.Filter.Eq(attempt => attempt.UserId, userId)) + filter) .ToListAsync(); + } public IMongoCollection Collection => _col; diff --git a/assets/icons/lucide/flask-conical.svg b/assets/icons/lucide/flask-conical.svg new file mode 100644 index 0000000..9a3fa9f --- /dev/null +++ b/assets/icons/lucide/flask-conical.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/lucide/flask-conical.svg.import b/assets/icons/lucide/flask-conical.svg.import new file mode 100644 index 0000000..193a804 --- /dev/null +++ b/assets/icons/lucide/flask-conical.svg.import @@ -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 diff --git a/assets/icons/lucide/gem.svg b/assets/icons/lucide/gem.svg new file mode 100644 index 0000000..84dc964 --- /dev/null +++ b/assets/icons/lucide/gem.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/lucide/gem.svg.import b/assets/icons/lucide/gem.svg.import new file mode 100644 index 0000000..0ec162b --- /dev/null +++ b/assets/icons/lucide/gem.svg.import @@ -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 diff --git a/assets/icons/lucide/rotate-ccw.svg b/assets/icons/lucide/rotate-ccw.svg new file mode 100644 index 0000000..717b7c0 --- /dev/null +++ b/assets/icons/lucide/rotate-ccw.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/lucide/rotate-ccw.svg.import b/assets/icons/lucide/rotate-ccw.svg.import new file mode 100644 index 0000000..80b5844 --- /dev/null +++ b/assets/icons/lucide/rotate-ccw.svg.import @@ -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 diff --git a/assets/slot_machine/cabinet_frame.png b/assets/slot_machine/cabinet_frame.png new file mode 100644 index 0000000..9184bbb Binary files /dev/null and b/assets/slot_machine/cabinet_frame.png differ diff --git a/assets/slot_machine/cabinet_frame.png.import b/assets/slot_machine/cabinet_frame.png.import new file mode 100644 index 0000000..0d0af7d --- /dev/null +++ b/assets/slot_machine/cabinet_frame.png.import @@ -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 diff --git a/assets/slot_machine/lever.png b/assets/slot_machine/lever.png new file mode 100644 index 0000000..4e001bf Binary files /dev/null and b/assets/slot_machine/lever.png differ diff --git a/assets/slot_machine/lever.png.import b/assets/slot_machine/lever.png.import new file mode 100644 index 0000000..03b3f53 --- /dev/null +++ b/assets/slot_machine/lever.png.import @@ -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 diff --git a/assets/slot_machine/slot_coin.png b/assets/slot_machine/slot_coin.png new file mode 100644 index 0000000..a6a06c3 Binary files /dev/null and b/assets/slot_machine/slot_coin.png differ diff --git a/assets/slot_machine/slot_coin.png.import b/assets/slot_machine/slot_coin.png.import new file mode 100644 index 0000000..5889b45 --- /dev/null +++ b/assets/slot_machine/slot_coin.png.import @@ -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 diff --git a/assets/slot_machine/slot_coin_stack.png b/assets/slot_machine/slot_coin_stack.png new file mode 100644 index 0000000..d04bf78 Binary files /dev/null and b/assets/slot_machine/slot_coin_stack.png differ diff --git a/assets/slot_machine/slot_coin_stack.png.import b/assets/slot_machine/slot_coin_stack.png.import new file mode 100644 index 0000000..e8bd189 --- /dev/null +++ b/assets/slot_machine/slot_coin_stack.png.import @@ -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 diff --git a/assets/slot_machine/slot_gem.png b/assets/slot_machine/slot_gem.png new file mode 100644 index 0000000..6e2c6be Binary files /dev/null and b/assets/slot_machine/slot_gem.png differ diff --git a/assets/slot_machine/slot_gem.png.import b/assets/slot_machine/slot_gem.png.import new file mode 100644 index 0000000..c46af92 --- /dev/null +++ b/assets/slot_machine/slot_gem.png.import @@ -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 diff --git a/assets/slot_machine/slot_gift.png b/assets/slot_machine/slot_gift.png new file mode 100644 index 0000000..ed3d80b Binary files /dev/null and b/assets/slot_machine/slot_gift.png differ diff --git a/assets/slot_machine/slot_gift.png.import b/assets/slot_machine/slot_gift.png.import new file mode 100644 index 0000000..70ac06e --- /dev/null +++ b/assets/slot_machine/slot_gift.png.import @@ -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 diff --git a/assets/slot_machine/slot_potion.png b/assets/slot_machine/slot_potion.png new file mode 100644 index 0000000..51e4f05 Binary files /dev/null and b/assets/slot_machine/slot_potion.png differ diff --git a/assets/slot_machine/slot_potion.png.import b/assets/slot_machine/slot_potion.png.import new file mode 100644 index 0000000..5dfb163 --- /dev/null +++ b/assets/slot_machine/slot_potion.png.import @@ -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 diff --git a/assets/slot_machine/slot_shield.png b/assets/slot_machine/slot_shield.png new file mode 100644 index 0000000..8ea1fb6 Binary files /dev/null and b/assets/slot_machine/slot_shield.png differ diff --git a/assets/slot_machine/slot_shield.png.import b/assets/slot_machine/slot_shield.png.import new file mode 100644 index 0000000..5d0c47d --- /dev/null +++ b/assets/slot_machine/slot_shield.png.import @@ -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 diff --git a/scripts/autoload/credits_service.gd b/scripts/autoload/credits_service.gd index 4f05a7a..f38b93e 100644 --- a/scripts/autoload/credits_service.gd +++ b/scripts/autoload/credits_service.gd @@ -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: diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index a114119..741693b 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -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": diff --git a/scripts/autoload/profile_service.gd b/scripts/autoload/profile_service.gd index ca344f0..fefc90e 100644 --- a/scripts/autoload/profile_service.gd +++ b/scripts/autoload/profile_service.gd @@ -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"])) diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd index 583d3de..cf6962e 100644 --- a/scripts/ui/credits_badge.gd +++ b/scripts/ui/credits_badge.gd @@ -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) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index dacb8dc..35ef020 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -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() diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index 8070cec..c9a1005 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -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 diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index fe7d9d8..3414aeb 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -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)) diff --git a/scripts/ui/rz_slot_machine_screen.gd b/scripts/ui/rz_slot_machine_screen.gd new file mode 100644 index 0000000..fbcd8b8 --- /dev/null +++ b/scripts/ui/rz_slot_machine_screen.gd @@ -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)) diff --git a/scripts/ui/rz_slot_machine_screen.gd.uid b/scripts/ui/rz_slot_machine_screen.gd.uid new file mode 100644 index 0000000..e2c086b --- /dev/null +++ b/scripts/ui/rz_slot_machine_screen.gd.uid @@ -0,0 +1 @@ +uid://col1hauyr27rn diff --git a/scripts/ui/rz_store_screen.gd b/scripts/ui/rz_store_screen.gd new file mode 100644 index 0000000..f0231cf --- /dev/null +++ b/scripts/ui/rz_store_screen.gd @@ -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 diff --git a/scripts/ui/rz_store_screen.gd.uid b/scripts/ui/rz_store_screen.gd.uid new file mode 100644 index 0000000..9c92c8f --- /dev/null +++ b/scripts/ui/rz_store_screen.gd.uid @@ -0,0 +1 @@ +uid://cldpnvs2k6r3t diff --git a/scripts/ui/simulation_history.gd b/scripts/ui/simulation_history.gd index 48a733a..139aa7e 100644 --- a/scripts/ui/simulation_history.gd +++ b/scripts/ui/simulation_history.gd @@ -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: diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index bb51347..8d6a3fb 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -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)