feat: Remove localStorage for game states and implement cross-domain player ID sync

- Remove localStorage for game states and statistics (backend only)
- Add API route to suggest player ID based on recently updated states
- Add async player ID lookup that finds existing IDs across domains
- When visiting a new domain, automatically find and use existing player ID
- Enables cross-domain synchronization between hoerdle.de and hördle.de
This commit is contained in:
Hördle Bot
2025-12-01 20:37:47 +01:00
parent 27fa689b18
commit 2846afb6f7
4 changed files with 154 additions and 89 deletions

View File

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

View File

@@ -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(
@@ -41,9 +38,6 @@ export function useGameState(
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);
@@ -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) => {

View File

@@ -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<string | null> {
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<string> {
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)
*/

View File

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