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:
104
lib/gameState.ts
104
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<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
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user