Add an RZ Coin slot machine
Adds a spend-coins-for-a-random-reward mini-game reachable from the shop. Backend mirrors the store's existing atomic/idempotent purchase pattern (SpinAsync in CreditStore.cs) with a server-authoritative, weighted reward roll and a rolling per-user spin-rate guard; odds are served from GET /api/credits/spin-config so the client never hardcodes numbers that could drift from the real payout table. The mockup's "Streak Protector" reward was swapped for a real Exam Simulation Token since streaks aren't currently a persisted, protectable concept. Also fixes two latent bugs surfaced while building this: - credits_service.gd's operation-id builder referenced a nonexistent AuthState.user_id (should be .username), throwing on every credit action. - _post() silently no-op'd with no signal when signed out, which combined with a GDScript closure gotcha (lambdas capture outer locals by value, so reassigning inside one never reached the caller) meant a spin attempted while unauthenticated could hang forever instead of surfacing an error.
This commit is contained in:
@@ -36,7 +36,49 @@ public static class CreditEconomy
|
||||
Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public enum RzStoreGrant { ExamToken, QBankResetToken }
|
||||
public static class SlotMachineEconomy
|
||||
{
|
||||
public const int SpinCost = 50;
|
||||
public const int RollingSpinLimit = 200;
|
||||
public static readonly TimeSpan SpinWindow = TimeSpan.FromHours(24);
|
||||
|
||||
public static readonly IReadOnlyList<SpinReward> Rewards =
|
||||
[
|
||||
new() { Key = "jackpot", Label = "10,000 RZ Coins", Description = "Jackpot!", Grant = RzStoreGrant.Coins, CoinAmount = 10000, Probability = 0.005 },
|
||||
new() { Key = "epic", Label = "2,000 RZ Coins", Description = "Epic Reward", Grant = RzStoreGrant.Coins, CoinAmount = 2000, Probability = 0.02 },
|
||||
new() { Key = "great", Label = "500 RZ Coins", Description = "Great Reward", Grant = RzStoreGrant.Coins, CoinAmount = 500, Probability = 0.10 },
|
||||
new() { Key = "exam_token", Label = "Exam Simulation Token", Description = "Starts one new full exam simulation.", Grant = RzStoreGrant.ExamToken, Probability = 0.15 },
|
||||
new() { Key = "qbank_reset", Label = "QBank Reset Token", Description = "Makes every QBank question eligible again.", Grant = RzStoreGrant.QBankResetToken, Probability = 0.20 },
|
||||
new() { Key = "nice", Label = "100 RZ Coins", Description = "Nice!", Grant = RzStoreGrant.Coins, CoinAmount = 100, Probability = 0.30 },
|
||||
new() { Key = "good_luck", Label = "50 RZ Coins", Description = "Good luck!", Grant = RzStoreGrant.Coins, CoinAmount = 50, Probability = 0.225 },
|
||||
];
|
||||
|
||||
public static SpinReward RollReward(double roll)
|
||||
{
|
||||
var cumulative = 0.0;
|
||||
foreach (var reward in Rewards)
|
||||
{
|
||||
cumulative += reward.Probability;
|
||||
if (roll < cumulative) return reward;
|
||||
}
|
||||
return Rewards[^1];
|
||||
}
|
||||
|
||||
public static SpinReward? FindReward(string key) =>
|
||||
Rewards.FirstOrDefault(value => value.Key.Equals(key, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public enum RzStoreGrant { ExamToken, QBankResetToken, Coins }
|
||||
|
||||
public sealed class SpinReward
|
||||
{
|
||||
public string Key { get; set; } = "";
|
||||
public string Label { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public RzStoreGrant Grant { get; set; }
|
||||
public int CoinAmount { get; set; }
|
||||
public double Probability { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RzStoreProduct
|
||||
{
|
||||
@@ -61,6 +103,14 @@ public sealed class CreditPurchaseEvent
|
||||
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]
|
||||
@@ -74,6 +124,8 @@ public sealed class UserCreditAccount
|
||||
public bool InitialExamGrantApplied { get; set; }
|
||||
public List<CreditEvent> RecentEarnings { get; set; } = [];
|
||||
public List<CreditPurchaseEvent> Purchases { get; set; } = [];
|
||||
public List<SpinEvent> Spins { get; set; } = [];
|
||||
public List<CreditEvent> RecentSpins { get; set; } = [];
|
||||
public List<string> AppliedOperations { get; set; } = [];
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -102,10 +154,30 @@ public sealed class QBankResetRequest
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum CreditOperationResult { Succeeded, InsufficientCredits, InsufficientTokens, OperationConflict }
|
||||
public sealed class SpinRequest
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum CreditOperationResult { Succeeded, InsufficientCredits, InsufficientTokens, OperationConflict, RateLimited }
|
||||
|
||||
public sealed class CreditOperationResponse
|
||||
{
|
||||
public CreditOperationResult Result { get; set; }
|
||||
public CreditStatusResponse Status { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class SpinConfigResponse
|
||||
{
|
||||
public int SpinCost { get; set; } = SlotMachineEconomy.SpinCost;
|
||||
public IReadOnlyList<SpinReward> Rewards { get; set; } = SlotMachineEconomy.Rewards;
|
||||
}
|
||||
|
||||
public sealed class SpinResponse
|
||||
{
|
||||
public CreditOperationResult Result { get; set; }
|
||||
public string RewardKey { get; set; } = "";
|
||||
public string RewardLabel { get; set; } = "";
|
||||
public int CoinsWon { get; set; }
|
||||
public CreditStatusResponse Status { get; set; } = new();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user