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:
67
app/api/player-id/suggest/route.ts
Normal file
67
app/api/player-id/suggest/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
104
lib/gameState.ts
104
lib/gameState.ts
@@ -28,9 +28,6 @@ export interface Statistics {
|
|||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
|
||||||
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
|
||||||
|
|
||||||
const INITIAL_SCORE = 90;
|
const INITIAL_SCORE = 90;
|
||||||
|
|
||||||
export function useGameState(
|
export function useGameState(
|
||||||
@@ -40,9 +37,6 @@ export function useGameState(
|
|||||||
) {
|
) {
|
||||||
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 getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
|
||||||
|
|
||||||
// Get genre key for backend storage
|
// Get genre key for backend storage
|
||||||
// For specials, genre contains the special name
|
// For specials, genre contains the special name
|
||||||
@@ -98,89 +92,31 @@ export function useGameState(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No backend state found - check localStorage before creating new state
|
// No backend state found - create new state
|
||||||
const storageKey = getStorageKey();
|
// This is the normal case for first-time players or new genre
|
||||||
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
|
|
||||||
const newState = createNewState(today);
|
const newState = createNewState(today);
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
|
||||||
const newStats = createNewStatistics();
|
const newStats = createNewStatistics();
|
||||||
setStatistics(newStats);
|
setStatistics(newStats);
|
||||||
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
// Save to backend for cross-domain sync
|
||||||
await savePlayerState(genreKey, newState, newStats);
|
await savePlayerState(genreKey, newState, newStats);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// On error, create new state and try to save to backend
|
||||||
const storageKey = getStorageKey();
|
// This handles network errors gracefully
|
||||||
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
|
|
||||||
const newState = createNewState(today);
|
const newState = createNewState(today);
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
|
||||||
const newStats = createNewStatistics();
|
const newStats = createNewStatistics();
|
||||||
setStatistics(newStats);
|
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) => {
|
const saveState = async (newState: GameState) => {
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
|
|
||||||
// Save to backend (primary)
|
// Save to backend only
|
||||||
if (statistics) {
|
if (statistics) {
|
||||||
try {
|
try {
|
||||||
await savePlayerState(genreKey, newState, statistics);
|
await savePlayerState(genreKey, newState, statistics);
|
||||||
} catch (error) {
|
} 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) => {
|
const updateStatistics = async (attempts: number, solved: boolean) => {
|
||||||
@@ -226,17 +160,15 @@ export function useGameState(
|
|||||||
|
|
||||||
setStatistics(newStats);
|
setStatistics(newStats);
|
||||||
|
|
||||||
// Save to backend (primary)
|
// Save to backend only
|
||||||
if (gameState) {
|
if (gameState) {
|
||||||
try {
|
try {
|
||||||
await savePlayerState(genreKey, gameState, newStats);
|
await savePlayerState(genreKey, gameState, newStats);
|
||||||
} catch (error) {
|
} 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) => {
|
const addGuess = (guess: string, correct: boolean) => {
|
||||||
|
|||||||
@@ -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
|
* Get or create a player identifier
|
||||||
*
|
*
|
||||||
* If no identifier exists in localStorage, a new UUID is generated and stored.
|
* If no identifier exists in localStorage, tries to find an existing one from the backend
|
||||||
* The same identifier is used across all domains (hoerdle.de and hördle.de).
|
* (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)
|
* @returns Player identifier (UUID v4)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ export async function loadPlayerState(
|
|||||||
genreKey: string
|
genreKey: string
|
||||||
): Promise<{ gameState: GameState; statistics: Statistics } | null> {
|
): Promise<{ gameState: GameState; statistics: Statistics } | null> {
|
||||||
try {
|
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) {
|
if (!playerId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user