From 9c050bdb70ab03b3e4a5a25dd93b3d80078f786f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 08:11:57 +0000 Subject: [PATCH 1/3] fix: wire multiplayer contributions and harden cosmic war system - CRITICAL: Connect reportLumens() to all lumen-earning paths (click, passive, rub, offline) so player contributions actually reach the cosmic war server - Deduplicate formatShort() by reusing formatNumber from utils.js - Persist mpPrestigeBonus to localStorage for offline play - Add disconnect visual indicator (red blinking pip) on balance circle - Add offline contribution queueing via localStorage backup - Add streak loss toast notification when streak resets - Add server acknowledgment for contribution rate changes - Add CSS for streak toast and disconnect state https://claude.ai/code/session_01WLAo9YUAXfLCe65rcZhFmL --- js/click.js | 2 ++ js/game-loop.js | 2 ++ js/interaction.js | 2 ++ js/main.js | 49 ++++++++++++++++++++++++++------------- js/multiplayer.js | 52 +++++++++++++++++++++++++++++++++++++++++- js/season-end.js | 20 +++++----------- js/state.js | 12 ++++++++++ server/index.js | 19 ++++++++++++---- style.css | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 180 insertions(+), 36 deletions(-) diff --git a/js/click.js b/js/click.js index eecaab5..74ab342 100644 --- a/js/click.js +++ b/js/click.js @@ -28,6 +28,7 @@ import { import { checkMilestones } from './upgrades.js'; import { updateUI } from './ui.js'; import { save } from './save.js'; +import { reportLumens } from './multiplayer.js'; function handleClick(e) { if (state.victoryReached || state.sunPurchased || shared.sunCinematicActive) return; @@ -75,6 +76,7 @@ function handleClick(e) { state.lumens += gain; state.totalLumens += gain; + reportLumens(gain); addHalo(x, y); if (multiplier > 1) { diff --git a/js/game-loop.js b/js/game-loop.js index 9b609db..dc903a6 100644 --- a/js/game-loop.js +++ b/js/game-loop.js @@ -3,6 +3,7 @@ import { state, getTotalPrestigeMultiplier } from './state.js'; import { _raf } from './utils.js'; +import { reportLumens } from './multiplayer.js'; import { canvas, ctx } from './canvas.js'; // Effects @@ -65,6 +66,7 @@ export function passiveTick() { const gain = (state.lumensPerSecond * getTotalPrestigeMultiplier()) / 10; // called 10x per sec state.lumens += gain; state.totalLumens += gain; + reportLumens(gain); checkMilestones(); updateUI(); } diff --git a/js/interaction.js b/js/interaction.js index 6d68cde..ba15475 100644 --- a/js/interaction.js +++ b/js/interaction.js @@ -5,6 +5,7 @@ import { state, gameMode, getUpgradeCount, shared, getSaveKey, getTotalPrestigeM import { _st, _now } from './utils.js'; import { canvas, ctx } from './canvas.js'; import { halos } from './effects/halos.js'; +import { reportLumens } from './multiplayer.js'; // --- Rubbing/swiping mechanic --- let isRubbing = false; @@ -36,6 +37,7 @@ export function moveRub(x, y, checkMilestones, updateUI) { const rubPower = Math.max(1, Math.floor(state.clickPower * 0.3 * getTotalPrestigeMultiplier())); state.lumens += rubPower; state.totalLumens += rubPower; + reportLumens(rubPower); rubDistance -= RUB_THRESHOLD; if (gameMode === 'off') { diff --git a/js/main.js b/js/main.js index c49aff0..35e4a45 100644 --- a/js/main.js +++ b/js/main.js @@ -39,8 +39,10 @@ import { onMultiplayerUpdate, onSeasonEnd, onRewardReceived, + onStreakChange, notifySideChange, setContributionRate, + reportLumens, } from './multiplayer.js'; import { setServerAvailable, isOnboardingDone, isMultiplayerActive, checkOnboarding } from './onboarding.js'; import { showSeasonEnd, showSeasonEndBroadcast } from './season-end.js'; @@ -112,6 +114,7 @@ function initGame() { if (offlineGain > 0) { state.lumens += offlineGain; state.totalLumens += offlineGain; + reportLumens(offlineGain); updateUI(); var offlinePopup = document.getElementById('offline-popup'); var offlineText = document.getElementById('offline-text'); @@ -232,14 +235,6 @@ var mpLeaderboardTabs = document.querySelectorAll('.mp-lb-tab'); var mpLeaderboardList = document.getElementById('mp-leaderboard-list'); var mpLeaderboardPlayerRank = document.getElementById('mp-leaderboard-player-rank'); -function formatShort(n) { - if (n >= 1e12) return (n / 1e12).toFixed(1) + 'T'; - if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'; - if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; - if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; - return String(n); -} - // --- Update balance indicator circle proportions --- function updateBalanceCircle(light, dark) { var total = light + dark; @@ -256,10 +251,16 @@ function updateBalanceCircle(light, dark) { // --- Update the full overlay with current multiplayer state --- function updateMultiplayerUI(mpState) { - // Balance indicator: only show if onboarding is done and user connected - if (isMultiplayerActive() && mpState.connected) { + // Balance indicator: only show if onboarding is done + if (isMultiplayerActive()) { mpBalance.classList.remove('hidden'); - mpBalance.classList.add('online'); + if (mpState.connected) { + mpBalance.classList.add('online'); + mpBalance.classList.remove('disconnected'); + } else { + mpBalance.classList.remove('online'); + mpBalance.classList.add('disconnected'); + } } // Update balance proportions @@ -300,8 +301,8 @@ function updateMultiplayerUI(mpState) { mpOverlayLightBar.style.width = '50%'; mpOverlayDarkBar.style.width = '50%'; } - mpOverlayLightTotal.textContent = formatShort(light); - mpOverlayDarkTotal.textContent = formatShort(dark); + mpOverlayLightTotal.textContent = formatNumber(light); + mpOverlayDarkTotal.textContent = formatNumber(dark); mpOverlayOnline.textContent = mpState.online.total + ' en ligne'; // Season info @@ -323,7 +324,7 @@ function updateMultiplayerUI(mpState) { if (mpState.user && mpState.profile) { mpOverlayPlayer.classList.remove('hidden'); mpOverlayPlayerContrib.textContent = - formatShort(mpState.profile.contribution) + (gameMode === 'off' ? ' ob' : ' lm'); + formatNumber(mpState.profile.contribution) + (gameMode === 'off' ? ' ob' : ' lm'); if (mpState.profile.streakDays > 0) { var streakText = mpState.profile.streakDays + 'j consécutifs'; @@ -479,7 +480,7 @@ function renderLeaderboard() { var totalEl = document.createElement('span'); totalEl.className = 'mp-lb-total'; - totalEl.textContent = formatShort(entry.total); + totalEl.textContent = formatNumber(entry.total); el.appendChild(rankEl); @@ -500,7 +501,7 @@ function renderLeaderboard() { // Player's own rank if (leaderboardData.playerRank && leaderboardData.playerRank.side === currentLeaderboardSide) { mpLeaderboardPlayerRank.textContent = - 'Votre rang : #' + leaderboardData.playerRank.rank + ' (' + formatShort(leaderboardData.playerRank.total) + ')'; + 'Votre rang : #' + leaderboardData.playerRank.rank + ' (' + formatNumber(leaderboardData.playerRank.total) + ')'; } else { mpLeaderboardPlayerRank.textContent = ''; } @@ -519,6 +520,22 @@ onRewardReceived(function (rewards) { } }); +// --- Streak loss notification --- +onStreakChange(function (info) { + if (!info.reset) return; + // Show a brief toast notification about streak loss + var toast = document.createElement('div'); + toast.className = 'mp-streak-toast'; + toast.textContent = 'Série perdue (' + info.oldStreak + 'j) — multiplieur réinitialisé'; + document.body.appendChild(toast); + _st(function () { + toast.classList.add('fade-out'); + _st(function () { + toast.remove(); + }, 600); + }, 4000); +}); + // --- Onboarding completion callback --- window._onOnboardingDone = function (choice) { if (choice === 'connected') { diff --git a/js/multiplayer.js b/js/multiplayer.js index 2d11a85..da46e56 100644 --- a/js/multiplayer.js +++ b/js/multiplayer.js @@ -48,6 +48,12 @@ export function onRewardReceived(fn) { rewardListeners.push(fn); } +// --- Streak change callbacks --- +const streakChangeListeners = []; +export function onStreakChange(fn) { + streakChangeListeners.push(fn); +} + // --- Auth --- export async function fetchUser() { try { @@ -109,13 +115,39 @@ export async function fetchRewards() { } catch (_) {} } +// --- Offline contribution queue (localStorage backup) --- +const OFFLINE_QUEUE_KEY = 'light-mp-offline-queue'; + +function loadOfflineQueue() { + try { + var val = localStorage.getItem(OFFLINE_QUEUE_KEY); + if (val) { + var amount = Number(val); + if (amount > 0) mp.pendingLumens += amount; + localStorage.removeItem(OFFLINE_QUEUE_KEY); + } + } catch (_) {} +} + +function saveOfflineQueue() { + try { + if (mp.pendingLumens > 0) { + localStorage.setItem(OFFLINE_QUEUE_KEY, String(mp.pendingLumens)); + } + } catch (_) {} +} + // --- Set contribution rate --- export function setContributionRate(rate) { const valid = [10, 25, 50, 100]; if (!valid.includes(rate)) return; mp.contributionRate = rate; if (mp.socket && mp.connected) { - mp.socket.emit('set-contribution-rate', { rate }); + mp.socket.emit('set-contribution-rate', { rate }, function (ack) { + if (ack && ack.error) { + console.warn('[multiplayer] Failed to save contribution rate:', ack.error); + } + }); } } @@ -129,6 +161,8 @@ export function connectSocket() { socket.on('connect', () => { mp.connected = true; + // Restore any offline-queued contributions + loadOfflineQueue(); // Join with current game mode if (gameMode) { socket.emit('join', { side: gameMode }); @@ -138,6 +172,8 @@ export function connectSocket() { socket.on('disconnect', () => { mp.connected = false; + // Save any pending contributions to localStorage so they survive page close + saveOfflineQueue(); notify(); }); @@ -157,8 +193,13 @@ export function connectSocket() { // Server sends player profile updates (after contributions) socket.on('profile', (data) => { if (data) { + var oldStreak = mp.profile ? mp.profile.streakDays : null; mp.profile = data; mp.contributionRate = data.contributionRate || mp.contributionRate; + // Detect streak reset (was > 1, now reset to 1 or 0) + if (oldStreak !== null && oldStreak > 1 && data.streakDays <= 1) { + streakChangeListeners.forEach((fn) => fn({ oldStreak, newStreak: data.streakDays, reset: true })); + } notify(); } }); @@ -205,6 +246,13 @@ function flushLumens() { mp.socket.emit('contribute', { amount: contributed }); } mp.pendingLumens = 0; + // Clear offline queue since we flushed successfully + try { + localStorage.removeItem(OFFLINE_QUEUE_KEY); + } catch (_) {} + } else if (mp.pendingLumens > 0) { + // Socket disconnected or not logged in — persist queue for later + saveOfflineQueue(); } } @@ -230,6 +278,8 @@ export async function initMultiplayer() { await fetchProfile(); await fetchRewards(); } + // Restore any queued offline contributions + loadOfflineQueue(); connectSocket(); setInterval(flushLumens, REPORT_INTERVAL); } diff --git a/js/season-end.js b/js/season-end.js index 53f3fdd..741b788 100644 --- a/js/season-end.js +++ b/js/season-end.js @@ -2,7 +2,7 @@ 'use strict'; import { gameMode, setMpPrestigeBonus } from './state.js'; -import { _st } from './utils.js'; +import { _st, formatNumber } from './utils.js'; import { mp, claimReward } from './multiplayer.js'; // --- DOM refs (lazy) --- @@ -34,14 +34,6 @@ function grabDOM() { continueBtn = document.getElementById('season-end-continue'); } -function formatShort(n) { - if (n >= 1e12) return (n / 1e12).toFixed(1) + 'T'; - if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'; - if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; - if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; - return String(n); -} - /** * Show the season end overlay with reward data. * @param {object} reward - A season_rewards row joined with cosmic_war data @@ -63,8 +55,8 @@ export function showSeasonEnd(reward) { result.textContent = winnerLabel + ' a triomphé.'; } - statsLight.textContent = formatShort(Number(reward.total_light)) + ' lm'; - statsDark.textContent = formatShort(Number(reward.total_dark)) + ' ob'; + statsLight.textContent = formatNumber(Number(reward.total_light)) + ' lm'; + statsDark.textContent = formatNumber(Number(reward.total_dark)) + ' ob'; // Player stats if (reward.grade && reward.grade !== 'none') { @@ -73,7 +65,7 @@ export function showSeasonEnd(reward) { playerGrade.textContent = ''; } - playerContrib.textContent = 'Contribution : ' + formatShort(Number(reward.contribution_total)); + playerContrib.textContent = 'Contribution : ' + formatNumber(Number(reward.contribution_total)); if (reward.rank_in_team) { playerRank.textContent = 'Rang : #' + reward.rank_in_team + (reward.top_percent ? ' (Top 10%)' : ''); @@ -137,8 +129,8 @@ export function showSeasonEndBroadcast(data) { title.textContent = 'Saison ' + data.endedSeason + ' terminée'; result.textContent = winnerLabel + ' a triomphé\u00a0!'; - statsLight.textContent = formatShort(data.totalLight) + ' lm'; - statsDark.textContent = formatShort(data.totalDark) + ' ob'; + statsLight.textContent = formatNumber(data.totalLight) + ' lm'; + statsDark.textContent = formatNumber(data.totalDark) + ' ob'; playerGrade.textContent = ''; playerContrib.textContent = ''; diff --git a/js/state.js b/js/state.js index 5827b76..50d9f3f 100644 --- a/js/state.js +++ b/js/state.js @@ -64,8 +64,20 @@ export function getSaveKey() { // --- Multiplayer prestige bonus (permanent, from season rewards) --- export let mpPrestigeBonus = 0; +function loadMpPrestigeBonus() { + try { + var raw = localStorage.getItem('light-mp-prestige-bonus'); + if (raw) mpPrestigeBonus = Number(raw) || 0; + } catch (_) {} +} + +loadMpPrestigeBonus(); + export function setMpPrestigeBonus(val) { mpPrestigeBonus = val; + try { + localStorage.setItem('light-mp-prestige-bonus', String(val)); + } catch (_) {} } export function getTotalPrestigeMultiplier() { diff --git a/server/index.js b/server/index.js index 23b6c35..9b0ab09 100644 --- a/server/index.js +++ b/server/index.js @@ -250,11 +250,20 @@ io.on('connection', (socket) => { } }); - // Player changes contribution rate - socket.on('set-contribution-rate', async (data) => { - if (!user?.id) return; - const rate = Number(data?.rate); - await setPlayerContributionRate(user.id, rate); + // Player changes contribution rate (with acknowledgment) + socket.on('set-contribution-rate', async (data, ack) => { + if (!user?.id) { + if (typeof ack === 'function') ack({ error: 'Not authenticated' }); + return; + } + try { + const rate = Number(data?.rate); + const success = await setPlayerContributionRate(user.id, rate); + if (typeof ack === 'function') ack(success ? { ok: true } : { error: 'Invalid rate' }); + } catch (err) { + console.error('[socket] set-contribution-rate error:', err); + if (typeof ack === 'function') ack({ error: 'Server error' }); + } }); // Player claims a season reward diff --git a/style.css b/style.css index 136e6d7..a20edb6 100644 --- a/style.css +++ b/style.css @@ -1095,6 +1095,22 @@ body.mode-off #reset-btn.confirming { opacity: 1; } +#mp-balance.disconnected #mp-balance-pip { + opacity: 1; + background: rgba(255, 100, 100, 0.7); + animation: pip-blink 2s ease-in-out infinite; +} + +@keyframes pip-blink { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} + @keyframes balance-breathe { 0%, 100% { @@ -2069,3 +2085,45 @@ body.mode-off #season-end-continue:hover { border-color: rgba(0, 0, 0, 0.8); color: #000; } + +/* === STREAK LOSS TOAST === */ +.mp-streak-toast { + position: fixed; + bottom: calc(24px + env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%); + z-index: 90; + background: rgba(0, 0, 0, 0.85); + color: rgba(255, 255, 255, 0.8); + padding: 10px 20px; + font-family: 'Courier New', Courier, monospace; + font-size: 0.65rem; + letter-spacing: 1px; + border: 1px solid rgba(255, 100, 100, 0.4); + border-radius: 4px; + animation: toast-in 0.4s ease; + white-space: nowrap; +} + +.mp-streak-toast.fade-out { + opacity: 0; + transition: opacity 0.6s ease; + pointer-events: none; +} + +@keyframes toast-in { + 0% { + opacity: 0; + transform: translateX(-50%) translateY(10px); + } + 100% { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +body.mode-off .mp-streak-toast { + background: rgba(255, 255, 255, 0.85); + color: rgba(0, 0, 0, 0.8); + border-color: rgba(200, 50, 50, 0.4); +} From a328db926a7852d4d74c3e74835d90d33136038a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 08:18:31 +0000 Subject: [PATCH 2/3] fix: address secondary multiplayer issues - Season-end overlay now pauses passive income (shared.seasonEndActive) - Leaderboard fetch retries up to 2 times with 2s delay on failure - Fix streak date comparison: normalize pg DATE values to strings, use explicit UTC timestamps to avoid DST/timezone drift - Extract grade qualification threshold to named constant (REWARD_MIN_GRADE_INDEX) instead of hardcoded grade name list - Show player grade badge below balance indicator during gameplay - Display accumulated MP prestige bonus in overlay player section - Remove unused isOff/gameMode from season-end.js (lint cleanup) https://claude.ai/code/session_01WLAo9YUAXfLCe65rcZhFmL --- index.html | 2 ++ js/game-loop.js | 4 ++-- js/main.js | 32 ++++++++++++++++++++++++++++++-- js/season-end.js | 8 ++++++-- js/state.js | 1 + server/db.js | 47 +++++++++++++++++++++++++++++++++-------------- style.css | 30 ++++++++++++++++++++++++++++++ 7 files changed, 104 insertions(+), 20 deletions(-) diff --git a/index.html b/index.html index de560ac..ead2744 100644 --- a/index.html +++ b/index.html @@ -35,6 +35,7 @@ @@ -74,6 +75,7 @@
Ma contribution
0
+
diff --git a/js/game-loop.js b/js/game-loop.js index dc903a6..310d3c4 100644 --- a/js/game-loop.js +++ b/js/game-loop.js @@ -1,7 +1,7 @@ // === Game Loop — Main loop and passive income tick === 'use strict'; -import { state, getTotalPrestigeMultiplier } from './state.js'; +import { state, shared, getTotalPrestigeMultiplier } from './state.js'; import { _raf } from './utils.js'; import { reportLumens } from './multiplayer.js'; import { canvas, ctx } from './canvas.js'; @@ -61,7 +61,7 @@ export function gameLoop() { } export function passiveTick() { - if (state.victoryReached || state.sunPurchased) return; + if (state.victoryReached || state.sunPurchased || shared.seasonEndActive) return; if (state.lumensPerSecond > 0) { const gain = (state.lumensPerSecond * getTotalPrestigeMultiplier()) / 10; // called 10x per sec state.lumens += gain; diff --git a/js/main.js b/js/main.js index 35e4a45..bf64afa 100644 --- a/js/main.js +++ b/js/main.js @@ -223,8 +223,10 @@ var mpOverlaySeason = document.getElementById('mp-overlay-season'); var mpOverlayPlayer = document.getElementById('mp-overlay-player'); var mpOverlayPlayerContrib = document.getElementById('mp-overlay-player-contrib'); var mpOverlayPlayerStreak = document.getElementById('mp-overlay-player-streak'); +var mpOverlayPlayerPrestige = document.getElementById('mp-overlay-player-prestige'); var mpOverlayRateBtns = document.querySelectorAll('.mp-rate-btn'); var mpOverlayLeaderboardBtn = document.getElementById('mp-overlay-leaderboard-btn'); +var mpBalanceGrade = document.getElementById('mp-balance-grade'); // Leaderboard DOM var mpLeaderboard = document.getElementById('mp-leaderboard'); @@ -336,6 +338,15 @@ function updateMultiplayerUI(mpState) { mpOverlayPlayerStreak.textContent = ''; } + // Prestige bonus display + if (mpState.profile.mpPrestigeBonus > 0) { + mpOverlayPlayerPrestige.textContent = + 'Bonus prestige : +' + mpState.profile.mpPrestigeBonus.toFixed(2) + ' (saisons passées)'; + mpOverlayPlayerPrestige.classList.remove('hidden'); + } else { + mpOverlayPlayerPrestige.classList.add('hidden'); + } + // Update rate buttons updateRateButtons(mpState.contributionRate); @@ -346,6 +357,14 @@ function updateMultiplayerUI(mpState) { } else { mpOverlayPlayer.classList.add('hidden'); } + + // Grade badge on balance indicator + if (mpState.profile && mpState.profile.grade && mpState.profile.grade !== 'none') { + mpBalanceGrade.textContent = mpState.profile.grade; + mpBalanceGrade.classList.remove('hidden'); + } else { + mpBalanceGrade.classList.add('hidden'); + } } function updateRateButtons(activeRate) { @@ -442,7 +461,9 @@ mpLeaderboardTabs.forEach(function (tab) { }); }); -function fetchLeaderboard() { +function fetchLeaderboard(retries) { + if (retries === undefined) retries = 2; + mpLeaderboardList.innerHTML = '
Chargement\u2026
'; fetch('/api/leaderboard') .then(function (res) { return res.json(); @@ -453,7 +474,14 @@ function fetchLeaderboard() { renderLeaderboard(); }) .catch(function () { - mpLeaderboardList.innerHTML = '
Indisponible
'; + if (retries > 0) { + _st(function () { + fetchLeaderboard(retries - 1); + }, 2000); + } else { + mpLeaderboardList.innerHTML = + '
Indisponible — réessayez plus tard
'; + } }); } diff --git a/js/season-end.js b/js/season-end.js index 741b788..cc10699 100644 --- a/js/season-end.js +++ b/js/season-end.js @@ -1,7 +1,7 @@ // === SeasonEnd — Season transition cinematic and reward claim === 'use strict'; -import { gameMode, setMpPrestigeBonus } from './state.js'; +import { shared, setMpPrestigeBonus } from './state.js'; import { _st, formatNumber } from './utils.js'; import { mp, claimReward } from './multiplayer.js'; @@ -42,7 +42,6 @@ export function showSeasonEnd(reward) { grabDOM(); if (!overlay) return; - var isOff = gameMode === 'off'; var winnerLabel = reward.winner === 'light' ? 'La Lumière' : reward.winner === 'dark' ? "L'Ombre" : 'Égalité'; title.textContent = 'Saison ' + reward.season + ' terminée'; @@ -104,12 +103,16 @@ export function showSeasonEnd(reward) { dismissSeasonEnd(); }; + // Pause game while showing + shared.seasonEndActive = true; + // Show overlay.classList.remove('hidden'); } function dismissSeasonEnd() { if (!overlay) return; + shared.seasonEndActive = false; overlay.classList.add('fade-out'); _st(function () { overlay.classList.add('hidden'); @@ -144,5 +147,6 @@ export function showSeasonEndBroadcast(data) { dismissSeasonEnd(); }; + shared.seasonEndActive = true; overlay.classList.remove('hidden'); } diff --git a/js/state.js b/js/state.js index 50d9f3f..c9d9c55 100644 --- a/js/state.js +++ b/js/state.js @@ -93,6 +93,7 @@ export const shared = { adminMode: false, pendingReward: false, acPenaltyCount: 0, + seasonEndActive: false, }; // --- Utility --- diff --git a/server/db.js b/server/db.js index 87b6a4c..2f7ee77 100644 --- a/server/db.js +++ b/server/db.js @@ -103,6 +103,9 @@ const GRADES_OFF = [ { name: 'Néant', threshold: 100000000 }, ]; +// Minimum contribution threshold to qualify for season rewards (grade index 1 = Flamme/Braise) +const REWARD_MIN_GRADE_INDEX = 1; + function computeGrade(totalContribution, side) { const grades = side === 'on' ? GRADES_ON : GRADES_OFF; let grade = 'none'; @@ -112,6 +115,12 @@ function computeGrade(totalContribution, side) { return grade; } +function gradeQualifiesForReward(grade, side) { + const grades = side === 'on' ? GRADES_ON : GRADES_OFF; + const gradeIndex = grades.findIndex((g) => g.name === grade); + return gradeIndex >= REWARD_MIN_GRADE_INDEX; +} + /** * Get a player's total contribution for the current season. */ @@ -154,23 +163,33 @@ async function getPlayerGrade(userId) { * Update the player's daily streak. * Called on each contribution — increments if new day, resets if gap > 1 day. */ +/** + * Normalize a DATE column value (could be Date object or string) to 'YYYY-MM-DD'. + */ +function toDateString(val) { + if (!val) return null; + if (val instanceof Date) return val.toISOString().slice(0, 10); + return String(val).slice(0, 10); +} + async function updateStreak(userId) { const result = await pool.query(`SELECT streak_days, streak_last_date FROM users WHERE id = $1`, [userId]); if (result.rows.length === 0) return 0; const user = result.rows[0]; - const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD (UTC) + const lastDateStr = toDateString(user.streak_last_date); - if (user.streak_last_date === today) { - // Already contributed today + if (lastDateStr === today) { + // Already contributed today (UTC) return user.streak_days; } let newStreak; - if (user.streak_last_date) { - const lastDate = new Date(user.streak_last_date); - const todayDate = new Date(today); - const diffDays = Math.floor((todayDate - lastDate) / (1000 * 60 * 60 * 24)); + if (lastDateStr) { + const lastDate = new Date(lastDateStr + 'T00:00:00Z'); + const todayDate = new Date(today + 'T00:00:00Z'); + const diffDays = Math.round((todayDate - lastDate) / (1000 * 60 * 60 * 24)); if (diffDays === 1) { // Consecutive day @@ -234,10 +253,12 @@ async function getPlayerProfile(userId) { // Check if streak is still active (contributed today or yesterday) let activeStreak = user.streak_days || 0; - if (user.streak_last_date) { - const lastDate = new Date(user.streak_last_date); - const today = new Date(new Date().toISOString().slice(0, 10)); - const diffDays = Math.floor((today - lastDate) / (1000 * 60 * 60 * 24)); + const lastDateStr = toDateString(user.streak_last_date); + if (lastDateStr) { + const today = new Date().toISOString().slice(0, 10); + const lastDate = new Date(lastDateStr + 'T00:00:00Z'); + const todayDate = new Date(today + 'T00:00:00Z'); + const diffDays = Math.round((todayDate - lastDate) / (1000 * 60 * 60 * 24)); if (diffDays > 1) activeStreak = 0; // Streak expired } @@ -421,9 +442,7 @@ async function generateSeasonRewards(seasonNum, winner) { const rank = i + 1; const isTopPercent = rank <= topTenCutoff; - // Minimum grade required: Flamme/Braise (100K) to receive rewards - const minGrade = side === 'on' ? 'Flamme' : 'Braise'; - const qualifies = ['Flamme', 'Braise', 'Étoile', 'Ombre', 'Nova', 'Abîme', 'Cosmos', 'Néant'].includes(grade); + const qualifies = gradeQualifiesForReward(grade, side); let won = false; let prestigeBonus = 0; diff --git a/style.css b/style.css index a20edb6..e86ece0 100644 --- a/style.css +++ b/style.css @@ -1133,6 +1133,24 @@ body.mode-off #mp-balance:hover #mp-balance-circle { border-color: rgba(0, 0, 0, 0.8); } +/* Grade badge next to balance indicator */ +#mp-balance-grade { + position: absolute; + top: 44px; + left: 50%; + transform: translateX(-50%); + font-family: 'Courier New', Courier, monospace; + font-size: 0.5rem; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.5); + white-space: nowrap; + pointer-events: none; +} + +body.mode-off #mp-balance-grade { + color: rgba(0, 0, 0, 0.5); +} + /* === MULTIPLAYER OVERLAY === */ #mp-overlay { position: fixed; @@ -1624,6 +1642,18 @@ body.mode-off #mp-overlay-player-streak { color: #000; } +#mp-overlay-player-prestige { + font-size: 0.55rem; + letter-spacing: 1px; + opacity: 0.4; + margin-top: 6px; + font-style: italic; +} + +body.mode-off #mp-overlay-player-prestige { + color: #000; +} + /* Contribution rate selector */ #mp-overlay-rate { margin-top: 12px; From 8f52fe47c1e4a2ce471935990e1c6d58a5c73e13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 08:29:50 +0000 Subject: [PATCH 3/3] Fix remaining multiplayer issues: race conditions, anti-cheat, upsert, UI - Add UNIQUE constraint on cosmic_war.season + ON CONFLICT to prevent duplicate season creation from concurrent checks (#3) - Change recordContribution to upsert (ON CONFLICT DO UPDATE) to aggregate contributions per user+season+side instead of inserting individual rows (#4) - Pass season number from caller to recordContribution, removing redundant getCurrentSeason query (#5) - Add server-side anti-cheat: cap contributions at 5B per report, rate-limit to 8s minimum between reports, reject non-finite amounts (#6) - Add multiplayer unit tests for reportLumens, getContributionAmount, offline queue, and mp state initialization (14 tests) (#8) - Move notifySideChange into startGame callback so it fires after game init instead of before the 700ms animation completes (#9) - Add login UI (Google/Discord buttons) in overlay for unauthenticated players with mode-off variant styling (#10) https://claude.ai/code/session_01WLAo9YUAXfLCe65rcZhFmL --- index.html | 9 +++ js/main.js | 7 ++- server/db.js | 22 ++++--- server/index.js | 29 +++++++-- server/schema.sql | 14 ++++- style.css | 54 +++++++++++++++++ tests/unit/multiplayer.test.js | 106 +++++++++++++++++++++++++++++++++ 7 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 tests/unit/multiplayer.test.js diff --git a/index.html b/index.html index ead2744..c12dc22 100644 --- a/index.html +++ b/index.html @@ -58,6 +58,15 @@
+ + +
diff --git a/js/main.js b/js/main.js index bf64afa..65b718b 100644 --- a/js/main.js +++ b/js/main.js @@ -86,6 +86,8 @@ function startGame(mode) { gameArea.classList.remove('hidden'); resizeCanvas(); initGame(); + // Notify server of side choice after game is initialized + notifySideChange(mode); }, 700); } @@ -175,11 +177,9 @@ function initGame() { // --- Mode selection --- modeOn.addEventListener('click', function () { startGame('on'); - notifySideChange('on'); }); modeOff.addEventListener('click', function () { startGame('off'); - notifySideChange('off'); }); // Check for saved game — skip landing if save exists @@ -227,6 +227,7 @@ var mpOverlayPlayerPrestige = document.getElementById('mp-overlay-player-prestig var mpOverlayRateBtns = document.querySelectorAll('.mp-rate-btn'); var mpOverlayLeaderboardBtn = document.getElementById('mp-overlay-leaderboard-btn'); var mpBalanceGrade = document.getElementById('mp-balance-grade'); +var mpOverlayLogin = document.getElementById('mp-overlay-login'); // Leaderboard DOM var mpLeaderboard = document.getElementById('mp-leaderboard'); @@ -280,10 +281,12 @@ function updateMultiplayerUI(mpState) { mpOverlayAvatar.style.display = 'none'; } mpOverlayLogout.style.display = ''; + mpOverlayLogin.classList.add('hidden'); } else { mpOverlayName.textContent = ''; mpOverlayAvatar.style.display = 'none'; mpOverlayLogout.style.display = 'none'; + mpOverlayLogin.classList.remove('hidden'); } // Grade badge diff --git a/server/db.js b/server/db.js index 2f7ee77..4b40305 100644 --- a/server/db.js +++ b/server/db.js @@ -73,16 +73,20 @@ async function addToCosmicWar(side, amount) { } /** - * Record a player's contribution. + * Record a player's contribution (upsert — aggregates per user+season+side). + * @param {string} userId + * @param {number} seasonNum - Season number (passed by caller to avoid redundant query) + * @param {string} side - 'on' or 'off' + * @param {number} amount */ -async function recordContribution(userId, gameMode, amount) { - const season = await getCurrentSeason(); - if (!season) return; - +async function recordContribution(userId, seasonNum, side, amount) { await pool.query( `INSERT INTO contributions (user_id, season, game_mode, lumens_contributed) - VALUES ($1, $2, $3, $4)`, - [userId, season.season, gameMode, Math.floor(amount)], + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, season, game_mode) + DO UPDATE SET lumens_contributed = contributions.lumens_contributed + $4, + contributed_at = NOW()`, + [userId, seasonNum, side, Math.floor(amount)], ); } @@ -386,9 +390,9 @@ async function checkAndEndSeason() { // Generate rewards for all contributing players await generateSeasonRewards(season.season, winner); - // Create the next season + // Create the next season (ON CONFLICT prevents duplicates from concurrent checks) const nextSeason = season.season + 1; - await pool.query(`INSERT INTO cosmic_war (season) VALUES ($1)`, [nextSeason]); + await pool.query(`INSERT INTO cosmic_war (season) VALUES ($1) ON CONFLICT (season) DO NOTHING`, [nextSeason]); console.log(`[db] Season ${nextSeason} started. Previous winner: ${winner}`); diff --git a/server/index.js b/server/index.js index 9b0ab09..835c5ce 100644 --- a/server/index.js +++ b/server/index.js @@ -165,7 +165,13 @@ app.use(express.static(path.join(__dirname, '..'))); io.engine.use(sessionMiddleware); // Track connected players per side -const players = new Map(); // socketId -> { userId, side, displayName } +const players = new Map(); // socketId -> { userId, side, displayName, lastContribTime, contribCount } + +// Anti-cheat: max lumens per 10-second report interval. +// Game max passive is ~100M/s (endgame with prestige), report interval = 10s → ~1B max. +// We add generous margin for click bursts and combo multipliers. +const MAX_CONTRIBUTION_PER_REPORT = 5_000_000_000; // 5 billion +const MIN_REPORT_INTERVAL_MS = 8_000; // reports come every 10s, allow 8s minimum function countBySide(side) { let count = 0; @@ -218,23 +224,36 @@ io.on('connection', (socket) => { socket.on('contribute', async (data) => { if (!user?.id) return; // Must be logged in const amount = Number(data?.amount) || 0; - if (amount <= 0) return; + if (amount <= 0 || !Number.isFinite(amount)) return; const player = players.get(socket.id); if (!player) return; + // Anti-cheat: cap contribution amount + const clampedAmount = Math.min(amount, MAX_CONTRIBUTION_PER_REPORT); + + // Anti-cheat: rate-limit reports + const now = Date.now(); + if (player.lastContribTime && now - player.lastContribTime < MIN_REPORT_INTERVAL_MS) { + return; // Too fast, ignore + } + player.lastContribTime = now; + try { // Update streak const streakDays = await updateStreak(user.id); const streakMult = streakDays > 0 ? getStreakMultiplier(streakDays) : 1.0; // Apply streak multiplier to contribution - const boostedAmount = Math.floor(amount * streakMult); + const boostedAmount = Math.floor(clampedAmount * streakMult); + + const season = await getCurrentSeason(); + if (!season) return; const totals = await addToCosmicWar(player.side, boostedAmount); if (totals) { - // Record individual contribution - await recordContribution(user.id, player.side, boostedAmount); + // Record contribution (upsert — aggregates per user+season+side) + await recordContribution(user.id, season.season, player.side, boostedAmount); // Broadcast updated war state to all io.emit('cosmic-war', { totalLight: Number(totals.total_light), diff --git a/server/schema.sql b/server/schema.sql index a307e50..693f6c2 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS users ( -- Cosmic War — one row per active season CREATE TABLE IF NOT EXISTS cosmic_war ( id SERIAL PRIMARY KEY, - season INT NOT NULL DEFAULT 1, + season INT NOT NULL DEFAULT 1 UNIQUE, total_light BIGINT DEFAULT 0, total_dark BIGINT DEFAULT 0, started_at TIMESTAMPTZ DEFAULT NOW(), @@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS cosmic_war ( -- Seed the first season INSERT INTO cosmic_war (season) VALUES (1) - ON CONFLICT DO NOTHING; + ON CONFLICT (season) DO NOTHING; -- Per-player contributions to the current cosmic war CREATE TABLE IF NOT EXISTS contributions ( @@ -72,6 +72,10 @@ CREATE TABLE IF NOT EXISTS season_rewards ( CREATE INDEX IF NOT EXISTS idx_season_rewards_user ON season_rewards(user_id); CREATE INDEX IF NOT EXISTS idx_season_rewards_season ON season_rewards(season); +-- Unique index for contribution aggregation (upsert per user+season+side) +CREATE UNIQUE INDEX IF NOT EXISTS idx_contributions_user_season_mode + ON contributions(user_id, season, game_mode); + -- Migrations: add columns if they don't exist (safe for existing databases) DO $$ BEGIN ALTER TABLE users ADD COLUMN IF NOT EXISTS contribution_rate INT DEFAULT 25; @@ -82,3 +86,9 @@ DO $$ BEGIN ALTER TABLE cosmic_war ADD COLUMN IF NOT EXISTS winner VARCHAR(5); EXCEPTION WHEN OTHERS THEN NULL; END $$; + +-- Add UNIQUE constraint on cosmic_war.season for existing databases +DO $$ BEGIN + ALTER TABLE cosmic_war ADD CONSTRAINT cosmic_war_season_unique UNIQUE (season); +EXCEPTION WHEN OTHERS THEN NULL; +END $$; diff --git a/style.css b/style.css index e86ece0..1714b1a 100644 --- a/style.css +++ b/style.css @@ -2157,3 +2157,57 @@ body.mode-off .mp-streak-toast { color: rgba(0, 0, 0, 0.8); border-color: rgba(200, 50, 50, 0.4); } + +/* === OVERLAY LOGIN PROMPT (unauthenticated) === */ +#mp-overlay-login { + text-align: center; + margin-top: 8px; +} + +#mp-overlay-login-text { + font-size: 0.6rem; + letter-spacing: 1px; + opacity: 0.5; + margin-bottom: 10px; +} + +#mp-overlay-login-btns { + display: flex; + gap: 10px; + justify-content: center; +} + +.mp-login-btn { + display: inline-block; + background: none; + border: 1px solid rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.7); + padding: 8px 18px; + font-family: 'Courier New', Courier, monospace; + font-size: 0.7rem; + letter-spacing: 1px; + cursor: pointer; + text-decoration: none; + transition: + border-color 0.2s, + color 0.2s; +} + +.mp-login-btn:hover { + border-color: rgba(255, 255, 255, 0.6); + color: #fff; +} + +body.mode-off .mp-login-btn { + border-color: rgba(0, 0, 0, 0.3); + color: rgba(0, 0, 0, 0.7); +} + +body.mode-off .mp-login-btn:hover { + border-color: rgba(0, 0, 0, 0.6); + color: #000; +} + +body.mode-off #mp-overlay-login-text { + color: #000; +} diff --git a/tests/unit/multiplayer.test.js b/tests/unit/multiplayer.test.js new file mode 100644 index 0000000..bd0db44 --- /dev/null +++ b/tests/unit/multiplayer.test.js @@ -0,0 +1,106 @@ +// === Tests for multiplayer.js — pure logic functions === + +import { describe, it, expect, beforeEach } from 'vitest'; +import { mp, reportLumens, getContributionAmount } from '../../js/multiplayer.js'; + +describe('reportLumens', () => { + beforeEach(() => { + mp.pendingLumens = 0; + }); + + it('should accumulate pending lumens', () => { + reportLumens(100); + expect(mp.pendingLumens).toBe(100); + reportLumens(250); + expect(mp.pendingLumens).toBe(350); + }); + + it('should handle zero amount', () => { + reportLumens(0); + expect(mp.pendingLumens).toBe(0); + }); + + it('should handle fractional amounts', () => { + reportLumens(1.5); + reportLumens(2.7); + expect(mp.pendingLumens).toBeCloseTo(4.2); + }); +}); + +describe('getContributionAmount', () => { + beforeEach(() => { + mp.contributionRate = 25; + }); + + it('should apply default 25% rate', () => { + expect(getContributionAmount(1000)).toBe(250); + }); + + it('should apply 10% rate', () => { + mp.contributionRate = 10; + expect(getContributionAmount(1000)).toBe(100); + }); + + it('should apply 50% rate', () => { + mp.contributionRate = 50; + expect(getContributionAmount(1000)).toBe(500); + }); + + it('should apply 100% rate', () => { + mp.contributionRate = 100; + expect(getContributionAmount(1000)).toBe(1000); + }); + + it('should floor the result', () => { + mp.contributionRate = 10; + expect(getContributionAmount(33)).toBe(3); + }); + + it('should return 0 for zero lumens', () => { + expect(getContributionAmount(0)).toBe(0); + }); +}); + +describe('offline queue (localStorage)', () => { + beforeEach(() => { + mp.pendingLumens = 0; + localStorage.clear(); + }); + + it('should store pending lumens to localStorage key', () => { + mp.pendingLumens = 500; + localStorage.setItem('light-mp-offline-queue', String(mp.pendingLumens)); + expect(localStorage.getItem('light-mp-offline-queue')).toBe('500'); + }); + + it('should restore from localStorage', () => { + localStorage.setItem('light-mp-offline-queue', '1234'); + var val = Number(localStorage.getItem('light-mp-offline-queue')); + mp.pendingLumens += val; + expect(mp.pendingLumens).toBe(1234); + }); + + it('should handle missing queue gracefully', () => { + var val = localStorage.getItem('light-mp-offline-queue'); + expect(val).toBeNull(); + }); +}); + +describe('mp state initialization', () => { + it('should have default values', () => { + expect(mp.connected).toBe(false); + expect(mp.contributionRate).toBe(25); + expect(mp.cosmicWar.totalLight).toBe(0); + expect(mp.cosmicWar.totalDark).toBe(0); + expect(mp.online.total).toBe(0); + expect(mp.pendingRewards).toEqual([]); + expect(mp.seasonEndData).toBeNull(); + }); + + it('should have season info defaults', () => { + expect(mp.seasonInfo.season).toBe(0); + expect(mp.seasonInfo.remainingDays).toBe(0); + expect(mp.seasonInfo.isLastDay).toBe(false); + expect(mp.seasonInfo.endsAt).toBeNull(); + }); +});