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);
+ });
+});