feat: free-play choice + per-game dual leaderboard (backend)

Free-to-play alongside paid, with scores split per game between a Pleb board
(free) and the Hall of Champions (paid). Keys on the existing per-game
game_mode, so no new game columns.

- Leaderboard API: GET /api/leaderboard?board=pleb|champions|all. champions =
  game_mode IN (PAID_SINGLE,PAID_CREDITS), pleb = FREE. board is whitelisted.
  Migration 019 adds a supporting partial index.
- FREE_PLAY_ENABLED config: offer free play as an explicit choice even when the
  instance also sells credits/entry (always on when payments are off).
- play_free socket event -> handleAutoStart(socket, {free:true}); skips all
  payment/credits/payout-address gating and records the game as game_mode=FREE
  via processGameStart(..., {forceFree:true}) -> _processGameStartFree (no
  payment, no payout, counted).
- getPaymentOptionsForUser exposes a play_free option; /api/game-modes exposes
  freePlayEnabled.

Adds freePlay.test.js. Full backend suite: 22 suites / 158 tests.
This commit is contained in:
jwinterm
2026-05-31 15:01:01 -04:00
parent 7581ba79cb
commit 0a1766ee2a
5 changed files with 154 additions and 13 deletions
+46 -5
View File
@@ -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');
+15 -3
View File
@@ -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',
@@ -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;
+11 -5
View File
@@ -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);
+74
View File
@@ -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);
});
});