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

@@ -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 }
);
}
}

View File

@@ -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) { export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
const t = useTranslations('Game'); const t = useTranslations('Game');
const locale = useLocale(); 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 [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false); const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState(`🔗 ${t('share')}`); const [shareText, setShareText] = useState(`🔗 ${t('share')}`);

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { getTodayISOString } from './dateUtils'; import { getTodayISOString } from './dateUtils';
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
export interface GameState { export interface GameState {
date: string; date: string;
@@ -32,13 +33,21 @@ const STATS_KEY_PREFIX = 'hoerdle_statistics';
const INITIAL_SCORE = 90; 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 [gameState, setGameState] = useState<GameState | null>(null);
const [statistics, setStatistics] = useState<Statistics | null>(null); const [statistics, setStatistics] = useState<Statistics | null>(null);
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX; const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_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 => ({ const createNewState = (date: string): GameState => ({
date, date,
guesses: [], guesses: [],
@@ -53,10 +62,38 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
}); });
useEffect(() => { useEffect(() => {
// Load game state 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;
}
}
} catch (error) {
console.warn('[gameState] Failed to load from backend, falling back to localStorage:', error);
}
// Fallback to localStorage
const storageKey = getStorageKey(); const storageKey = getStorageKey();
const stored = localStorage.getItem(storageKey); const stored = localStorage.getItem(storageKey);
const today = getTodayISOString();
if (stored) { if (stored) {
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
@@ -68,9 +105,6 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
parsed.skipCount = 0; parsed.skipCount = 0;
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }]; parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
parsed.yearGuessed = false; parsed.yearGuessed = false;
// Retroactively deduct points for existing guesses if possible,
// but simpler to just start at 90 for active games to avoid confusion
} }
setGameState(parsed as GameState); setGameState(parsed as GameState);
} else { } else {
@@ -86,7 +120,7 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
localStorage.setItem(storageKey, JSON.stringify(newState)); localStorage.setItem(storageKey, JSON.stringify(newState));
} }
// Load statistics // Load statistics from localStorage
const statsKey = getStatsKey(); const statsKey = getStatsKey();
const storedStats = localStorage.getItem(statsKey); const storedStats = localStorage.getItem(statsKey);
if (storedStats) { if (storedStats) {
@@ -110,14 +144,28 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
setStatistics(newStats); setStatistics(newStats);
localStorage.setItem(statsKey, JSON.stringify(newStats)); localStorage.setItem(statsKey, JSON.stringify(newStats));
} }
}, [genre]); // Re-run when genre changes };
const saveState = (newState: GameState) => { loadFromBackend();
}, [genre, isSpecial, genreKey]); // Re-run when genre or isSpecial changes
const saveState = async (newState: GameState) => {
setGameState(newState); 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)); localStorage.setItem(getStorageKey(), JSON.stringify(newState));
}; };
const updateStatistics = (attempts: number, solved: boolean) => { const updateStatistics = async (attempts: number, solved: boolean) => {
if (!statistics) return; if (!statistics) return;
const newStats = { ...statistics }; const newStats = { ...statistics };
@@ -139,6 +187,17 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
} }
setStatistics(newStats); 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)); localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
}; };

56
lib/playerId.ts Normal file
View File

@@ -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);
}

130
lib/playerStorage.ts Normal file
View File

@@ -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<void> {
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);
}
}

View File

@@ -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");

View File

@@ -88,3 +88,16 @@ model News {
@@index([publishedAt]) @@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:<name>"
gameState String // JSON-String des GameState
statistics String // JSON-String der Statistics
lastPlayed DateTime @updatedAt
createdAt DateTime @default(now())
@@unique([identifier, genreKey])
@@index([identifier])
}