Spaces:
Running
Running
| /** | |
| * Leaderboard Service | |
| * Manages high scores, player stats, with HF Hub persistence and localStorage fallback | |
| * Following arcade conventions with 3-letter initials and top 10 tracking | |
| */ | |
| import { HFLeaderboardAPI } from './hfLeaderboardAPI.js'; | |
| export class LeaderboardService { | |
| constructor() { | |
| this.storageKeys = { | |
| leaderboard: 'cloze-reader-leaderboard', | |
| player: 'cloze-reader-player', | |
| stats: 'cloze-reader-stats' | |
| }; | |
| this.maxEntries = 10; | |
| // Initialize HF API client | |
| this.hfAPI = new HFLeaderboardAPI(); | |
| this.useHF = false; // Will be set based on availability check | |
| // Check HF availability and initialize | |
| this.initializeAsync(); | |
| } | |
| /** | |
| * Async initialization to check HF availability | |
| */ | |
| async initializeAsync() { | |
| try { | |
| this.useHF = await this.hfAPI.isAvailable(); | |
| console.log(`π§ LEADERBOARD: Using ${this.useHF ? 'HF Hub backend' : 'localStorage only'}`); | |
| } catch (error) { | |
| console.warn('β οΈ LEADERBOARD: HF backend unavailable, using localStorage', error); | |
| this.useHF = false; | |
| } | |
| // Reset all data on initialization (fresh start each session) | |
| this.resetAll(); | |
| this.initializeStorage(); | |
| // If HF is available, sync from HF to localStorage | |
| if (this.useHF) { | |
| await this.syncFromHF(); | |
| } | |
| } | |
| /** | |
| * Sync leaderboard from HF Hub to localStorage | |
| */ | |
| async syncFromHF() { | |
| try { | |
| const hfLeaderboard = await this.hfAPI.getLeaderboard(); | |
| if (hfLeaderboard && hfLeaderboard.length > 0) { | |
| this.saveLeaderboard(hfLeaderboard); | |
| console.log('π₯ LEADERBOARD: Synced from HF Hub', { entries: hfLeaderboard.length }); | |
| } | |
| } catch (error) { | |
| console.error('β LEADERBOARD: Failed to sync from HF', error); | |
| } | |
| } | |
| /** | |
| * Initialize localStorage with default values if needed | |
| */ | |
| initializeStorage() { | |
| if (!this.getLeaderboard()) { | |
| this.saveLeaderboard([]); | |
| } | |
| if (!this.getPlayerProfile()) { | |
| this.savePlayerProfile({ | |
| initials: null, | |
| hasEnteredInitials: false, | |
| gamesPlayed: 0, | |
| lastPlayed: null | |
| }); | |
| } | |
| if (!this.getStats()) { | |
| this.saveStats(this.createEmptyStats()); | |
| } | |
| } | |
| /** | |
| * Create empty stats object | |
| */ | |
| createEmptyStats() { | |
| return { | |
| highestLevel: 1, | |
| roundAtHighestLevel: 1, | |
| totalPassagesPassed: 0, | |
| totalPassagesAttempted: 0, | |
| longestStreak: 0, | |
| currentStreak: 0, | |
| totalCorrectWords: 0, | |
| uniqueWordsCorrect: new Set(), | |
| gamesPlayed: 0, | |
| lastPlayed: null | |
| }; | |
| } | |
| /** | |
| * Get leaderboard from localStorage | |
| */ | |
| getLeaderboard() { | |
| try { | |
| const data = localStorage.getItem(this.storageKeys.leaderboard); | |
| return data ? JSON.parse(data) : null; | |
| } catch (e) { | |
| console.error('Error reading leaderboard:', e); | |
| return []; | |
| } | |
| } | |
| /** | |
| * Save leaderboard to localStorage | |
| */ | |
| saveLeaderboard(entries) { | |
| try { | |
| localStorage.setItem(this.storageKeys.leaderboard, JSON.stringify(entries)); | |
| } catch (e) { | |
| console.error('Error saving leaderboard:', e); | |
| } | |
| } | |
| /** | |
| * Get player profile from localStorage | |
| */ | |
| getPlayerProfile() { | |
| try { | |
| const data = localStorage.getItem(this.storageKeys.player); | |
| return data ? JSON.parse(data) : null; | |
| } catch (e) { | |
| console.error('Error reading player profile:', e); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Save player profile to localStorage | |
| */ | |
| savePlayerProfile(profile) { | |
| try { | |
| localStorage.setItem(this.storageKeys.player, JSON.stringify(profile)); | |
| } catch (e) { | |
| console.error('Error saving player profile:', e); | |
| } | |
| } | |
| /** | |
| * Get stats from localStorage | |
| */ | |
| getStats() { | |
| try { | |
| const data = localStorage.getItem(this.storageKeys.stats); | |
| if (!data) return null; | |
| const stats = JSON.parse(data); | |
| // Convert uniqueWordsCorrect back to Set | |
| if (stats.uniqueWordsCorrect && Array.isArray(stats.uniqueWordsCorrect)) { | |
| stats.uniqueWordsCorrect = new Set(stats.uniqueWordsCorrect); | |
| } else { | |
| stats.uniqueWordsCorrect = new Set(); | |
| } | |
| return stats; | |
| } catch (e) { | |
| console.error('Error reading stats:', e); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Save stats to localStorage | |
| */ | |
| saveStats(stats) { | |
| try { | |
| // Convert Set to Array for JSON serialization | |
| const statsToSave = { | |
| ...stats, | |
| uniqueWordsCorrect: Array.from(stats.uniqueWordsCorrect || []) | |
| }; | |
| localStorage.setItem(this.storageKeys.stats, JSON.stringify(statsToSave)); | |
| } catch (e) { | |
| console.error('Error saving stats:', e); | |
| } | |
| } | |
| /** | |
| * Validate and sanitize initials (3 letters, A-Z only) | |
| */ | |
| validateInitials(initials) { | |
| if (!initials || typeof initials !== 'string') { | |
| return false; | |
| } | |
| const sanitized = initials.toUpperCase().replace(/[^A-Z]/g, ''); | |
| return sanitized.length === 3 ? sanitized : false; | |
| } | |
| /** | |
| * Sort leaderboard entries | |
| * Primary: Level (desc), Secondary: Round (desc), Tertiary: Passages passed (desc) | |
| */ | |
| sortLeaderboard(entries) { | |
| return entries.sort((a, b) => { | |
| // Primary: Level (higher is better) | |
| if (b.level !== a.level) { | |
| return b.level - a.level; | |
| } | |
| // Secondary: Round at that level (higher is better) | |
| if (b.round !== a.round) { | |
| return b.round - a.round; | |
| } | |
| // Tertiary: Total passages passed (higher is better) | |
| if (b.passagesPassed !== a.passagesPassed) { | |
| return b.passagesPassed - a.passagesPassed; | |
| } | |
| // Quaternary: Date (newer is better) | |
| return new Date(b.date) - new Date(a.date); | |
| }); | |
| } | |
| /** | |
| * Check if a score qualifies for the leaderboard | |
| */ | |
| qualifiesForLeaderboard(level, round, passagesPassed) { | |
| const leaderboard = this.getLeaderboard(); | |
| // If leaderboard isn't full, always qualifies | |
| if (leaderboard.length < this.maxEntries) { | |
| return true; | |
| } | |
| // Check if better than lowest entry | |
| const lowestEntry = leaderboard[leaderboard.length - 1]; | |
| if (level > lowestEntry.level) return true; | |
| if (level === lowestEntry.level && round > lowestEntry.round) return true; | |
| if (level === lowestEntry.level && round === lowestEntry.round && passagesPassed > lowestEntry.passagesPassed) return true; | |
| return false; | |
| } | |
| /** | |
| * Get the rank position for a score (1-10, or null if doesn't qualify) | |
| */ | |
| getRankForScore(level, round, passagesPassed) { | |
| if (!this.qualifiesForLeaderboard(level, round, passagesPassed)) { | |
| return null; | |
| } | |
| const leaderboard = this.getLeaderboard(); | |
| const tempEntry = { level, round, passagesPassed, date: new Date().toISOString() }; | |
| const tempLeaderboard = [...leaderboard, tempEntry]; | |
| const sorted = this.sortLeaderboard(tempLeaderboard); | |
| return sorted.findIndex(entry => entry === tempEntry) + 1; | |
| } | |
| /** | |
| * Add a new entry to the leaderboard | |
| */ | |
| async addEntry(initials, level, round, passagesPassed) { | |
| const validInitials = this.validateInitials(initials); | |
| if (!validInitials) { | |
| console.error('Invalid initials:', initials); | |
| return false; | |
| } | |
| const leaderboard = this.getLeaderboard(); | |
| const newEntry = { | |
| initials: validInitials, | |
| level, | |
| round, | |
| passagesPassed, | |
| date: new Date().toISOString() | |
| }; | |
| leaderboard.push(newEntry); | |
| const sorted = this.sortLeaderboard(leaderboard); | |
| // Keep only top 10 | |
| const trimmed = sorted.slice(0, this.maxEntries); | |
| this.saveLeaderboard(trimmed); | |
| const rank = sorted.findIndex(entry => entry === newEntry) + 1; | |
| // If HF is available, also save to HF Hub | |
| if (this.useHF) { | |
| try { | |
| await this.hfAPI.addEntry(newEntry); | |
| console.log('π€ LEADERBOARD: Saved to HF Hub', { initials: validInitials, level, rank }); | |
| } catch (error) { | |
| console.error('β LEADERBOARD: Failed to save to HF, localStorage only', error); | |
| } | |
| } | |
| return rank; // Return rank | |
| } | |
| /** | |
| * Update session stats after a passage attempt | |
| */ | |
| updateStats(data) { | |
| const stats = this.getStats() || this.createEmptyStats(); | |
| console.log('π LEADERBOARD: Updating stats', { | |
| before: { attempted: stats.totalPassagesAttempted, passed: stats.totalPassagesPassed, level: stats.highestLevel }, | |
| passResult: data.passed, | |
| currentLevel: data.currentLevel | |
| }); | |
| stats.totalPassagesAttempted++; | |
| if (data.passed) { | |
| stats.totalPassagesPassed++; | |
| stats.currentStreak++; | |
| stats.longestStreak = Math.max(stats.longestStreak, stats.currentStreak); | |
| } else { | |
| stats.currentStreak = 0; | |
| } | |
| // Track highest level reached | |
| if (data.currentLevel > stats.highestLevel) { | |
| stats.highestLevel = data.currentLevel; | |
| stats.roundAtHighestLevel = data.round; | |
| } else if (data.currentLevel === stats.highestLevel) { | |
| stats.roundAtHighestLevel = Math.max(stats.roundAtHighestLevel, data.round); | |
| } | |
| // Track correct words | |
| if (data.results) { | |
| data.results.forEach(result => { | |
| if (result.isCorrect) { | |
| stats.totalCorrectWords++; | |
| const word = result.correctAnswer.toLowerCase(); | |
| stats.uniqueWordsCorrect.add(word); | |
| } | |
| }); | |
| } | |
| stats.lastPlayed = new Date().toISOString(); | |
| console.log('π LEADERBOARD: Stats updated', { | |
| after: { attempted: stats.totalPassagesAttempted, passed: stats.totalPassagesPassed, level: stats.highestLevel } | |
| }); | |
| this.saveStats(stats); | |
| return stats; | |
| } | |
| /** | |
| * Get formatted leaderboard for display | |
| */ | |
| getFormattedLeaderboard() { | |
| const leaderboard = this.getLeaderboard(); | |
| const player = this.getPlayerProfile(); | |
| const stats = this.getStats(); | |
| return { | |
| entries: leaderboard.map((entry, index) => ({ | |
| rank: index + 1, | |
| initials: entry.initials, | |
| level: entry.level, | |
| round: entry.round, | |
| passagesPassed: entry.passagesPassed, | |
| date: entry.date, | |
| isPlayer: player && player.initials === entry.initials | |
| })), | |
| playerBest: stats ? { | |
| level: stats.highestLevel, | |
| round: stats.roundAtHighestLevel, | |
| passagesPassed: stats.totalPassagesPassed | |
| } : null, | |
| playerInitials: player ? player.initials : null | |
| }; | |
| } | |
| /** | |
| * Reset all leaderboard data (fresh start each session) | |
| */ | |
| resetAll() { | |
| console.log('π LEADERBOARD: Starting fresh session (stats reset on page load)'); | |
| this.saveLeaderboard([]); | |
| this.savePlayerProfile({ | |
| initials: null, | |
| hasEnteredInitials: false, | |
| gamesPlayed: 0, | |
| lastPlayed: null | |
| }); | |
| this.saveStats(this.createEmptyStats()); | |
| } | |
| /** | |
| * Get player stats summary | |
| */ | |
| getPlayerStats() { | |
| const stats = this.getStats() || this.createEmptyStats(); | |
| const profile = this.getPlayerProfile(); | |
| return { | |
| initials: profile?.initials || '---', | |
| highestLevel: stats.highestLevel, | |
| roundAtHighestLevel: stats.roundAtHighestLevel, | |
| totalPassagesPassed: stats.totalPassagesPassed, | |
| totalPassagesAttempted: stats.totalPassagesAttempted, | |
| successRate: stats.totalPassagesAttempted > 0 | |
| ? Math.round((stats.totalPassagesPassed / stats.totalPassagesAttempted) * 100) | |
| : 0, | |
| longestStreak: stats.longestStreak, | |
| currentStreak: stats.currentStreak, | |
| totalCorrectWords: stats.totalCorrectWords, | |
| uniqueWords: stats.uniqueWordsCorrect.size, | |
| gamesPlayed: stats.gamesPlayed, | |
| lastPlayed: stats.lastPlayed | |
| }; | |
| } | |
| } | |