import { CURATED_PUZZLES } from '../data/puzzles.js'; import { getRankInfo, QUARTILE_COMPLETION_BONUS } from '../engine/constants.js'; class GameStateManager { constructor() { this.currentPuzzle = null; this.boardTiles = []; // Array of { id, text, isQuartileSolved, isLocked } this.activeTileIds = []; // Array of tile IDs in construction tray this.foundWords = new Map(); // word -> wordData this.score = 0; this.quartilesBonusAwarded = false; this.lockQuartiles = localStorage.getItem('quartiles_lock_setting') !== 'false'; this.listeners = new Set(); this.toastListeners = new Set(); this.isRevealed = false; this.hintIndex = 0; } subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); } notify() { for (const listener of this.listeners) { listener(this); } } subscribeToast(listener) { this.toastListeners.add(listener); return () => this.toastListeners.delete(listener); } notifyToast(message, type = 'info', duration = 2500) { for (const listener of this.toastListeners) { listener({ message, type, duration }); } } // Load a puzzle (by object or find by date/id) loadPuzzle(puzzleOrId) { let puzzle = puzzleOrId; if (typeof puzzleOrId === 'string') { puzzle = CURATED_PUZZLES.find(p => p.id === puzzleOrId || p.date === puzzleOrId) || CURATED_PUZZLES[0]; } this.currentPuzzle = puzzle; this.activeTileIds = []; this.foundWords = new Map(); this.score = 0; this.quartilesBonusAwarded = false; this.isRevealed = false; this.hintIndex = 0; // Initialize board tiles from the 20 puzzle tiles this.boardTiles = puzzle.tiles.map((text, id) => ({ id, text: text.toUpperCase(), originalText: text, isQuartileSolved: false, isLocked: false })); // Restore saved state from LocalStorage if available const hasSavedTileOrder = this.restoreProgress(); // If fresh puzzle without saved order, scramble the board so target words are not formed! if (!hasSavedTileOrder) { this.scrambleBoard(puzzle.id || String(Date.now())); } // Organize board tiles according to lock state this.reorganizeBoard(); this.notify(); } // Seeded Fisher-Yates scramble for initial board dealing scrambleBoard(seed = '') { let h = 0x811c9dc5; for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193); } let s = (h >>> 0) || 123456789; const nextRandom = () => { s = (Math.imul(1664525, s) + 1013904223) >>> 0; return s / 4294967296; }; const shuffled = [...this.boardTiles]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(nextRandom() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } this.boardTiles = shuffled; this.saveProgress(); } // Get current date string in YYYY-MM-DD getTodayDateString() { const today = new Date(); const y = today.getFullYear(); const m = String(today.getMonth() + 1).padStart(2, '0'); const d = String(today.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } // Save current puzzle progress to LocalStorage saveProgress() { if (!this.currentPuzzle) return; const saveKey = `quartiles_save_${this.currentPuzzle.id}`; const payload = { foundWords: Array.from(this.foundWords.entries()), score: this.score, bonus: this.quartilesBonusAwarded, tileOrder: this.boardTiles.map(t => t.id) }; try { localStorage.setItem(saveKey, JSON.stringify(payload)); } catch (err) { console.warn('LocalStorage save failed:', err); } } // Restore puzzle progress restoreProgress() { if (!this.currentPuzzle) return false; const saveKey = `quartiles_save_${this.currentPuzzle.id}`; try { const saved = localStorage.getItem(saveKey); if (saved) { const parsed = JSON.parse(saved); if (parsed && Array.isArray(parsed.foundWords)) { this.foundWords = new Map(parsed.foundWords); this.score = parsed.score || 0; this.quartilesBonusAwarded = !!parsed.bonus; // Re-mark solved quartiles this.updateSolvedQuartileTiles(); // Restore tile order if saved if (Array.isArray(parsed.tileOrder) && parsed.tileOrder.length === 20) { // Check if saved order was the trivial unscrambled [0..19] order const isSequential = parsed.tileOrder.every((id, idx) => id === idx); if (isSequential && this.foundWords.size === 0) { return false; } const tileMap = new Map(this.boardTiles.map(t => [t.id, t])); const reordered = parsed.tileOrder.map(id => tileMap.get(id)).filter(Boolean); if (reordered.length === 20) { this.boardTiles = reordered; return true; } } } } } catch (err) { console.warn('Failed to restore progress:', err); } return false; } // Check if a tile belongs to an already found Quartile updateSolvedQuartileTiles() { const solvedQuartileWords = new Set(); for (const [w, data] of this.foundWords.entries()) { if (data.isTargetQuartile) { solvedQuartileWords.add(w.toLowerCase()); } } // For each target quartet, check if solved this.boardTiles.forEach(tile => { tile.isQuartileSolved = false; tile.isLocked = false; }); for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) { const quartet = this.currentPuzzle.quartets[qIdx]; const word = quartet.join('').toLowerCase(); if (solvedQuartileWords.has(word)) { // Mark tiles belonging to this quartet const startTileIndex = qIdx * 4; for (let i = startTileIndex; i < startTileIndex + 4; i++) { const tile = this.boardTiles.find(t => t.id === i); if (tile) { tile.isQuartileSolved = true; if (this.lockQuartiles) { tile.isLocked = true; } } } } } } // Reorganizes the board: locked solved quartiles go to top rows reorganizeBoard() { this.updateSolvedQuartileTiles(); if (this.lockQuartiles) { // Group locked solved quartiles first in neat rows const lockedTiles = []; for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) { const quartet = this.currentPuzzle.quartets[qIdx]; const word = quartet.join('').toLowerCase(); if (this.foundWords.has(word)) { const start = qIdx * 4; for (let i = start; i < start + 4; i++) { const t = this.boardTiles.find(tile => tile.id === i); if (t) lockedTiles.push(t); } } } const lockedSet = new Set(lockedTiles.map(t => t.id)); const unlockedTiles = this.boardTiles.filter(t => !lockedSet.has(t.id)); this.boardTiles = [...lockedTiles, ...unlockedTiles]; } } // Toggle lock quartiles setting toggleLockQuartiles() { this.lockQuartiles = !this.lockQuartiles; localStorage.setItem('quartiles_lock_setting', this.lockQuartiles ? 'true' : 'false'); this.reorganizeBoard(); this.notify(); this.notifyToast( this.lockQuartiles ? 'Quartiles locked at top' : 'Quartiles unlocked for free shuffle', 'info' ); } // Tile clicked on board: add to tray or remove if already in tray onTileClicked(tileId) { const existingIndex = this.activeTileIds.indexOf(tileId); if (existingIndex !== -1) { // Already selected, remove it this.removeActiveTile(existingIndex); return; } if (this.activeTileIds.length >= 4) { this.notifyToast('Maximum 4 tiles per word', 'warning'); return; } this.activeTileIds.push(tileId); this.notify(); } // Remove tile from active tray by index removeActiveTile(index) { if (index >= 0 && index < this.activeTileIds.length) { this.activeTileIds.splice(index, 1); this.notify(); } } // Reorder tiles in active tray (e.g. from drag & drop) reorderActiveTiles(fromIndex, toIndex) { if ( fromIndex >= 0 && fromIndex < this.activeTileIds.length && toIndex >= 0 && toIndex < this.activeTileIds.length && fromIndex !== toIndex ) { const [item] = this.activeTileIds.splice(fromIndex, 1); this.activeTileIds.splice(toIndex, 0, item); this.notify(); } } // Backspace: removes last tile in tray backspace() { if (this.activeTileIds.length > 0) { this.activeTileIds.pop(); this.notify(); } } // Clear all tiles in tray clearActive() { if (this.activeTileIds.length > 0) { this.activeTileIds = []; this.notify(); } } // Shuffle unlocked tiles shuffle() { const lockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => t.isLocked) : []; const unlockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => !t.isLocked) : [...this.boardTiles]; // Fisher-Yates shuffle for (let i = unlockedTiles.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [unlockedTiles[i], unlockedTiles[j]] = [unlockedTiles[j], unlockedTiles[i]]; } this.boardTiles = [...lockedTiles, ...unlockedTiles]; this.saveProgress(); this.notify(); } // Sort unlocked tiles alphabetically sortAlphabetical() { const lockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => t.isLocked) : []; const unlockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => !t.isLocked) : [...this.boardTiles]; unlockedTiles.sort((a, b) => a.text.localeCompare(b.text)); this.boardTiles = [...lockedTiles, ...unlockedTiles]; this.saveProgress(); this.notify(); } // Submit current word in active construction tray submitWord() { if (this.activeTileIds.length === 0) { this.notifyToast('Tap tiles to form a word', 'info'); return { success: false, reason: 'empty' }; } const constructedWord = this.activeTileIds .map(id => { const tile = this.boardTiles.find(t => t.id === id); return tile ? tile.text.toLowerCase() : ''; }) .join(''); // Check if already found if (this.foundWords.has(constructedWord)) { this.notifyToast(`"${constructedWord.toUpperCase()}" already found!`, 'warning'); this.activeTileIds = []; this.notify(); return { success: false, reason: 'duplicate' }; } // Look up in current puzzle's valid words list const match = this.currentPuzzle.words.find(w => w.word.toLowerCase() === constructedWord); if (!match) { this.notifyToast(`"${constructedWord.toUpperCase()}" is not in the word list`, 'error'); return { success: false, reason: 'invalid' }; } // Valid word! const tileSegments = this.activeTileIds.map(id => { const tile = this.boardTiles.find(t => t.id === id); return tile ? tile.text : ''; }); const tileCount = this.activeTileIds.length; const isTargetQuartile = this.currentPuzzle.targetQuartiles.some(t => t.toLowerCase() === constructedWord); const isQuartile = tileCount === 4; const wordEntry = { word: constructedWord, points: match.points, tileCount, tileSegments, isTargetQuartile, isQuartile, foundAt: Date.now() }; this.foundWords.set(constructedWord, wordEntry); this.score += match.points; // Check if all 5 target quartiles are solved let quartilesCount = 0; for (const target of this.currentPuzzle.targetQuartiles) { if (this.foundWords.has(target.toLowerCase())) { quartilesCount++; } } let bonusUnlocked = false; if (quartilesCount === 5 && !this.quartilesBonusAwarded) { this.quartilesBonusAwarded = true; this.score += QUARTILE_COMPLETION_BONUS; bonusUnlocked = true; } // Clear active tray this.activeTileIds = []; // Reorganize board if quartiles locked this.reorganizeBoard(); // Save progress and update player stats this.saveProgress(); this.updateStats(); this.notify(); return { success: true, word: constructedWord, points: match.points, isQuartile, isTargetQuartile, quartilesFound: quartilesCount, bonusUnlocked }; } // Get target quartiles solved count getTargetQuartilesSolvedCount() { if (!this.currentPuzzle) return 0; let count = 0; for (const q of this.currentPuzzle.targetQuartiles) { if (this.foundWords.has(q.toLowerCase())) count++; } return count; } // Get rank data getRank() { return getRankInfo( this.score, this.foundWords.size, this.currentPuzzle ? this.currentPuzzle.wordCount : 0 ); } // Progressive hint system getHint() { if (!this.currentPuzzle) return null; // 1. Prioritize unfound target Quartiles const unfoundQuartiles = this.currentPuzzle.quartileDetails.filter( qd => !this.foundWords.has(qd.word.toLowerCase()) ); if (unfoundQuartiles.length > 0) { const target = unfoundQuartiles[this.hintIndex % unfoundQuartiles.length]; this.hintIndex++; return { type: 'quartile', word: target.word, firstTile: target.tiles[0].toUpperCase(), clue: target.clue || 'A 4-part Quartile word', remainingQuartiles: unfoundQuartiles.length }; } // 2. Unfound other words const unfoundWords = this.currentPuzzle.words.filter( w => !this.foundWords.has(w.word.toLowerCase()) ); if (unfoundWords.length > 0) { const sample = unfoundWords[Math.floor(Math.random() * unfoundWords.length)]; return { type: 'word', tileCount: sample.tileCount, firstTile: sample.tileSegments[0].toUpperCase(), points: sample.points, remainingWords: unfoundWords.length }; } return null; } // Reveal all words (when expert reached or explicitly requested) revealAllWords() { this.isRevealed = true; this.notify(); } // Update persistent stats in LocalStorage updateStats() { try { const statsKey = 'quartiles_player_stats'; const stats = JSON.parse(localStorage.getItem(statsKey) || '{}'); stats.totalWordsFound = (stats.totalWordsFound || 0) + 1; const rank = this.getRank(); if (rank.isExpert) { stats.expertPuzzles = stats.expertPuzzles || {}; stats.expertPuzzles[this.currentPuzzle.id] = true; } if (rank.isPerfect) { stats.perfectPuzzles = stats.perfectPuzzles || {}; stats.perfectPuzzles[this.currentPuzzle.id] = true; } localStorage.setItem(statsKey, JSON.stringify(stats)); } catch (err) { console.warn('Stats update error:', err); } } // Get player stats getStats() { try { const statsKey = 'quartiles_player_stats'; const stats = JSON.parse(localStorage.getItem(statsKey) || '{}'); const expertCount = Object.keys(stats.expertPuzzles || {}).length; const perfectCount = Object.keys(stats.perfectPuzzles || {}).length; return { totalWordsFound: stats.totalWordsFound || 0, expertCount, perfectCount }; } catch (err) { return { totalWordsFound: 0, expertCount: 0, perfectCount: 0 }; } } } export const gameState = new GameStateManager();