mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
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:
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user