- Add checks in handleGuess, handleSkip, and handleGiveUp to prevent actions on solved/failed puzzles - Add protection in addGuess to prevent adding guesses to solved puzzles - Fix and simplify backend state loading logic - Ensure solved puzzles cannot be replayed when switching domains
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { getTodayISOString } from './dateUtils';
|
|
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
|
|
|
|
export interface GameState {
|
|
date: string;
|
|
guesses: string[]; // Array of song titles or IDs guessed
|
|
isSolved: boolean;
|
|
isFailed: boolean;
|
|
lastPlayed: number; // Timestamp
|
|
score: number;
|
|
replayCount: number;
|
|
skipCount: number;
|
|
scoreBreakdown: Array<{ value: number; reason: string }>;
|
|
yearGuessed: boolean;
|
|
}
|
|
|
|
export interface Statistics {
|
|
solvedIn1: number;
|
|
solvedIn2: number;
|
|
solvedIn3: number;
|
|
solvedIn4: number;
|
|
solvedIn5: number;
|
|
solvedIn6: number;
|
|
solvedIn7: number;
|
|
failed: number;
|
|
}
|
|
|
|
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
|
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
|
|
|
const INITIAL_SCORE = 90;
|
|
|
|
export function useGameState(
|
|
genre: string | null = null,
|
|
maxAttempts: number = 7,
|
|
isSpecial: boolean = false
|
|
) {
|
|
const [gameState, setGameState] = useState<GameState | null>(null);
|
|
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
|
|
|
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
|
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
|
|
|
// Get genre key for backend storage
|
|
// For specials, genre contains the special name
|
|
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
|
|
|
const createNewState = (date: string): GameState => ({
|
|
date,
|
|
guesses: [],
|
|
isSolved: false,
|
|
isFailed: false,
|
|
lastPlayed: Date.now(),
|
|
score: INITIAL_SCORE,
|
|
replayCount: 0,
|
|
skipCount: 0,
|
|
scoreBreakdown: [{ value: INITIAL_SCORE, reason: 'Start value' }],
|
|
yearGuessed: false
|
|
});
|
|
|
|
const createNewStatistics = (): Statistics => ({
|
|
solvedIn1: 0,
|
|
solvedIn2: 0,
|
|
solvedIn3: 0,
|
|
solvedIn4: 0,
|
|
solvedIn5: 0,
|
|
solvedIn6: 0,
|
|
solvedIn7: 0,
|
|
failed: 0,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const today = getTodayISOString();
|
|
|
|
// Try to load from backend first
|
|
const loadFromBackend = async () => {
|
|
try {
|
|
const backendState = await loadPlayerState(genreKey);
|
|
|
|
if (backendState) {
|
|
const { gameState: loadedState, statistics: loadedStats } = backendState;
|
|
|
|
// Check if the loaded state is for today
|
|
if (loadedState.date === today) {
|
|
setGameState(loadedState);
|
|
setStatistics(loadedStats);
|
|
return; // Successfully loaded from backend
|
|
} else {
|
|
// State is for a different day - create new state
|
|
const newState = createNewState(today);
|
|
setGameState(newState);
|
|
setStatistics(loadedStats); // Keep statistics across days
|
|
// Save new state to backend
|
|
await savePlayerState(genreKey, newState, loadedStats);
|
|
return;
|
|
}
|
|
} else {
|
|
// No backend state found - check localStorage before creating new state
|
|
const storageKey = getStorageKey();
|
|
const stored = localStorage.getItem(storageKey);
|
|
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (parsed.date === today) {
|
|
// Migration for existing states without score
|
|
if (parsed.score === undefined) {
|
|
parsed.score = INITIAL_SCORE;
|
|
parsed.replayCount = 0;
|
|
parsed.skipCount = 0;
|
|
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
|
|
parsed.yearGuessed = false;
|
|
}
|
|
setGameState(parsed as GameState);
|
|
// Also save to backend for cross-domain sync
|
|
const statsKey = getStatsKey();
|
|
const storedStats = localStorage.getItem(statsKey);
|
|
const stats = storedStats ? JSON.parse(storedStats) : createNewStatistics();
|
|
if (!statistics) {
|
|
setStatistics(stats);
|
|
}
|
|
await savePlayerState(genreKey, parsed as GameState, stats);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No state found - create new state
|
|
const newState = createNewState(today);
|
|
setGameState(newState);
|
|
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
|
const newStats = createNewStatistics();
|
|
setStatistics(newStats);
|
|
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
|
await savePlayerState(genreKey, newState, newStats);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
console.warn('[gameState] Failed to load from backend, falling back to localStorage:', error);
|
|
|
|
// Fallback to localStorage only on error
|
|
const storageKey = getStorageKey();
|
|
const stored = localStorage.getItem(storageKey);
|
|
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (parsed.date === today) {
|
|
// Migration for existing states without score
|
|
if (parsed.score === undefined) {
|
|
parsed.score = INITIAL_SCORE;
|
|
parsed.replayCount = 0;
|
|
parsed.skipCount = 0;
|
|
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
|
|
parsed.yearGuessed = false;
|
|
}
|
|
setGameState(parsed as GameState);
|
|
|
|
// Load statistics from localStorage
|
|
const statsKey = getStatsKey();
|
|
const storedStats = localStorage.getItem(statsKey);
|
|
if (storedStats) {
|
|
const parsedStats = JSON.parse(storedStats);
|
|
if (parsedStats.solvedIn7 === undefined) {
|
|
parsedStats.solvedIn7 = 0;
|
|
}
|
|
setStatistics(parsedStats);
|
|
} else {
|
|
const newStats = createNewStatistics();
|
|
setStatistics(newStats);
|
|
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No state found - create new state
|
|
const newState = createNewState(today);
|
|
setGameState(newState);
|
|
localStorage.setItem(storageKey, JSON.stringify(newState));
|
|
const newStats = createNewStatistics();
|
|
setStatistics(newStats);
|
|
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
|
}
|
|
};
|
|
|
|
loadFromBackend();
|
|
}, [genre, isSpecial, genreKey]); // Re-run when genre or isSpecial changes
|
|
|
|
const saveState = async (newState: GameState) => {
|
|
setGameState(newState);
|
|
|
|
// Save to backend (primary)
|
|
if (statistics) {
|
|
try {
|
|
await savePlayerState(genreKey, newState, statistics);
|
|
} catch (error) {
|
|
console.warn('[gameState] Failed to save to backend, using localStorage fallback:', error);
|
|
}
|
|
}
|
|
|
|
// Also save to localStorage as backup
|
|
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
|
};
|
|
|
|
const updateStatistics = async (attempts: number, solved: boolean) => {
|
|
if (!statistics) return;
|
|
|
|
const newStats = { ...statistics };
|
|
|
|
if (solved) {
|
|
switch (attempts) {
|
|
case 1: newStats.solvedIn1++; break;
|
|
case 2: newStats.solvedIn2++; break;
|
|
case 3: newStats.solvedIn3++; break;
|
|
case 4: newStats.solvedIn4++; break;
|
|
case 5: newStats.solvedIn5++; break;
|
|
case 6: newStats.solvedIn6++; break;
|
|
case 7: newStats.solvedIn7++; break;
|
|
default:
|
|
break;
|
|
}
|
|
} else {
|
|
newStats.failed++;
|
|
}
|
|
|
|
setStatistics(newStats);
|
|
|
|
// Save to backend (primary)
|
|
if (gameState) {
|
|
try {
|
|
await savePlayerState(genreKey, gameState, newStats);
|
|
} catch (error) {
|
|
console.warn('[gameState] Failed to save statistics to backend, using localStorage fallback:', error);
|
|
}
|
|
}
|
|
|
|
// Also save to localStorage as backup
|
|
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
|
};
|
|
|
|
const addGuess = (guess: string, correct: boolean) => {
|
|
if (!gameState) return;
|
|
// Prevent adding guesses if already solved or failed
|
|
if (gameState.isSolved || gameState.isFailed) return;
|
|
|
|
const newGuesses = [...gameState.guesses, guess];
|
|
const isSolved = correct;
|
|
const isFailed = !correct && newGuesses.length >= maxAttempts;
|
|
|
|
let newScore = gameState.score;
|
|
const newBreakdown = [...gameState.scoreBreakdown];
|
|
|
|
if (correct) {
|
|
newScore += 20;
|
|
newBreakdown.push({ value: 20, reason: 'Correct Answer' });
|
|
} else {
|
|
if (guess === 'SKIPPED') {
|
|
newScore -= 5;
|
|
newBreakdown.push({ value: -5, reason: 'Skip' });
|
|
} else {
|
|
newScore -= 3;
|
|
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
|
|
}
|
|
}
|
|
|
|
// If failed, reset score to 0
|
|
if (isFailed) {
|
|
if (newScore > 0) {
|
|
newBreakdown.push({ value: -newScore, reason: 'Game Over' });
|
|
newScore = 0;
|
|
}
|
|
}
|
|
|
|
// Ensure score doesn't go below 0
|
|
newScore = Math.max(0, newScore);
|
|
|
|
const newState = {
|
|
...gameState,
|
|
guesses: newGuesses,
|
|
isSolved,
|
|
isFailed,
|
|
lastPlayed: Date.now(),
|
|
score: newScore,
|
|
scoreBreakdown: newBreakdown,
|
|
// Update skip count if skipped
|
|
skipCount: guess === 'SKIPPED' ? gameState.skipCount + 1 : gameState.skipCount
|
|
};
|
|
|
|
saveState(newState);
|
|
|
|
// Update statistics when game ends
|
|
if (isSolved || isFailed) {
|
|
updateStatistics(newGuesses.length, isSolved);
|
|
}
|
|
};
|
|
|
|
const giveUp = () => {
|
|
if (!gameState || gameState.isSolved || gameState.isFailed) return;
|
|
|
|
let newScore = 0;
|
|
const newBreakdown = [...gameState.scoreBreakdown];
|
|
|
|
if (gameState.score > 0) {
|
|
newBreakdown.push({ value: -gameState.score, reason: 'Gave Up' });
|
|
}
|
|
|
|
const newState = {
|
|
...gameState,
|
|
isFailed: true,
|
|
score: 0,
|
|
scoreBreakdown: newBreakdown,
|
|
lastPlayed: Date.now()
|
|
};
|
|
saveState(newState);
|
|
updateStatistics(gameState.guesses.length, false);
|
|
};
|
|
|
|
const addReplay = () => {
|
|
if (!gameState || gameState.isSolved || gameState.isFailed) return;
|
|
|
|
let newScore = gameState.score - 1;
|
|
// Ensure score doesn't go below 0
|
|
newScore = Math.max(0, newScore);
|
|
|
|
const newBreakdown = [...gameState.scoreBreakdown, { value: -1, reason: 'Replay snippet' }];
|
|
|
|
const newState = {
|
|
...gameState,
|
|
replayCount: gameState.replayCount + 1,
|
|
score: newScore,
|
|
scoreBreakdown: newBreakdown
|
|
};
|
|
saveState(newState);
|
|
};
|
|
|
|
const addYearBonus = (correct: boolean) => {
|
|
if (!gameState) return;
|
|
|
|
let newScore = gameState.score;
|
|
const newBreakdown = [...gameState.scoreBreakdown];
|
|
|
|
if (correct) {
|
|
newScore += 10;
|
|
newBreakdown.push({ value: 10, reason: 'Bonus: Correct Year' });
|
|
} else {
|
|
newBreakdown.push({ value: 0, reason: 'Bonus: Wrong Year' });
|
|
}
|
|
|
|
const newState = {
|
|
...gameState,
|
|
score: newScore,
|
|
scoreBreakdown: newBreakdown,
|
|
yearGuessed: true
|
|
};
|
|
saveState(newState);
|
|
};
|
|
|
|
const skipYearBonus = () => {
|
|
if (!gameState) return;
|
|
|
|
const newBreakdown = [...gameState.scoreBreakdown, { value: 0, reason: 'Bonus: Skipped' }];
|
|
|
|
const newState = {
|
|
...gameState,
|
|
scoreBreakdown: newBreakdown,
|
|
yearGuessed: true
|
|
};
|
|
saveState(newState);
|
|
};
|
|
|
|
return { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus };
|
|
}
|