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:
@@ -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
|
||||||
return NextResponse.json({
|
const colonIndex = recentState.identifier.indexOf(':');
|
||||||
playerId: recentState.identifier,
|
if (colonIndex !== -1) {
|
||||||
lastPlayed: recentState.lastPlayed,
|
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
|
// 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);
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
175
lib/playerId.ts
175
lib/playerId.ts
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user