feat: Add backend storage for cross-domain player state synchronization

- Add PlayerState model to database schema for storing game states
- Create player identifier system (UUID-based) for cross-domain sync
- Implement API endpoints for loading/saving player states
- Refactor gameState hook to use backend storage with localStorage fallback
- Support synchronization between hoerdle.de and hördle.de
- Migration automatically runs on Docker container start
This commit is contained in:
Hördle Bot
2025-12-01 20:09:54 +01:00
parent bba6b9ef31
commit 61846a6982
7 changed files with 499 additions and 53 deletions

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { getTodayISOString } from './dateUtils';
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
export interface GameState {
date: string;
@@ -32,12 +33,20 @@ const STATS_KEY_PREFIX = 'hoerdle_statistics';
const INITIAL_SCORE = 90;
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
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,
@@ -53,71 +62,110 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
});
useEffect(() => {
// Load game state
const storageKey = getStorageKey();
const stored = localStorage.getItem(storageKey);
const today = getTodayISOString();
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;
// Retroactively deduct points for existing guesses if possible,
// but simpler to just start at 90 for active games to avoid confusion
// 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;
}
}
} catch (error) {
console.warn('[gameState] Failed to load from backend, falling back to localStorage:', error);
}
// Fallback to localStorage
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);
} else {
// New day
const newState = createNewState(today);
setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState));
}
setGameState(parsed as GameState);
} else {
// New day
// No state
const newState = createNewState(today);
setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState));
}
} else {
// No state
const newState = createNewState(today);
setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState));
}
// Load statistics
const statsKey = getStatsKey();
const storedStats = localStorage.getItem(statsKey);
if (storedStats) {
const parsedStats = JSON.parse(storedStats);
// Migration for existing stats without solvedIn7
if (parsedStats.solvedIn7 === undefined) {
parsedStats.solvedIn7 = 0;
// Load statistics from localStorage
const statsKey = getStatsKey();
const storedStats = localStorage.getItem(statsKey);
if (storedStats) {
const parsedStats = JSON.parse(storedStats);
// Migration for existing stats without solvedIn7
if (parsedStats.solvedIn7 === undefined) {
parsedStats.solvedIn7 = 0;
}
setStatistics(parsedStats);
} else {
const newStats: Statistics = {
solvedIn1: 0,
solvedIn2: 0,
solvedIn3: 0,
solvedIn4: 0,
solvedIn5: 0,
solvedIn6: 0,
solvedIn7: 0,
failed: 0,
};
setStatistics(newStats);
localStorage.setItem(statsKey, JSON.stringify(newStats));
}
setStatistics(parsedStats);
} else {
const newStats: Statistics = {
solvedIn1: 0,
solvedIn2: 0,
solvedIn3: 0,
solvedIn4: 0,
solvedIn5: 0,
solvedIn6: 0,
solvedIn7: 0,
failed: 0,
};
setStatistics(newStats);
localStorage.setItem(statsKey, JSON.stringify(newStats));
}
}, [genre]); // Re-run when genre changes
};
loadFromBackend();
}, [genre, isSpecial, genreKey]); // Re-run when genre or isSpecial changes
const saveState = (newState: GameState) => {
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 = (attempts: number, solved: boolean) => {
const updateStatistics = async (attempts: number, solved: boolean) => {
if (!statistics) return;
const newStats = { ...statistics };
@@ -139,6 +187,17 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
}
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));
};