diff --git a/app/api/player-id/suggest/route.ts b/app/api/player-id/suggest/route.ts new file mode 100644 index 0000000..5a319c3 --- /dev/null +++ b/app/api/player-id/suggest/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +/** + * POST /api/player-id/suggest + * + * Tries to find a player ID based on recently updated states for a genre. + * This helps synchronize player IDs across different domains (hoerdle.de and hördle.de). + * + * Request body: + * - genreKey: Genre key (e.g., "global", "Rock", "special:00725") + * + * Returns: + * - playerId: Suggested player ID (UUID) if found, null otherwise + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + const { genreKey } = body; + + if (!genreKey || typeof genreKey !== 'string') { + return NextResponse.json( + { error: 'Missing or invalid genreKey' }, + { status: 400 } + ); + } + + // Find the most recently updated player state for this genre + // Look for states updated in the last 48 hours + const cutoffDate = new Date(); + cutoffDate.setHours(cutoffDate.getHours() - 48); + + const recentState = await prisma.playerState.findFirst({ + where: { + genreKey: genreKey, + lastPlayed: { + gte: cutoffDate, + }, + }, + orderBy: { + lastPlayed: 'desc', + }, + }); + + if (recentState) { + // Return the player ID from the most recent state + return NextResponse.json({ + playerId: recentState.identifier, + lastPlayed: recentState.lastPlayed, + }); + } + + // No recent state found + return NextResponse.json({ + playerId: null, + }); + } catch (error) { + console.error('[player-id/suggest] Error finding player ID:', error); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + diff --git a/lib/gameState.ts b/lib/gameState.ts index f1d16c9..1a0cd42 100644 --- a/lib/gameState.ts +++ b/lib/gameState.ts @@ -28,9 +28,6 @@ export interface Statistics { failed: number; } -const STORAGE_KEY_PREFIX = 'hoerdle_game_state'; -const STATS_KEY_PREFIX = 'hoerdle_statistics'; - const INITIAL_SCORE = 90; export function useGameState( @@ -40,9 +37,6 @@ export function useGameState( ) { const [gameState, setGameState] = useState(null); const [statistics, setStatistics] = useState(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 @@ -98,89 +92,31 @@ export function useGameState( 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 + // No backend state found - create new state + // This is the normal case for first-time players or new genre const newState = createNewState(today); setGameState(newState); - localStorage.setItem(getStorageKey(), JSON.stringify(newState)); const newStats = createNewStatistics(); setStatistics(newStats); - localStorage.setItem(getStatsKey(), JSON.stringify(newStats)); + // Save to backend for cross-domain sync await savePlayerState(genreKey, newState, newStats); return; } } catch (error) { - console.warn('[gameState] Failed to load from backend, falling back to localStorage:', error); + console.error('[gameState] Failed to load from backend:', 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 + // On error, create new state and try to save to backend + // This handles network errors gracefully const newState = createNewState(today); setGameState(newState); - localStorage.setItem(storageKey, JSON.stringify(newState)); const newStats = createNewStatistics(); setStatistics(newStats); - localStorage.setItem(getStatsKey(), JSON.stringify(newStats)); + // Try to save to backend (may fail, but we try) + try { + await savePlayerState(genreKey, newState, newStats); + } catch (saveError) { + console.error('[gameState] Failed to save new state to backend:', saveError); + } } }; @@ -190,17 +126,15 @@ export function useGameState( const saveState = async (newState: GameState) => { setGameState(newState); - // Save to backend (primary) + // Save to backend only if (statistics) { try { await savePlayerState(genreKey, newState, statistics); } catch (error) { - console.warn('[gameState] Failed to save to backend, using localStorage fallback:', error); + console.error('[gameState] Failed to save to backend:', error); + // No fallback - backend is required for cross-domain sync } } - - // Also save to localStorage as backup - localStorage.setItem(getStorageKey(), JSON.stringify(newState)); }; const updateStatistics = async (attempts: number, solved: boolean) => { @@ -226,17 +160,15 @@ export function useGameState( setStatistics(newStats); - // Save to backend (primary) + // Save to backend only if (gameState) { try { await savePlayerState(genreKey, gameState, newStats); } catch (error) { - console.warn('[gameState] Failed to save statistics to backend, using localStorage fallback:', error); + console.error('[gameState] Failed to save statistics to backend:', error); + // No fallback - backend is required for cross-domain sync } } - - // Also save to localStorage as backup - localStorage.setItem(getStatsKey(), JSON.stringify(newStats)); }; const addGuess = (guess: string, correct: boolean) => { diff --git a/lib/playerId.ts b/lib/playerId.ts index 2aa0231..78d23d9 100644 --- a/lib/playerId.ts +++ b/lib/playerId.ts @@ -20,11 +20,75 @@ function generatePlayerId(): string { }); } +/** + * Try to find an existing player ID from the backend + * + * @param genreKey - Genre key to search for + * @returns Player ID if found, null otherwise + */ +async function findExistingPlayerId(genreKey: string): Promise { + try { + const response = await fetch('/api/player-id/suggest', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ genreKey }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.playerId) { + return data.playerId; + } + } + } catch (error) { + console.warn('[playerId] Failed to find existing player ID:', error); + } + return null; +} + /** * Get or create a player identifier * - * If no identifier exists in localStorage, a new UUID is generated and stored. - * The same identifier is used across all domains (hoerdle.de and hördle.de). + * If no identifier exists in localStorage, tries to find an existing one from the backend + * (based on recently updated states). If none found, generates a new UUID. + * This enables cross-domain synchronization between hoerdle.de and hördle.de. + * + * @param genreKey - Optional genre key to search for existing player ID + * @returns Player identifier (UUID v4) + */ +export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise { + if (typeof window === 'undefined') { + // Server-side: return empty string (not used on server) + return ''; + } + + let playerId = localStorage.getItem(STORAGE_KEY); + + if (!playerId) { + // Try to find an existing player ID from backend if genreKey is provided + if (genreKey) { + const existingId = await findExistingPlayerId(genreKey); + if (existingId) { + playerId = existingId; + localStorage.setItem(STORAGE_KEY, playerId); + return playerId; + } + } + + // Generate new UUID if no existing ID found + playerId = generatePlayerId(); + localStorage.setItem(STORAGE_KEY, playerId); + } + + return playerId; +} + +/** + * Get or create a player identifier (synchronous version) + * + * This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead. * * @returns Player identifier (UUID v4) */ diff --git a/lib/playerStorage.ts b/lib/playerStorage.ts index 8fa18b7..e61c5ac 100644 --- a/lib/playerStorage.ts +++ b/lib/playerStorage.ts @@ -37,7 +37,9 @@ export async function loadPlayerState( genreKey: string ): Promise<{ gameState: GameState; statistics: Statistics } | null> { try { - const playerId = getOrCreatePlayerId(); + // Use async version to enable cross-domain player ID sync + const { getOrCreatePlayerIdAsync } = await import('./playerId'); + const playerId = await getOrCreatePlayerIdAsync(genreKey); if (!playerId) { return null; }