From dc763c88a35664db483ac9665f56577a4d8ae6d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Tue, 2 Dec 2025 01:49:45 +0100 Subject: [PATCH] feat: Add device-specific isolation for player IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/player-id/suggest/route.ts | 67 +++++++++-- app/api/player-state/route.ts | 28 ++++- lib/playerId.ts | 175 +++++++++++++++++++++++------ lib/playerStorage.ts | 4 +- 4 files changed, 223 insertions(+), 51 deletions(-) diff --git a/app/api/player-id/suggest/route.ts b/app/api/player-id/suggest/route.ts index 5a319c3..99cf085 100644 --- a/app/api/player-id/suggest/route.ts +++ b/app/api/player-id/suggest/route.ts @@ -6,19 +6,21 @@ 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). + * Tries to find a base player ID based on recently updated states for a genre and device. + * This helps synchronize player IDs across different domains (hoerdle.de and hördle.de) + * on the same device. * * Request body: * - genreKey: Genre key (e.g., "global", "Rock", "special:00725") + * - deviceId: Device identifier (UUID) * * Returns: - * - playerId: Suggested player ID (UUID) if found, null otherwise + * - basePlayerId: Suggested base player ID (UUID) if found, null otherwise */ export async function POST(request: Request) { try { const body = await request.json(); - const { genreKey } = body; + const { genreKey, deviceId } = body; if (!genreKey || typeof genreKey !== 'string') { return NextResponse.json( @@ -32,6 +34,41 @@ export async function POST(request: Request) { const cutoffDate = new Date(); cutoffDate.setHours(cutoffDate.getHours() - 48); + // If deviceId is provided, search for states with matching device ID + // Format: {basePlayerId}:{deviceId} + if (deviceId && typeof deviceId === 'string') { + // Search for states with the same device ID + const recentStates = await prisma.playerState.findMany({ + where: { + genreKey: genreKey, + lastPlayed: { + gte: cutoffDate, + }, + identifier: { + endsWith: `:${deviceId}`, + }, + }, + orderBy: { + lastPlayed: 'desc', + }, + take: 1, + }); + + if (recentStates.length > 0) { + const recentState = recentStates[0]; + // Extract base player ID from full identifier + const colonIndex = recentState.identifier.indexOf(':'); + if (colonIndex !== -1) { + const basePlayerId = recentState.identifier.substring(0, colonIndex); + return NextResponse.json({ + basePlayerId: basePlayerId, + lastPlayed: recentState.lastPlayed, + }); + } + } + } + + // Fallback: Find any recent state for this genre (legacy support) const recentState = await prisma.playerState.findFirst({ where: { genreKey: genreKey, @@ -45,16 +82,26 @@ export async function POST(request: Request) { }); if (recentState) { - // Return the player ID from the most recent state - return NextResponse.json({ - playerId: recentState.identifier, - lastPlayed: recentState.lastPlayed, - }); + // Extract base player ID if format is basePlayerId:deviceId + const colonIndex = recentState.identifier.indexOf(':'); + if (colonIndex !== -1) { + const basePlayerId = recentState.identifier.substring(0, colonIndex); + return NextResponse.json({ + basePlayerId: basePlayerId, + lastPlayed: recentState.lastPlayed, + }); + } else { + // Legacy format: return as-is + return NextResponse.json({ + basePlayerId: recentState.identifier, + lastPlayed: recentState.lastPlayed, + }); + } } // No recent state found return NextResponse.json({ - playerId: null, + basePlayerId: null, }); } catch (error) { console.error('[player-id/suggest] Error finding player ID:', error); diff --git a/app/api/player-state/route.ts b/app/api/player-state/route.ts index edfe9b4..9e1cc0d 100644 --- a/app/api/player-state/route.ts +++ b/app/api/player-state/route.ts @@ -7,10 +7,30 @@ const prisma = new PrismaClient(); /** * Validate UUID format (basic check) + * Supports both legacy format (single UUID) and new format (basePlayerId:deviceId) */ -function isValidUUID(uuid: string): boolean { +function isValidPlayerId(playerId: string): boolean { + // Legacy format: single UUID const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - return uuidRegex.test(uuid); + + // New format: basePlayerId:deviceId (two UUIDs separated by colon) + const combinedRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + return uuidRegex.test(playerId) || combinedRegex.test(playerId); +} + +/** + * Extract base player ID from full player ID + * Format: {basePlayerId}:{deviceId} -> {basePlayerId} + * Legacy: {uuid} -> {uuid} + */ +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); } /** @@ -33,7 +53,7 @@ export async function GET(request: Request) { // Get player identifier from header const playerId = request.headers.get('X-Player-Id'); - if (!playerId || !isValidUUID(playerId)) { + if (!playerId || !isValidPlayerId(playerId)) { return NextResponse.json( { error: 'Invalid or missing player identifier' }, { status: 400 } @@ -109,7 +129,7 @@ export async function POST(request: Request) { try { // Get player identifier from header const playerId = request.headers.get('X-Player-Id'); - if (!playerId || !isValidUUID(playerId)) { + if (!playerId || !isValidPlayerId(playerId)) { return NextResponse.json( { error: 'Invalid or missing player identifier' }, { status: 400 } diff --git a/lib/playerId.ts b/lib/playerId.ts index 78d23d9..03e3f9d 100644 --- a/lib/playerId.ts +++ b/lib/playerId.ts @@ -4,14 +4,20 @@ * 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 = 'hoerdle_player_id'; +const STORAGE_KEY_PLAYER = 'hoerdle_player_id'; +const STORAGE_KEY_DEVICE = 'hoerdle_device_id'; /** * Generate a UUID v4 */ -function generatePlayerId(): string { +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; @@ -21,68 +27,143 @@ function generatePlayerId(): string { } /** - * Try to find an existing player ID from the backend + * 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 Player ID if found, null otherwise + * @returns Base player ID if found, null otherwise */ -async function findExistingPlayerId(genreKey: string): Promise { +async function findExistingBasePlayerId(genreKey: string): Promise { try { + const deviceId = getOrCreateDeviceId(); const response = await fetch('/api/player-id/suggest', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ genreKey }), + body: JSON.stringify({ genreKey, deviceId }), }); if (response.ok) { const data = await response.json(); - if (data.playerId) { - return data.playerId; + if (data.basePlayerId) { + return data.basePlayerId; } } } catch (error) { - console.warn('[playerId] Failed to find existing player ID:', 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 * - * 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. + * Player ID format: {basePlayerId}:{deviceId} * - * @param genreKey - Optional genre key to search for existing player ID - * @returns Player identifier (UUID v4) + * 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 { if (typeof window === 'undefined') { - // Server-side: return empty string (not used on server) return ''; } - let playerId = localStorage.getItem(STORAGE_KEY); + // Always get/create device ID (device-specific) + const deviceId = getOrCreateDeviceId(); - if (!playerId) { - // Try to find an existing player ID from backend if genreKey is provided + // 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 existingId = await findExistingPlayerId(genreKey); - if (existingId) { - playerId = existingId; - localStorage.setItem(STORAGE_KEY, playerId); - return playerId; + const existingBaseId = await findExistingBasePlayerId(genreKey); + if (existingBaseId) { + basePlayerId = existingBaseId; + localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId); } } - // Generate new UUID if no existing ID found - playerId = generatePlayerId(); - localStorage.setItem(STORAGE_KEY, playerId); + // Generate new base player ID if no existing one found + if (!basePlayerId) { + basePlayerId = generateBasePlayerId(); + localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId); + } } - return playerId; + // Combine base player ID with device ID + return combinePlayerId(basePlayerId, deviceId); } /** @@ -90,31 +171,53 @@ export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise { try { - const playerId = getOrCreatePlayerId(); + // 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;