diff --git a/app/api/player-state/route.ts b/app/api/player-state/route.ts new file mode 100644 index 0000000..edfe9b4 --- /dev/null +++ b/app/api/player-state/route.ts @@ -0,0 +1,172 @@ +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; +import { getLocalizedValue } from '@/lib/i18n'; +import type { GameState, Statistics } from '@/lib/gameState'; + +const prisma = new PrismaClient(); + +/** + * Validate UUID format (basic check) + */ +function isValidUUID(uuid: string): boolean { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(uuid); +} + +/** + * GET /api/player-state + * + * Loads player state for a given identifier and genre/special. + * + * Query parameters: + * - genre: Genre name (e.g., "Rock") + * - special: Special name (e.g., "00725") + * + * Headers: + * - X-Player-Id: Player identifier (UUID) + */ +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const genreName = searchParams.get('genre'); + const specialName = searchParams.get('special'); + + // Get player identifier from header + const playerId = request.headers.get('X-Player-Id'); + if (!playerId || !isValidUUID(playerId)) { + return NextResponse.json( + { error: 'Invalid or missing player identifier' }, + { status: 400 } + ); + } + + // Determine genre key + let genreKey: string; + if (specialName) { + genreKey = `special:${specialName}`; + } else if (genreName) { + genreKey = genreName; + } else { + genreKey = 'global'; + } + + // Load player state from database + const playerState = await prisma.playerState.findUnique({ + where: { + identifier_genreKey: { + identifier: playerId, + genreKey: genreKey, + }, + }, + }); + + if (!playerState) { + return NextResponse.json(null, { status: 404 }); + } + + // Parse JSON strings + let gameState: GameState; + let statistics: Statistics; + + try { + gameState = JSON.parse(playerState.gameState) as GameState; + statistics = JSON.parse(playerState.statistics) as Statistics; + } catch (parseError) { + console.error('[player-state] Failed to parse stored state:', parseError); + return NextResponse.json( + { error: 'Invalid stored state format' }, + { status: 500 } + ); + } + + return NextResponse.json({ + gameState, + statistics, + }); + } catch (error) { + console.error('[player-state] Error loading player state:', error); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + +/** + * POST /api/player-state + * + * Saves player state for a given identifier and genre/special. + * + * Request body: + * - genreKey: Genre key (e.g., "global", "Rock", "special:00725") + * - gameState: GameState object + * - statistics: Statistics object + * + * Headers: + * - X-Player-Id: Player identifier (UUID) + */ +export async function POST(request: Request) { + try { + // Get player identifier from header + const playerId = request.headers.get('X-Player-Id'); + if (!playerId || !isValidUUID(playerId)) { + return NextResponse.json( + { error: 'Invalid or missing player identifier' }, + { status: 400 } + ); + } + + // Parse request body + const body = await request.json(); + const { genreKey, gameState, statistics } = body; + + if (!genreKey || !gameState || !statistics) { + return NextResponse.json( + { error: 'Missing required fields: genreKey, gameState, statistics' }, + { status: 400 } + ); + } + + // Validate genre key format + if (typeof genreKey !== 'string' || genreKey.length === 0) { + return NextResponse.json( + { error: 'Invalid genreKey format' }, + { status: 400 } + ); + } + + // Serialize to JSON strings + const gameStateJson = JSON.stringify(gameState); + const statisticsJson = JSON.stringify(statistics); + + // Upsert player state (update if exists, create if not) + await prisma.playerState.upsert({ + where: { + identifier_genreKey: { + identifier: playerId, + genreKey: genreKey, + }, + }, + update: { + gameState: gameStateJson, + statistics: statisticsJson, + lastPlayed: new Date(), + }, + create: { + identifier: playerId, + genreKey: genreKey, + gameState: gameStateJson, + statistics: statisticsJson, + }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[player-state] Error saving player state:', error); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + diff --git a/components/Game.tsx b/components/Game.tsx index 49fcce0..ecb6a60 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -39,7 +39,7 @@ const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60]; export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) { const t = useTranslations('Game'); const locale = useLocale(); - const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts); + const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial); const [hasWon, setHasWon] = useState(false); const [hasLost, setHasLost] = useState(false); const [shareText, setShareText] = useState(`🔗 ${t('share')}`); diff --git a/lib/gameState.ts b/lib/gameState.ts index f2eaf9d..5281a91 100644 --- a/lib/gameState.ts +++ b/lib/gameState.ts @@ -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(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 + 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)); }; diff --git a/lib/playerId.ts b/lib/playerId.ts new file mode 100644 index 0000000..2aa0231 --- /dev/null +++ b/lib/playerId.ts @@ -0,0 +1,56 @@ +/** + * Player Identifier Management + * + * Generates and manages a unique player identifier (UUID) that is stored + * in localStorage. This identifier is used to sync game states across + * different domains (hoerdle.de and hördle.de). + */ + +const STORAGE_KEY = 'hoerdle_player_id'; + +/** + * Generate a UUID v4 + */ +function generatePlayerId(): string { + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + +/** + * 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). + * + * @returns Player identifier (UUID v4) + */ +export function getOrCreatePlayerId(): string { + if (typeof window === 'undefined') { + // Server-side: return empty string (not used on server) + return ''; + } + + let playerId = localStorage.getItem(STORAGE_KEY); + if (!playerId) { + playerId = generatePlayerId(); + localStorage.setItem(STORAGE_KEY, playerId); + } + return playerId; +} + +/** + * Get the current player identifier without creating a new one + * + * @returns Player identifier or null if not set + */ +export function getPlayerId(): string | null { + if (typeof window === 'undefined') { + return null; + } + return localStorage.getItem(STORAGE_KEY); +} + diff --git a/lib/playerStorage.ts b/lib/playerStorage.ts new file mode 100644 index 0000000..8fa18b7 --- /dev/null +++ b/lib/playerStorage.ts @@ -0,0 +1,130 @@ +/** + * Player Storage API + * + * Handles loading and saving player game states from/to the backend. + * This enables cross-domain synchronization between hoerdle.de and hördle.de. + */ + +import { getOrCreatePlayerId } from './playerId'; +import type { GameState, Statistics } from './gameState'; + +/** + * Get genre key for storage + * + * Formats the genre/special into a consistent key format: + * - Global: "global" + * - Genre: "Rock" (localized name) + * - Special: "special:00725" (special name) + */ +export function getGenreKey( + genre: string | null, + isSpecial: boolean, + specialName?: string +): string { + if (isSpecial && specialName) { + return `special:${specialName}`; + } + return genre || 'global'; +} + +/** + * Load player state from backend + * + * @param genreKey - Genre key (from getGenreKey) + * @returns GameState and Statistics, or null if not found + */ +export async function loadPlayerState( + genreKey: string +): Promise<{ gameState: GameState; statistics: Statistics } | null> { + try { + const playerId = getOrCreatePlayerId(); + if (!playerId) { + return null; + } + + // Determine if it's a special or genre + const isSpecial = genreKey.startsWith('special:'); + const params = new URLSearchParams(); + + if (isSpecial) { + const specialName = genreKey.replace('special:', ''); + params.append('special', specialName); + } else if (genreKey !== 'global') { + params.append('genre', genreKey); + } + + const response = await fetch(`/api/player-state?${params.toString()}`, { + method: 'GET', + headers: { + 'X-Player-Id': playerId, + }, + }); + + if (!response.ok) { + if (response.status === 404) { + // No state found - this is normal for new players + return null; + } + // Other errors: log and return null (will fallback to localStorage) + console.warn('[playerStorage] Failed to load player state:', response.status); + return null; + } + + const data = await response.json(); + if (!data || !data.gameState || !data.statistics) { + return null; + } + + return { + gameState: data.gameState as GameState, + statistics: data.statistics as Statistics, + }; + } catch (error) { + // Network errors or other issues: fallback to localStorage + console.warn('[playerStorage] Error loading player state:', error); + return null; + } +} + +/** + * Save player state to backend + * + * @param genreKey - Genre key (from getGenreKey) + * @param gameState - Current game state + * @param statistics - Current statistics + */ +export async function savePlayerState( + genreKey: string, + gameState: GameState, + statistics: Statistics +): Promise { + try { + const playerId = getOrCreatePlayerId(); + if (!playerId) { + console.warn('[playerStorage] No player ID available, cannot save state'); + return; + } + + const response = await fetch('/api/player-state', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Player-Id': playerId, + }, + body: JSON.stringify({ + genreKey, + gameState, + statistics, + }), + }); + + if (!response.ok) { + console.warn('[playerStorage] Failed to save player state:', response.status); + // Don't throw - allow fallback to localStorage + } + } catch (error) { + // Network errors: log but don't throw (will fallback to localStorage) + console.warn('[playerStorage] Error saving player state:', error); + } +} + diff --git a/prisma/migrations/20251201190234_add_player_state/migration.sql b/prisma/migrations/20251201190234_add_player_state/migration.sql new file mode 100644 index 0000000..5c0848d --- /dev/null +++ b/prisma/migrations/20251201190234_add_player_state/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "PlayerState" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "identifier" TEXT NOT NULL, + "genreKey" TEXT NOT NULL, + "gameState" TEXT NOT NULL, + "statistics" TEXT NOT NULL, + "lastPlayed" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateIndex +CREATE INDEX "PlayerState_identifier_idx" ON "PlayerState"("identifier"); + +-- CreateIndex +CREATE UNIQUE INDEX "PlayerState_identifier_genreKey_key" ON "PlayerState"("identifier", "genreKey"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36ef48a..015a219 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -88,3 +88,16 @@ model News { @@index([publishedAt]) } + +model PlayerState { + id Int @id @default(autoincrement()) + identifier String // UUID des Spielers (für Cross-Domain-Sync) + genreKey String // Genre-Name oder "global" oder "special:" + gameState String // JSON-String des GameState + statistics String // JSON-String der Statistics + lastPlayed DateTime @updatedAt + createdAt DateTime @default(now()) + + @@unique([identifier, genreKey]) + @@index([identifier]) +}