Files
hoerdle/lib/playerStorage.ts
Hördle Bot dc763c88a3 feat: Add device-specific isolation for player IDs
- Add device ID generation (unique per device, stored in localStorage)
- Extend player ID format to: {basePlayerId}:{deviceId}
- Enable cross-domain sync on same device while keeping devices isolated
- Update backend APIs to support new player ID format
- Maintain backward compatibility with legacy UUID format

This allows:
- Each device (Desktop, Android, iOS) to have separate game states
- Cross-domain sync still works on the same device (hoerdle.de ↔ hördle.de)
- Easier debugging with visible device ID in player identifier
2025-12-02 01:49:45 +01:00

135 lines
4.1 KiB
TypeScript

/**
* 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 {
// Use async version to enable cross-domain player ID sync
const { getOrCreatePlayerIdAsync } = await import('./playerId');
const playerId = await getOrCreatePlayerIdAsync(genreKey);
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 {
// Use async version to ensure device ID is included
const { getOrCreatePlayerIdAsync } = await import('./playerId');
const playerId = await getOrCreatePlayerIdAsync();
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);
}
}