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
This commit is contained in:
Hördle Bot
2025-12-02 01:49:45 +01:00
parent 1613bf0dda
commit dc763c88a3
4 changed files with 223 additions and 51 deletions

View File

@@ -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);

View File

@@ -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 }

View File

@@ -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<string | null> {
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 }),
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<string> {
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<strin
*
* 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 {
if (typeof window === 'undefined') {
// Server-side: return empty string (not used on server)
return '';
}
let playerId = localStorage.getItem(STORAGE_KEY);
if (!playerId) {
playerId = generatePlayerId();
localStorage.setItem(STORAGE_KEY, playerId);
const deviceId = getOrCreateDeviceId();
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
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
*
* @returns Player identifier or null if not set
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
*/
export function getPlayerId(): string | null {
if (typeof window === 'undefined') {
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
): Promise<void> {
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;