Compare commits

...

2 Commits

Author SHA1 Message Date
Hördle Bot
a59f6f747e chore: Bump version to 0.1.4.4 2025-12-02 01:51:43 +01:00
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
5 changed files with 224 additions and 52 deletions

View File

@@ -6,19 +6,21 @@ const prisma = new PrismaClient();
/** /**
* POST /api/player-id/suggest * POST /api/player-id/suggest
* *
* Tries to find a player ID based on recently updated states for a genre. * 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). * This helps synchronize player IDs across different domains (hoerdle.de and hördle.de)
* on the same device.
* *
* Request body: * Request body:
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725") * - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
* - deviceId: Device identifier (UUID)
* *
* Returns: * 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) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { genreKey } = body; const { genreKey, deviceId } = body;
if (!genreKey || typeof genreKey !== 'string') { if (!genreKey || typeof genreKey !== 'string') {
return NextResponse.json( return NextResponse.json(
@@ -32,6 +34,41 @@ export async function POST(request: Request) {
const cutoffDate = new Date(); const cutoffDate = new Date();
cutoffDate.setHours(cutoffDate.getHours() - 48); 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({ const recentState = await prisma.playerState.findFirst({
where: { where: {
genreKey: genreKey, genreKey: genreKey,
@@ -45,16 +82,26 @@ export async function POST(request: Request) {
}); });
if (recentState) { if (recentState) {
// Return the player ID from the most recent state // 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({ return NextResponse.json({
playerId: recentState.identifier, basePlayerId: basePlayerId,
lastPlayed: recentState.lastPlayed, lastPlayed: recentState.lastPlayed,
}); });
} else {
// Legacy format: return as-is
return NextResponse.json({
basePlayerId: recentState.identifier,
lastPlayed: recentState.lastPlayed,
});
}
} }
// No recent state found // No recent state found
return NextResponse.json({ return NextResponse.json({
playerId: null, basePlayerId: null,
}); });
} catch (error) { } catch (error) {
console.error('[player-id/suggest] Error finding player ID:', error); console.error('[player-id/suggest] Error finding player ID:', error);

View File

@@ -7,10 +7,30 @@ const prisma = new PrismaClient();
/** /**
* Validate UUID format (basic check) * 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; 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 // Get player identifier from header
const playerId = request.headers.get('X-Player-Id'); const playerId = request.headers.get('X-Player-Id');
if (!playerId || !isValidUUID(playerId)) { if (!playerId || !isValidPlayerId(playerId)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid or missing player identifier' }, { error: 'Invalid or missing player identifier' },
{ status: 400 } { status: 400 }
@@ -109,7 +129,7 @@ export async function POST(request: Request) {
try { try {
// Get player identifier from header // Get player identifier from header
const playerId = request.headers.get('X-Player-Id'); const playerId = request.headers.get('X-Player-Id');
if (!playerId || !isValidUUID(playerId)) { if (!playerId || !isValidPlayerId(playerId)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Invalid or missing player identifier' }, { error: 'Invalid or missing player identifier' },
{ status: 400 } { status: 400 }

View File

@@ -4,14 +4,20 @@
* Generates and manages a unique player identifier (UUID) that is stored * Generates and manages a unique player identifier (UUID) that is stored
* in localStorage. This identifier is used to sync game states across * in localStorage. This identifier is used to sync game states across
* different domains (hoerdle.de and hördle.de). * 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 * Generate a UUID v4
*/ */
function generatePlayerId(): string { function generateUUID(): string {
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0; 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 * @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<string | null> { async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
try { try {
const deviceId = getOrCreateDeviceId();
const response = await fetch('/api/player-id/suggest', { const response = await fetch('/api/player-id/suggest', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ genreKey }), body: JSON.stringify({ genreKey, deviceId }),
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data.playerId) { if (data.basePlayerId) {
return data.playerId; return data.basePlayerId;
} }
} }
} catch (error) { } 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; 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 * Get or create a player identifier
* *
* If no identifier exists in localStorage, tries to find an existing one from the backend * Player ID format: {basePlayerId}:{deviceId}
* (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 * If no identifier exists in localStorage, tries to find an existing base player ID
* @returns Player identifier (UUID v4) * 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> { export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
// Server-side: return empty string (not used on server)
return ''; return '';
} }
let playerId = localStorage.getItem(STORAGE_KEY); // Always get/create device ID (device-specific)
const deviceId = getOrCreateDeviceId();
if (!playerId) { // Try to get base player ID from localStorage
// Try to find an existing player ID from backend if genreKey is provided 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) { if (genreKey) {
const existingId = await findExistingPlayerId(genreKey); const existingBaseId = await findExistingBasePlayerId(genreKey);
if (existingId) { if (existingBaseId) {
playerId = existingId; basePlayerId = existingBaseId;
localStorage.setItem(STORAGE_KEY, playerId); localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
return playerId;
} }
} }
// Generate new UUID if no existing ID found // Generate new base player ID if no existing one found
playerId = generatePlayerId(); if (!basePlayerId) {
localStorage.setItem(STORAGE_KEY, playerId); 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<strin
* *
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead. * This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
* *
* @returns Player identifier (UUID v4) * @returns Full player identifier ({basePlayerId}:{deviceId})
*/ */
export function getOrCreatePlayerId(): string { export function getOrCreatePlayerId(): string {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
// Server-side: return empty string (not used on server)
return ''; return '';
} }
let playerId = localStorage.getItem(STORAGE_KEY); const deviceId = getOrCreateDeviceId();
if (!playerId) { let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
playerId = generatePlayerId();
localStorage.setItem(STORAGE_KEY, playerId); if (!basePlayerId) {
basePlayerId = generateBasePlayerId();
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
} }
return playerId;
return combinePlayerId(basePlayerId, deviceId);
} }
/** /**
* Get the current player identifier without creating a new one * Get the current player identifier without creating a new one
* *
* @returns Player identifier or null if not set * @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
*/ */
export function getPlayerId(): string | null { export function getPlayerId(): string | null {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return null; return null;
} }
return localStorage.getItem(STORAGE_KEY);
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);
} }

View File

@@ -101,7 +101,9 @@ export async function savePlayerState(
statistics: Statistics statistics: Statistics
): Promise<void> { ): Promise<void> {
try { try {
const playerId = getOrCreatePlayerId(); // Use async version to ensure device ID is included
const { getOrCreatePlayerIdAsync } = await import('./playerId');
const playerId = await getOrCreatePlayerIdAsync();
if (!playerId) { if (!playerId) {
console.warn('[playerStorage] No player ID available, cannot save state'); console.warn('[playerStorage] No player ID available, cannot save state');
return; return;

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoerdle", "name": "hoerdle",
"version": "0.1.4.3", "version": "0.1.4.4",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",