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:
hz
2026-07-30 00:29:49 -05:00
parent 4909cf0b71
commit 1860e6ede6
8 changed files with 816 additions and 3 deletions
+85
View File
@@ -130,6 +130,65 @@ public sealed class CreditStore
return Response(CreditOperationResult.InsufficientTokens, account);
}
public async Task<SpinResponse> SpinAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
var priorSpin = existing.Spins.FirstOrDefault(value => value.OperationId == operationId);
if (priorSpin is not null)
return SpinResult(CreditOperationResult.Succeeded, existing, priorSpin);
var reward = SlotMachineEconomy.RollReward(Random.Shared.NextDouble());
var netCreditDelta = reward.Grant == RzStoreGrant.Coins
? reward.CoinAmount - SlotMachineEconomy.SpinCost
: -SlotMachineEconomy.SpinCost;
var spinEvent = new SpinEvent
{
OperationId = operationId,
RewardKey = reward.Key,
CoinsWon = reward.Grant == RzStoreGrant.Coins ? reward.CoinAmount : 0,
CreatedUtc = now
};
var belowSpinCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
{
new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })),
SlotMachineEconomy.RollingSpinLimit
})));
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) &
Builders<UserCreditAccount>.Filter.Not(
Builders<UserCreditAccount>.Filter.ElemMatch(
value => value.Spins, spin => spin.OperationId == operationId)) &
belowSpinCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, netCreditDelta)
.Push(value => value.Spins, spinEvent)
.Push(value => value.RecentSpins, new CreditEvent { OperationId = operationId, CreatedUtc = now })
.Set(value => value.UpdatedUtc, now);
update = reward.Grant switch
{
RzStoreGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
RzStoreGrant.QBankResetToken => update.Inc(value => value.QBankResetTokens, 1),
_ => update
};
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) return SpinResult(CreditOperationResult.Succeeded, account, spinEvent);
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
priorSpin = account.Spins.FirstOrDefault(value => value.OperationId == operationId);
if (priorSpin is not null) return SpinResult(CreditOperationResult.Succeeded, account, priorSpin);
if (account.Credits < SlotMachineEconomy.SpinCost)
return SpinResult(CreditOperationResult.InsufficientCredits, account, null);
return SpinResult(CreditOperationResult.RateLimited, account, null);
}
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
@@ -166,6 +225,8 @@ public sealed class CreditStore
.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 });
@@ -183,6 +244,14 @@ public sealed class CreditStore
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Spins), false),
Builders<UserCreditAccount>.Update.Set(value => value.Spins, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentSpins, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
@@ -193,6 +262,12 @@ public sealed class CreditStore
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
var spinCutoff = now - SlotMachineEconomy.SpinWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentSpins,
spin => spin.CreatedUtc <= spinCutoff));
}
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
@@ -212,4 +287,14 @@ public sealed class CreditStore
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)
};
}