feat: free-play + dual-leaderboard frontend

- Leaderboard panel: two boards via tabs — 🏅 Hall of Champions (paid) and
  🪙 Pleb (free) — default Champions; passes board= to /api/leaderboard and
  retitles per board.
- PaymentUI: the Free button now shows whenever free play is offered (instance
  free-only OR free-as-a-choice via freePlayEnabled), labelled "Play Free / no
  payout / Pleb leaderboard" on paid instances, and emits the play_free event
  (records game_mode=FREE). Replaces an emit of the dead 'enter_game' event.
- Server game_mode_info payload now includes freePlayEnabled so the client can
  show the choice.

Full suite: 22 suites / 158 tests.
This commit is contained in:
jwinterm
2026-05-31 15:08:54 -04:00
parent 0a1766ee2a
commit 988617289b
3 changed files with 33 additions and 7 deletions
+21 -2
View File
@@ -5,6 +5,7 @@
const Leaderboard = {
_isLoading: false,
_currentPeriod: 'all',
_currentBoard: 'champions', // 'champions' (paid) | 'pleb' (free)
init: function() {
$('#leaderboardButton').on('click', function() {
@@ -34,9 +35,13 @@ const Leaderboard = {
var panel = $(
'<div id="leaderboard-panel" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:2100; background:rgba(10,10,20,0.97); border:2px solid #f59e0b; padding:20px; color:#e0e0e0; min-width:380px; max-width:500px; max-height:80vh; overflow-y:auto; box-shadow:0 0 20px rgba(245,158,11,0.3); border-radius:6px;">' +
'<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">' +
'<strong style="color:#fbbf24; font-size:1.1em;">🏆 Leaderboard</strong>' +
'<strong id="leaderboard-title" style="color:#fbbf24; font-size:1.1em;">🏅 Hall of Champions</strong>' +
'<button id="close-leaderboard" style="background:#500; color:#f00; border:1px solid #f00; padding:2px 8px; cursor:pointer; font-size:14px;">✕</button>' +
'</div>' +
'<div id="leaderboard-boards" style="display:flex; gap:4px; margin-bottom:8px;">' +
'<button class="lb-board" data-board="champions" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🏅 Champions</button>' +
'<button class="lb-board" data-board="pleb" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🪙 Pleb</button>' +
'</div>' +
'<div id="leaderboard-tabs" style="display:flex; gap:4px; margin-bottom:12px;">' +
'<button class="lb-tab" data-period="all" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">All Time</button>' +
'<button class="lb-tab" data-period="month" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">Month</button>' +
@@ -59,6 +64,11 @@ const Leaderboard = {
self._updateTabs();
self._fetchAndRender();
});
$(document).on('click', '.lb-board', function() {
self._currentBoard = $(this).data('board');
self._updateTabs();
self._fetchAndRender();
});
this._updateTabs();
},
@@ -72,6 +82,15 @@ const Leaderboard = {
$(this).css({ background: '#1a1a2e', color: '#aaa', borderColor: '#555' });
}
});
var board = this._currentBoard;
$('.lb-board').each(function() {
if ($(this).data('board') === board) {
$(this).css({ background: '#92400e', color: '#fff', borderColor: '#f59e0b' });
} else {
$(this).css({ background: '#1a1a2e', color: '#aaa', borderColor: '#555' });
}
});
$('#leaderboard-title').text(board === 'pleb' ? '🪙 Pleb Board' : '🏅 Hall of Champions');
},
_fetchAndRender: function() {
@@ -80,7 +99,7 @@ const Leaderboard = {
$('#leaderboard-content').html('<div style="text-align:center; color:#888;">Loading...</div>');
var self = this;
$.getJSON('/api/leaderboard?period=' + this._currentPeriod + '&limit=20')
$.getJSON('/api/leaderboard?period=' + this._currentPeriod + '&board=' + this._currentBoard + '&limit=20')
.done(function(data) {
self._render(data.leaderboard || []);
})
+11 -5
View File
@@ -260,13 +260,17 @@ const PaymentUI = {
`);
}
// === FREE mode (only if payments disabled) ===
if (!this.config.paymentsEnabled) {
// === FREE play === shown when the instance is free-only OR offers free as a choice
// alongside paid (freePlayEnabled). Free games go to the Pleb leaderboard.
if (this.config.freePlayEnabled) {
const note = this.config.paymentsEnabled
? 'No payout • Pleb leaderboard'
: 'No payments required';
$container.append(`
<button class="mode-option" data-mode="FREE" data-action="free"
style="width:100%;padding:12px;background:#166534;border:none;color:#fff;cursor:pointer;border-radius:4px;">
<strong>🆓 Free Play</strong><br>
<span style="font-size:0.8em;color:#aaa;">No payments required</span>
<strong>🆓 Play Free</strong><br>
<span style="font-size:0.8em;color:#aaa;">${note}</span>
</button>
`);
}
@@ -278,7 +282,9 @@ const PaymentUI = {
if (mode === 'FREE') {
$('#payment-ui').hide();
if (window.socket) {
window.socket.emit('enter_game');
// play_free records the game as FREE (Pleb leaderboard, no payout) even on
// an instance that also sells credits/entry.
window.socket.emit('play_free');
}
return;
}
+1
View File
@@ -1149,6 +1149,7 @@ class GameModeManager {
creditPackages: creditPackages,
creditsPayoutBaseValue: this.creditsPayoutBaseValue,
paymentsEnabled: this.paymentsEnabled,
freePlayEnabled: this.freePlayEnabled,
directModeEnabled: this.directModeEnabled,
creditsModeEnabled: this.creditsModeEnabled,
directPayoutsEnabled: this.directPayoutMultipliers.escape > 0,