diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index ee38dbb..efcbbec 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -104,6 +104,11 @@ class GameModeManager { this.paymentsEnabled = this.gameMode !== 'FREE'; this.directModeEnabled = this.gameMode === 'PAID_SINGLE'; this.creditsModeEnabled = this.gameMode === 'PAID_CREDITS'; + + // FREE_PLAY_ENABLED lets players CHOOSE free play even on an instance that also + // sells credits/entry (their score goes to the Pleb board; paid games go to the + // Hall of Champions). Always true when payments are off (free is the only option). + this.freePlayEnabled = !this.paymentsEnabled || /^true$/i.test(process.env.FREE_PLAY_ENABLED || 'false'); } applyConfigSnapshot(config, options = {}) { @@ -477,9 +482,22 @@ class GameModeManager { try { const user = await this.getOrCreateUser(socketId); const effective = this.getEffectiveModeForUser(user); - + const options = []; - + + // Free play (Pleb board, no payout) — offered alongside paid options when enabled. + if (this.freePlayEnabled && this.paymentsEnabled) { + options.push({ + type: 'play_free', + label: 'Play Free (Pleb leaderboard)', + mode: 'FREE', + cost: 0, + costDisplay: 'Free', + payoutEligible: false, + recommended: false + }); + } + if (effective.canUseCredits) { options.push({ type: 'use_credit', @@ -655,12 +673,20 @@ class GameModeManager { /** * Process game start (deduct credits or link payment) */ - async processGameStart(socketId, gameId) { + async processGameStart(socketId, gameId, options = {}) { try { const user = await this.getOrCreateUser(socketId); + + // The player explicitly chose FREE play (even on an instance that also sells + // credits/entry). Record this game as FREE so it lands on the Pleb leaderboard, + // with no payment and no payout. + if (options.forceFree) { + return await this._processGameStartFree(user, gameId); + } + const effective = this.getEffectiveModeForUser(user); - - // FREE mode + + // FREE mode (instance is free-only) if (effective.mode === 'FREE') { return { success: true, effectiveMode: 'FREE' }; } @@ -747,6 +773,21 @@ class GameModeManager { }; } + /** + * Start a game the player explicitly chose to play for FREE. Records game_mode='FREE' + * on the game (overriding the instance default, so it goes to the Pleb leaderboard) and + * counts the game. No payment, no payout. + */ + async _processGameStartFree(user, gameId) { + await this.db.withTransaction(async (client) => { + await client.query(`UPDATE games SET game_mode = 'FREE' WHERE dungeon_seed = $1`, [gameId]); + await client.query(` + UPDATE users SET total_games_played = total_games_played + 1, updated_at = NOW() WHERE id = $1 + `, [user.id]); + }); + return { success: true, effectiveMode: 'FREE' }; + } + async _processGameStartWithCredits(user, socketId, gameId) { const creditsToSpend = this.creditsPerGameCost; const snap = this._computePayoutSnapshot('PAID_CREDITS'); diff --git a/src/index.js b/src/index.js index 18a4042..a882966 100644 --- a/src/index.js +++ b/src/index.js @@ -517,6 +517,15 @@ app.get('/api/leaderboard', asyncHandler(async (req, res) => { if (period === 'week') timeFilter = "AND g.completed_at > NOW() - INTERVAL '7 days'"; else if (period === 'month') timeFilter = "AND g.completed_at > NOW() - INTERVAL '30 days'"; + // Per-game leaderboard split (whitelisted -> safe to interpolate): + // champions = games played with credits/entry-fee (Hall of Champions) + // pleb = free games (Pleb board) + // all = everyone (default, backward compatible) + const board = req.query.board || 'all'; + let boardFilter = ''; + if (board === 'champions') boardFilter = "AND g.game_mode IN ('PAID_SINGLE','PAID_CREDITS')"; + else if (board === 'pleb') boardFilter = "AND g.game_mode = 'FREE'"; + const result = await db.query(` SELECT u.id, @@ -531,13 +540,13 @@ app.get('/api/leaderboard', asyncHandler(async (req, res) => { COUNT(*) as games_played FROM games g JOIN users u ON g.user_id = u.id - WHERE g.status IN ('won', 'lost') AND g.score > 0 ${timeFilter} + WHERE g.status IN ('won', 'lost') AND g.score > 0 ${timeFilter} ${boardFilter} GROUP BY u.id, u.display_name, u.payout_address ORDER BY best_score DESC LIMIT $1 `, [limit]); - res.json({ leaderboard: result.rows, period }); + res.json({ leaderboard: result.rows, period, board }); })); app.get('/api/game-modes', (req, res) => { @@ -573,11 +582,14 @@ app.get('/api/game-modes', (req, res) => { } res.json({ + // Free play is available when the instance is free-only OR when free play is offered + // as a choice alongside paid options (FREE_PLAY_ENABLED). + freePlayEnabled: !!gameModeManager.freePlayEnabled, FREE: { name: 'Free Play', cost: 0, payoutMultiplier: 0, - enabled: !config.paymentsEnabled + enabled: !!gameModeManager.freePlayEnabled }, PAID_SINGLE: { name: 'Paid Single Game', diff --git a/src/migrations/019_leaderboard_board_index.sql b/src/migrations/019_leaderboard_board_index.sql new file mode 100644 index 0000000..4fd2aff --- /dev/null +++ b/src/migrations/019_leaderboard_board_index.sql @@ -0,0 +1,8 @@ +-- Migration 019: Index for the per-game leaderboard split (Pleb vs Hall of Champions) +-- +-- The leaderboard is split by game_mode: free games -> Pleb board, paid (credits/entry) +-- games -> Hall of Champions. This composite index supports both filtered queries +-- (score-ranked, restricted to ranked games). +CREATE INDEX IF NOT EXISTS idx_games_leaderboard +ON games (game_mode, score DESC) +WHERE status IN ('won', 'lost') AND score > 0; diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index 4549bf9..cf7e86a 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -381,6 +381,7 @@ class SocketHandlers { } }); socket.on('auto_start', () => this.handleAutoStart(socket)); // New handler for start button + socket.on('play_free', () => this.handleAutoStart(socket, { free: true })); // Explicit free-play choice socket.on('join_queue', () => this.handleJoinQueue(socket)); // Queue instead of auto-start socket.on('early_entry', () => this.handleEarlyEntry(socket)); // Early entry without waiting for block socket.on('address:prompt', () => this.handleAddressPrompt(socket)); @@ -402,15 +403,19 @@ class SocketHandlers { * Applies payment eligibility + payout address gating (for payout-eligible modes) * If eligible, starts game immediately (bypassing block queue) and processes game start (credit deduction / payment linkage) */ - async handleAutoStart(socket) { + async handleAutoStart(socket, opts = {}) { try { + // The player explicitly chose FREE play (Pleb board, no payment/payout). Only + // honoured when the instance allows free play; otherwise fall through to paid. + const wantsFree = opts.free === true && this.gameModeManager?.freePlayEnabled; + // Rate limiting for game starts — keyed on stable identity + IP so reconnecting // (new socket.id) can't reset the limit. const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:start', rlIp); if (!rateLimitResult.allowed) { - this.broadcastManager.sendStatusUpdate(socket.id, 'warning', + this.broadcastManager.sendStatusUpdate(socket.id, 'warning', `Please wait ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds before starting another game. (${rateLimitResult.remaining} attempts remaining)`); return; } @@ -428,7 +433,8 @@ class SocketHandlers { } let canStart = { allowed: true, reason: 'Free mode' }; - if (this.gameModeManager) { + // Free play skips all payment/credits/payout-address gating below. + if (this.gameModeManager && !wantsFree) { // Payout address gating for modes that can payout const payoutEligible = (this.gameModeManager.gameMode === 'PAID_SINGLE') || (this.gameModeManager.gameMode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled); if (payoutEligible) { @@ -480,9 +486,9 @@ class SocketHandlers { state.proof = game.getProofCommitment(); } - // Process start (credits deduction / payment link) + // Process start (free / credits deduction / payment link) if (this.gameModeManager) { - const startRes = await this.gameModeManager.processGameStart(socket.id, game.id); + const startRes = await this.gameModeManager.processGameStart(socket.id, game.id, { forceFree: wantsFree }); if (!startRes.success) { // Abort game this.activeGames.delete(socket.id); diff --git a/test/freePlay.test.js b/test/freePlay.test.js new file mode 100644 index 0000000..0a5f5be --- /dev/null +++ b/test/freePlay.test.js @@ -0,0 +1,74 @@ +/** + * Free-play entry tests (Feature 1). + * + * When a player explicitly chooses free play (even on an instance that also sells + * credits/entry), the game must be recorded as game_mode='FREE' (so it lands on the Pleb + * leaderboard, not the Hall of Champions) with no payment and no payout. + */ + +const GameModeManager = require('../src/game/gameModeManager'); + +const createMockDb = () => { + const client = { query: jest.fn().mockResolvedValue({ rows: [] }) }; + return { + query: jest.fn().mockResolvedValue({ rows: [] }), + withTransaction: jest.fn().mockImplementation(async (cb) => cb(client)), + _client: client + }; +}; + +const cfg = () => ({ + getConfig: () => ({ + paymentsEnabled: true, + currency: { symbol: 'WOW', decimals: 11 }, + modes: { + direct: { enabled: true, price: 100000000000n }, + credits: { enabled: true, creditsPerGame: 1, packages: [{ id: 'small', credits: 10, price: '500000000000', bonus: 0 }] } + }, + payouts: { rules: { direct: { enabled: true, multipliers: { escape: 2, escapeWithTreasure: 3 } }, credits: { enabled: false, multipliers: { escape: 1.5, escapeWithTreasure: 2 }, baseValue: 50000000000n } } }, + preferences: { preferCreditsFirst: true }, + earlyEntry: { enabled: false } + }), + getLegacyGameMode: () => 'PAID_SINGLE', + eventBus: { on: () => {} } +}); + +function buildGmm() { + const db = createMockDb(); + const gmm = new GameModeManager(db, { processPayout: jest.fn() }, { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, cfg()); + // Resolve identity straight to a stable user row. + gmm.sessionManager = { getBySocket: jest.fn().mockResolvedValue({ id: 1 }) }; + db.query.mockImplementation(async (text) => { + if (/SELECT \* FROM users WHERE id = \$1/i.test(text)) return { rows: [{ id: 1, credits: 0 }] }; + return { rows: [] }; + }); + return { gmm, db }; +} + +describe('free-play game start (forceFree)', () => { + test('records game_mode=FREE and counts the game, no payment', async () => { + const { gmm, db } = buildGmm(); + + const res = await gmm.processGameStart('sock1', 'seed-123', { forceFree: true }); + + expect(res.success).toBe(true); + expect(res.effectiveMode).toBe('FREE'); + const calls = db._client.query.mock.calls.map(c => c[0]); + expect(calls.find(s => /UPDATE games SET game_mode = 'FREE'/i.test(s))).toBeDefined(); + expect(calls.find(s => /UPDATE users SET total_games_played/i.test(s))).toBeDefined(); + }); + + test('offers a play_free option when free play is enabled alongside payments', async () => { + const { gmm } = buildGmm(); + gmm.freePlayEnabled = true; + const opts = await gmm.getPaymentOptionsForUser('sock1'); + expect(opts.options.some(o => o.type === 'play_free' && o.mode === 'FREE')).toBe(true); + }); + + test('does NOT offer free play when disabled', async () => { + const { gmm } = buildGmm(); + gmm.freePlayEnabled = false; + const opts = await gmm.getPaymentOptionsForUser('sock1'); + expect(opts.options.some(o => o.type === 'play_free')).toBe(false); + }); +});