Files
quartiles/src/state/game-state.js

521 lines
15 KiB
JavaScript

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();