mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 07:38:34 +02:00
feat(credits): client consumes served catalog; direct entry counts as a credit purchase
① assetPacks.js merges the server-authoritative catalog (from the entitlement snapshot) into RK.PACKS — operator-added packs now appear client-side and existing packs pick up server definitions, retiring the client half of the split-brain. ② A confirmed direct/single_game payment now records as 'buy 1 credit + spend it': balance nets to zero but total_credits_purchased advances (recordDirectEntryPurchase), so direct play unlocks the same tier/threshold cosmetics as buying credits, and the client gets a fresh entitlement snapshot. +2 tests. Suite 49/345 green. P1 foundation (branch feat/credits-catalog-foundation). Remaining: the buy modal reflects the unified credits model; a later pass can fully collapse single_game into the credit spend path.
This commit is contained in:
@@ -490,6 +490,67 @@ class GameModeManager {
|
||||
return this.processProductPaymentConfirmation(socketId, paymentId, packageInfo, receivedAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a direct/single_game entry as "buy 1 credit and immediately spend it on this game":
|
||||
* the balance nets to zero but total_credits_purchased advances, so a direct payment unlocks the
|
||||
* SAME tier/threshold cosmetics as buying credits (the first step of unifying everything to
|
||||
* credits). Idempotent by the caller — the single_game confirmation runs once per payment via
|
||||
* its status='pending' -> 'confirmed' guard. Returns { totalCreditsPurchased, balance,
|
||||
* entitlements } or null.
|
||||
*/
|
||||
async recordDirectEntryPurchase(socketId) {
|
||||
try {
|
||||
const user = await this.getOrCreateUser(socketId, { create: false });
|
||||
if (!user || user.id == null) return null;
|
||||
let totalCreditsPurchased = 0;
|
||||
let balance = 0;
|
||||
let premiumLevel = null;
|
||||
await this.db.withTransaction(async (client) => {
|
||||
const upd = await client.query(
|
||||
`UPDATE users
|
||||
SET total_credits_purchased = COALESCE(total_credits_purchased, 0) + 1
|
||||
WHERE id = $1
|
||||
RETURNING total_credits_purchased, credits, premium_level`,
|
||||
[user.id]
|
||||
);
|
||||
if (!upd.rows.length) return;
|
||||
totalCreditsPurchased = Number(upd.rows[0].total_credits_purchased || 0);
|
||||
balance = Number(upd.rows[0].credits || 0);
|
||||
premiumLevel = upd.rows[0].premium_level || null;
|
||||
// Audit trail: bought 1 credit + spent it on this game (net 0 balance).
|
||||
await client.query(
|
||||
`INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, 1, 'direct_entry', $2, 'purchase'),
|
||||
($1, -1, 'game_entry', $2, 'spend')`,
|
||||
[user.id, balance]
|
||||
);
|
||||
});
|
||||
|
||||
let packGrants = [];
|
||||
try {
|
||||
const g = await this.db.query(
|
||||
`SELECT pack_id, source, granted_at, expires_at FROM user_pack_entitlements
|
||||
WHERE user_id = $1 AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
[user.id]
|
||||
);
|
||||
packGrants = g.rows || [];
|
||||
} catch (_) { packGrants = []; }
|
||||
|
||||
const entitlements = Entitlements.snapshotForUser({
|
||||
id: user.id,
|
||||
credits: balance,
|
||||
total_credits_purchased: totalCreditsPurchased,
|
||||
premium_level: premiumLevel
|
||||
}, packGrants);
|
||||
|
||||
return { totalCreditsPurchased, balance, entitlements };
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err, 'Failed to record direct entry purchase');
|
||||
console.error('❌ recordDirectEntryPurchase error:', normalized.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async _findReusablePayment(userId, paymentType, productId = null) {
|
||||
if (!userId) return null;
|
||||
const result = await this.db.query(`
|
||||
|
||||
@@ -459,6 +459,25 @@ class PaymentHandlers {
|
||||
|
||||
socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations });
|
||||
|
||||
// Unify to credits: a direct/single_game entry counts as buying + spending 1
|
||||
// credit, so it advances total_credits_purchased and unlocks the same tier/
|
||||
// threshold cosmetics as a credit purchase. (Runs once — guarded by wasUpdated.)
|
||||
if (this.gameModeManager && typeof this.gameModeManager.recordDirectEntryPurchase === 'function') {
|
||||
try {
|
||||
const rec = await this.gameModeManager.recordDirectEntryPurchase(socket.id);
|
||||
if (rec) {
|
||||
socket.emit('credits_update', {
|
||||
balance: rec.balance,
|
||||
totalCreditsPurchased: rec.totalCreditsPurchased,
|
||||
...(rec.entitlements || {})
|
||||
});
|
||||
if (rec.entitlements) socket.emit('identity_update', { entitlements: rec.entitlements });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PaymentHandlers] direct-entry credit record failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// IMPORTANT: If payment confirmed before mempool detection (fast blocks),
|
||||
// the player may not be in the queue yet. Add them now with confirmed=true.
|
||||
const existingIdx = this.queueManager.getPlayerIndex(socket.id);
|
||||
|
||||
Reference in New Issue
Block a user