mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-25 09:08:34 +01: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);
|
||||
|
||||
Reference in New Issue
Block a user