- 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
224 lines
6.5 KiB
TypeScript
224 lines
6.5 KiB
TypeScript
/**
|
|
* 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).
|
|
*
|
|
* Device-specific isolation:
|
|
* - Each device has its own device ID stored in localStorage
|
|
* - Player ID format: {basePlayerId}:{deviceId}
|
|
* - This allows cross-domain sync on the same device while keeping devices isolated
|
|
*/
|
|
|
|
const STORAGE_KEY_PLAYER = 'hoerdle_player_id';
|
|
const STORAGE_KEY_DEVICE = 'hoerdle_device_id';
|
|
|
|
/**
|
|
* Generate a UUID v4
|
|
*/
|
|
function generateUUID(): 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 device ID (unique per device)
|
|
*
|
|
* The device ID is stored in localStorage and persists across sessions.
|
|
* This allows device-specific isolation of game states.
|
|
*
|
|
* @returns Device identifier (UUID v4)
|
|
*/
|
|
export function getOrCreateDeviceId(): string {
|
|
if (typeof window === 'undefined') {
|
|
return '';
|
|
}
|
|
|
|
let deviceId = localStorage.getItem(STORAGE_KEY_DEVICE);
|
|
if (!deviceId) {
|
|
deviceId = generateUUID();
|
|
localStorage.setItem(STORAGE_KEY_DEVICE, deviceId);
|
|
}
|
|
return deviceId;
|
|
}
|
|
|
|
/**
|
|
* Get the device ID without creating a new one
|
|
*
|
|
* @returns Device identifier or null if not set
|
|
*/
|
|
export function getDeviceId(): string | null {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
return localStorage.getItem(STORAGE_KEY_DEVICE);
|
|
}
|
|
|
|
/**
|
|
* Generate a base player ID (for cross-domain sync)
|
|
*/
|
|
function generateBasePlayerId(): string {
|
|
return generateUUID();
|
|
}
|
|
|
|
/**
|
|
* Try to find an existing base player ID from the backend
|
|
*
|
|
* Extracts the base player ID from a full player ID (format: {basePlayerId}:{deviceId})
|
|
*
|
|
* @param genreKey - Genre key to search for
|
|
* @returns Base player ID if found, null otherwise
|
|
*/
|
|
async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
|
|
try {
|
|
const deviceId = getOrCreateDeviceId();
|
|
const response = await fetch('/api/player-id/suggest', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ genreKey, deviceId }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.basePlayerId) {
|
|
return data.basePlayerId;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('[playerId] Failed to find existing base player ID:', error);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Combine base player ID and device ID into full player ID
|
|
* Format: {basePlayerId}:{deviceId}
|
|
*/
|
|
function combinePlayerId(basePlayerId: string, deviceId: string): string {
|
|
return `${basePlayerId}:${deviceId}`;
|
|
}
|
|
|
|
/**
|
|
* Extract base player ID from full player ID
|
|
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
|
*/
|
|
function extractBasePlayerId(fullPlayerId: string): string {
|
|
const colonIndex = fullPlayerId.indexOf(':');
|
|
if (colonIndex === -1) {
|
|
// Legacy format (no device ID) - return as is
|
|
return fullPlayerId;
|
|
}
|
|
return fullPlayerId.substring(0, colonIndex);
|
|
}
|
|
|
|
/**
|
|
* Get or create a player identifier
|
|
*
|
|
* Player ID format: {basePlayerId}:{deviceId}
|
|
*
|
|
* If no identifier exists in localStorage, tries to find an existing base player ID
|
|
* from the backend (for cross-domain sync). If none found, generates a new base ID.
|
|
* The device ID is always device-specific.
|
|
*
|
|
* This enables:
|
|
* - Cross-domain synchronization on the same device (same base player ID)
|
|
* - Device isolation (different device IDs)
|
|
*
|
|
* @param genreKey - Optional genre key to search for existing base player ID
|
|
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
|
*/
|
|
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
|
|
if (typeof window === 'undefined') {
|
|
return '';
|
|
}
|
|
|
|
// Always get/create device ID (device-specific)
|
|
const deviceId = getOrCreateDeviceId();
|
|
|
|
// Try to get base player ID from localStorage
|
|
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
|
|
|
if (!basePlayerId) {
|
|
// Try to find an existing base player ID from backend if genreKey is provided
|
|
if (genreKey) {
|
|
const existingBaseId = await findExistingBasePlayerId(genreKey);
|
|
if (existingBaseId) {
|
|
basePlayerId = existingBaseId;
|
|
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
|
}
|
|
}
|
|
|
|
// Generate new base player ID if no existing one found
|
|
if (!basePlayerId) {
|
|
basePlayerId = generateBasePlayerId();
|
|
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
|
}
|
|
}
|
|
|
|
// Combine base player ID with device ID
|
|
return combinePlayerId(basePlayerId, deviceId);
|
|
}
|
|
|
|
/**
|
|
* Get or create a player identifier (synchronous version)
|
|
*
|
|
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
|
|
*
|
|
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
|
*/
|
|
export function getOrCreatePlayerId(): string {
|
|
if (typeof window === 'undefined') {
|
|
return '';
|
|
}
|
|
|
|
const deviceId = getOrCreateDeviceId();
|
|
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
|
|
|
if (!basePlayerId) {
|
|
basePlayerId = generateBasePlayerId();
|
|
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
|
}
|
|
|
|
return combinePlayerId(basePlayerId, deviceId);
|
|
}
|
|
|
|
/**
|
|
* Get the current player identifier without creating a new one
|
|
*
|
|
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
|
|
*/
|
|
export function getPlayerId(): string | null {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const deviceId = getDeviceId();
|
|
const basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
|
|
|
if (!deviceId || !basePlayerId) {
|
|
return null;
|
|
}
|
|
|
|
return combinePlayerId(basePlayerId, deviceId);
|
|
}
|
|
|
|
/**
|
|
* Get base player ID (for debugging/logging)
|
|
*
|
|
* @returns Base player ID or null if not set
|
|
*/
|
|
export function getBasePlayerId(): string | null {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
return localStorage.getItem(STORAGE_KEY_PLAYER);
|
|
}
|
|
|