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.
301 lines
15 KiB
C#
301 lines
15 KiB
C#
using MongoDB.Bson;
|
|
using MongoDB.Driver;
|
|
using QuestionsApi.Models;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public sealed class CreditStore
|
|
{
|
|
private readonly IMongoCollection<UserCreditAccount> _accounts;
|
|
private readonly TimeProvider _clock;
|
|
|
|
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
|
|
{
|
|
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
|
|
_clock = clock;
|
|
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
|
|
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
|
|
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
|
|
}
|
|
|
|
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attemptId)) return false;
|
|
var now = _clock.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
|
|
var filters = Builders<UserCreditAccount>.Filter;
|
|
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
|
|
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
|
|
{
|
|
new BsonDocument("$size", "$RecentEarnings"),
|
|
CreditEconomy.RollingEarningLimit
|
|
})));
|
|
var filter = filters.Eq(value => value.UserId, userId) &
|
|
filters.Not(filters.ElemMatch(value => value.RecentEarnings,
|
|
earning => earning.OperationId == attemptId)) &
|
|
belowCap;
|
|
var update = Builders<UserCreditAccount>.Update
|
|
.Inc(value => value.Credits, 1)
|
|
.Push(value => value.RecentEarnings, new CreditEvent
|
|
{
|
|
OperationId = attemptId,
|
|
CreatedUtc = now
|
|
})
|
|
.Set(value => value.UpdatedUtc, now);
|
|
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
|
}
|
|
|
|
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
|
|
{
|
|
var now = _clock.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
|
return ToStatus(account);
|
|
}
|
|
|
|
public async Task<int> GetBankCycleAsync(string userId)
|
|
{
|
|
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
|
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
|
|
}
|
|
|
|
public async Task<CreditOperationResponse> PurchaseAsync(
|
|
string userId, RzStoreProduct product, string operationId)
|
|
{
|
|
var now = _clock.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
|
var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId);
|
|
if (prior is not null)
|
|
return Response(prior.Sku == product.Sku
|
|
? CreditOperationResult.Succeeded
|
|
: CreditOperationResult.OperationConflict, existing);
|
|
|
|
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
|
|
Builders<UserCreditAccount>.Filter.Not(
|
|
Builders<UserCreditAccount>.Filter.ElemMatch(
|
|
value => value.Purchases, purchase => purchase.OperationId == operationId));
|
|
var update = Builders<UserCreditAccount>.Update
|
|
.Inc(value => value.Credits, -product.CoinPrice)
|
|
.Push(value => value.Purchases, new CreditPurchaseEvent
|
|
{
|
|
OperationId = operationId,
|
|
Sku = product.Sku,
|
|
CoinCost = product.CoinPrice,
|
|
CreatedUtc = now
|
|
})
|
|
.Set(value => value.UpdatedUtc, now);
|
|
update = product.Grant == RzStoreGrant.ExamToken
|
|
? update.Inc(value => value.ExamTokens, 1)
|
|
: update.Inc(value => value.QBankResetTokens, 1);
|
|
var account = await _accounts.FindOneAndUpdateAsync(
|
|
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
|
|
{
|
|
ReturnDocument = ReturnDocument.After
|
|
});
|
|
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
|
|
|
|
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
|
prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId);
|
|
if (prior is not null)
|
|
return Response(prior.Sku == product.Sku
|
|
? CreditOperationResult.Succeeded
|
|
: CreditOperationResult.OperationConflict, account);
|
|
return Response(CreditOperationResult.InsufficientCredits, account);
|
|
}
|
|
|
|
public async Task<CreditOperationResponse> ApplyQBankResetAsync(string userId, string operationId)
|
|
{
|
|
var now = _clock.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Gt(value => value.QBankResetTokens, 0) &
|
|
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
|
|
var update = Builders<UserCreditAccount>.Update
|
|
.Inc(value => value.QBankResetTokens, -1)
|
|
.Inc(value => value.BankCycle, 1)
|
|
.AddToSet(value => value.AppliedOperations, operationId)
|
|
.Set(value => value.UpdatedUtc, now);
|
|
var account = await _accounts.FindOneAndUpdateAsync(
|
|
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
|
|
{
|
|
ReturnDocument = ReturnDocument.After
|
|
});
|
|
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
|
|
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
|
if (account.AppliedOperations.Contains(operationId))
|
|
return Response(CreditOperationResult.Succeeded, account);
|
|
return Response(CreditOperationResult.InsufficientTokens, account);
|
|
}
|
|
|
|
public async Task<SpinResponse> SpinAsync(string userId, string operationId)
|
|
{
|
|
var now = _clock.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
|
var priorSpin = existing.Spins.FirstOrDefault(value => value.OperationId == operationId);
|
|
if (priorSpin is not null)
|
|
return SpinResult(CreditOperationResult.Succeeded, existing, priorSpin);
|
|
|
|
var reward = SlotMachineEconomy.RollReward(Random.Shared.NextDouble());
|
|
var netCreditDelta = reward.Grant == RzStoreGrant.Coins
|
|
? reward.CoinAmount - SlotMachineEconomy.SpinCost
|
|
: -SlotMachineEconomy.SpinCost;
|
|
var spinEvent = new SpinEvent
|
|
{
|
|
OperationId = operationId,
|
|
RewardKey = reward.Key,
|
|
CoinsWon = reward.Grant == RzStoreGrant.Coins ? reward.CoinAmount : 0,
|
|
CreatedUtc = now
|
|
};
|
|
|
|
var belowSpinCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
|
|
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
|
|
{
|
|
new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })),
|
|
SlotMachineEconomy.RollingSpinLimit
|
|
})));
|
|
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) &
|
|
Builders<UserCreditAccount>.Filter.Not(
|
|
Builders<UserCreditAccount>.Filter.ElemMatch(
|
|
value => value.Spins, spin => spin.OperationId == operationId)) &
|
|
belowSpinCap;
|
|
var update = Builders<UserCreditAccount>.Update
|
|
.Inc(value => value.Credits, netCreditDelta)
|
|
.Push(value => value.Spins, spinEvent)
|
|
.Push(value => value.RecentSpins, new CreditEvent { OperationId = operationId, CreatedUtc = now })
|
|
.Set(value => value.UpdatedUtc, now);
|
|
update = reward.Grant switch
|
|
{
|
|
RzStoreGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
|
|
RzStoreGrant.QBankResetToken => update.Inc(value => value.QBankResetTokens, 1),
|
|
_ => update
|
|
};
|
|
var account = await _accounts.FindOneAndUpdateAsync(
|
|
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
|
|
{
|
|
ReturnDocument = ReturnDocument.After
|
|
});
|
|
if (account is not null) return SpinResult(CreditOperationResult.Succeeded, account, spinEvent);
|
|
|
|
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
|
priorSpin = account.Spins.FirstOrDefault(value => value.OperationId == operationId);
|
|
if (priorSpin is not null) return SpinResult(CreditOperationResult.Succeeded, account, priorSpin);
|
|
if (account.Credits < SlotMachineEconomy.SpinCost)
|
|
return SpinResult(CreditOperationResult.InsufficientCredits, account, null);
|
|
return SpinResult(CreditOperationResult.RateLimited, account, null);
|
|
}
|
|
|
|
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
|
{
|
|
var now = _clock.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Gt(value => value.ExamTokens, 0) &
|
|
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
|
|
var update = Builders<UserCreditAccount>.Update
|
|
.Inc(value => value.ExamTokens, -1)
|
|
.AddToSet(value => value.AppliedOperations, operationId)
|
|
.Set(value => value.UpdatedUtc, now);
|
|
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
|
}
|
|
|
|
public Task RefundExamTokenAsync(string userId, string operationId) =>
|
|
_accounts.UpdateOneAsync(
|
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.AnyEq(value => value.AppliedOperations, operationId),
|
|
Builders<UserCreditAccount>.Update
|
|
.Inc(value => value.ExamTokens, 1)
|
|
.Pull(value => value.AppliedOperations, operationId)
|
|
.Set(value => value.UpdatedUtc, _clock.GetUtcNow().UtcDateTime));
|
|
|
|
private async Task EnsureAndCleanAsync(string userId, DateTime now)
|
|
{
|
|
await _accounts.UpdateOneAsync(
|
|
value => value.UserId == userId,
|
|
Builders<UserCreditAccount>.Update
|
|
.SetOnInsert(value => value.UserId, userId)
|
|
.SetOnInsert(value => value.Credits, 0)
|
|
.SetOnInsert(value => value.ExamTokens, 1)
|
|
.SetOnInsert(value => value.QBankResetTokens, 0)
|
|
.SetOnInsert(value => value.BankCycle, 0)
|
|
.SetOnInsert(value => value.InitialExamGrantApplied, true)
|
|
.SetOnInsert(value => value.RecentEarnings, [])
|
|
.SetOnInsert(value => value.Purchases, [])
|
|
.SetOnInsert(value => value.Spins, [])
|
|
.SetOnInsert(value => value.RecentSpins, [])
|
|
.SetOnInsert(value => value.AppliedOperations, [])
|
|
.SetOnInsert(value => value.UpdatedUtc, now),
|
|
new UpdateOptions { IsUpsert = true });
|
|
await _accounts.UpdateOneAsync(
|
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Ne(value => value.InitialExamGrantApplied, true),
|
|
Builders<UserCreditAccount>.Update
|
|
.Inc(value => value.ExamTokens, 1)
|
|
.Set(value => value.InitialExamGrantApplied, true));
|
|
await _accounts.UpdateOneAsync(
|
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
|
|
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
|
|
await _accounts.UpdateOneAsync(
|
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
|
|
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
|
|
await _accounts.UpdateOneAsync(
|
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Spins), false),
|
|
Builders<UserCreditAccount>.Update.Set(value => value.Spins, []));
|
|
await _accounts.UpdateOneAsync(
|
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false),
|
|
Builders<UserCreditAccount>.Update.Set(value => value.RecentSpins, []));
|
|
await _accounts.UpdateOneAsync(
|
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
|
|
Builders<UserCreditAccount>.Update.Set(value => value.AppliedOperations, []));
|
|
var cutoff = now - CreditEconomy.EarningWindow;
|
|
await _accounts.UpdateOneAsync(
|
|
value => value.UserId == userId,
|
|
Builders<UserCreditAccount>.Update.PullFilter(
|
|
value => value.RecentEarnings,
|
|
earning => earning.CreatedUtc <= cutoff));
|
|
var spinCutoff = now - SlotMachineEconomy.SpinWindow;
|
|
await _accounts.UpdateOneAsync(
|
|
value => value.UserId == userId,
|
|
Builders<UserCreditAccount>.Update.PullFilter(
|
|
value => value.RecentSpins,
|
|
spin => spin.CreatedUtc <= spinCutoff));
|
|
}
|
|
|
|
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
|
|
{
|
|
Credits = account.Credits,
|
|
EarnedInRollingWindow = account.RecentEarnings.Count,
|
|
RemainingEarningCapacity = Math.Max(
|
|
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count),
|
|
ExamTokens = account.ExamTokens,
|
|
QBankResetTokens = account.QBankResetTokens,
|
|
BankCycle = account.BankCycle
|
|
};
|
|
|
|
private static CreditOperationResponse Response(
|
|
CreditOperationResult result, UserCreditAccount account) => new()
|
|
{
|
|
Result = result,
|
|
Status = ToStatus(account)
|
|
};
|
|
|
|
private static SpinResponse SpinResult(
|
|
CreditOperationResult result, UserCreditAccount account, SpinEvent? spinEvent) => new()
|
|
{
|
|
Result = result,
|
|
RewardKey = spinEvent?.RewardKey ?? "",
|
|
RewardLabel = spinEvent is null ? "" : SlotMachineEconomy.FindReward(spinEvent.RewardKey)?.Label ?? "",
|
|
CoinsWon = spinEvent?.CoinsWon ?? 0,
|
|
Status = ToStatus(account)
|
|
};
|
|
}
|