diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js index 8fc4ce3..e1443d3 100644 --- a/html/js/render/assetPacks.js +++ b/html/js/render/assetPacks.js @@ -44,6 +44,30 @@ RK.setEntitlementSnapshot = function (data) { data = data || {}; var prev = RK.entitlements || {}; + + // Consume the server-authoritative catalog (migration 024). This retires the hardcoded + // client pack list as the source of truth: operator-added packs appear, and existing packs + // pick up server definitions (label/projection/tier). Merged (not replaced) so any client + // fallback a render mode relies on is preserved if the operator's catalog omits it. + if (Array.isArray(data.catalog) && data.catalog.length) { + var merged = {}; + for (var k in RK.PACKS) merged[k] = RK.PACKS[k]; + for (var ci = 0; ci < data.catalog.length; ci++) { + var c = data.catalog[ci]; + if (!c || !c.id) continue; + var ex = merged[c.id] || {}; + merged[c.id] = { + id: c.id, + label: c.label || ex.label || c.id, + premium: c.premium != null ? !!c.premium : !!ex.premium, + projection: c.projection || ex.projection || null, + tier: c.tier != null ? c.tier : ex.tier, + unlockMinCredits: c.unlockMinCredits != null ? c.unlockMinCredits : ex.unlockMinCredits, + unlock: ex.unlock + }; + } + RK.PACKS = merged; + } var hasTotal = data.totalCreditsPurchased != null || data.total_credits_purchased != null; var hasPremium = data.premium != null; var hasPacks = !!data.packs; diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index fcb3c05..3685319 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -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(` diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index 7ffca27..398dc24 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -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); diff --git a/test/directEntryCredit_ultra.test.js b/test/directEntryCredit_ultra.test.js new file mode 100644 index 0000000..82a2dc6 --- /dev/null +++ b/test/directEntryCredit_ultra.test.js @@ -0,0 +1,63 @@ +/** + * Credits unification, step ②: a direct/single_game entry is recorded as "buy 1 credit and + * spend it" — the balance nets to zero but total_credits_purchased advances, so direct play + * unlocks the same tier/threshold cosmetics as buying credits. + */ + +const GameModeManager = require('../src/game/gameModeManager'); + +const mockPaymentConfig = () => ({ + getConfig: () => ({ + paymentsEnabled: true, + currency: { symbol: 'WOW', decimals: 11 }, + modes: { + direct: { enabled: true, price: 100000000000n }, + credits: { enabled: false, creditsPerGame: 1, packages: [] } + }, + payouts: { rules: { direct: { enabled: false, multipliers: { escape: 2, escapeWithTreasure: 3 }, minPayout: 0n, maxPayout: 0n } } }, + preferences: { preferCreditsFirst: true }, + earlyEntry: { enabled: false } + }), + getLegacyGameMode: () => 'PAID_SINGLE', + eventBus: { on: () => {} } +}); + +function makeGmm(userRow) { + const client = { query: jest.fn().mockResolvedValue({ rows: [userRow] }) }; + const db = { + query: jest.fn().mockResolvedValue({ rows: [] }), // user_pack_entitlements lookup + withTransaction: jest.fn().mockImplementation(async (cb) => cb(client)), + _client: client + }; + const gmm = new GameModeManager(db, { processPayout: jest.fn() }, { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, mockPaymentConfig()); + gmm.getOrCreateUser = jest.fn().mockResolvedValue({ id: 7 }); + return { gmm, db, client }; +} + +describe('recordDirectEntryPurchase', () => { + test('advances total_credits_purchased and unlocks threshold-1 packs', async () => { + const { gmm, client } = makeGmm({ total_credits_purchased: 1, credits: 0, premium_level: null }); + const rec = await gmm.recordDirectEntryPurchase('sock'); + expect(rec).toBeTruthy(); + expect(rec.totalCreditsPurchased).toBe(1); + expect(rec.balance).toBe(0); // net zero — bought and spent + + const insert = client.query.mock.calls.find(c => /INSERT INTO credit_transactions/i.test(c[0])); + expect(insert).toBeTruthy(); + expect(insert[0]).toMatch(/'direct_entry'/); // audits the purchase leg + expect(insert[0]).toMatch(/'game_entry'/); // audits the spend leg + + // The seed premium packs (threshold 1) are now unlocked, exactly like a credit purchase. + expect(rec.entitlements.packs['iso-dungeon']).toBe(true); + expect(rec.entitlements.packs['generated-skins']).toBe(true); + expect(rec.entitlements.premium).toBe(true); + // ...but a direct buyer is still NOT a premium tier. + expect(rec.entitlements.level).toBe('free'); + }); + + test('returns null when the session has no user', async () => { + const { gmm } = makeGmm({}); + gmm.getOrCreateUser = jest.fn().mockResolvedValue(null); + expect(await gmm.recordDirectEntryPurchase('sock')).toBe(null); + }); +});