Files
hoerdle/app/api/player-state/route.ts
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

193 lines
5.9 KiB
TypeScript

import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { getLocalizedValue } from '@/lib/i18n';
import type { GameState, Statistics } from '@/lib/gameState';
const prisma = new PrismaClient();
/**
* Validate UUID format (basic check)
* Supports both legacy format (single UUID) and new format (basePlayerId:deviceId)
*/
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;
// 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);
}
/**
* GET /api/player-state
*
* Loads player state for a given identifier and genre/special.
*
* Query parameters:
* - genre: Genre name (e.g., "Rock")
* - special: Special name (e.g., "00725")
*
* Headers:
* - X-Player-Id: Player identifier (UUID)
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const genreName = searchParams.get('genre');
const specialName = searchParams.get('special');
// Get player identifier from header
const playerId = request.headers.get('X-Player-Id');
if (!playerId || !isValidPlayerId(playerId)) {
return NextResponse.json(
{ error: 'Invalid or missing player identifier' },
{ status: 400 }
);
}
// Determine genre key
let genreKey: string;
if (specialName) {
genreKey = `special:${specialName}`;
} else if (genreName) {
genreKey = genreName;
} else {
genreKey = 'global';
}
// Load player state from database
const playerState = await prisma.playerState.findUnique({
where: {
identifier_genreKey: {
identifier: playerId,
genreKey: genreKey,
},
},
});
if (!playerState) {
return NextResponse.json(null, { status: 404 });
}
// Parse JSON strings
let gameState: GameState;
let statistics: Statistics;
try {
gameState = JSON.parse(playerState.gameState) as GameState;
statistics = JSON.parse(playerState.statistics) as Statistics;
} catch (parseError) {
console.error('[player-state] Failed to parse stored state:', parseError);
return NextResponse.json(
{ error: 'Invalid stored state format' },
{ status: 500 }
);
}
return NextResponse.json({
gameState,
statistics,
});
} catch (error) {
console.error('[player-state] Error loading player state:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
/**
* POST /api/player-state
*
* Saves player state for a given identifier and genre/special.
*
* Request body:
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
* - gameState: GameState object
* - statistics: Statistics object
*
* Headers:
* - X-Player-Id: Player identifier (UUID)
*/
export async function POST(request: Request) {
try {
// Get player identifier from header
const playerId = request.headers.get('X-Player-Id');
if (!playerId || !isValidPlayerId(playerId)) {
return NextResponse.json(
{ error: 'Invalid or missing player identifier' },
{ status: 400 }
);
}
// Parse request body
const body = await request.json();
const { genreKey, gameState, statistics } = body;
if (!genreKey || !gameState || !statistics) {
return NextResponse.json(
{ error: 'Missing required fields: genreKey, gameState, statistics' },
{ status: 400 }
);
}
// Validate genre key format
if (typeof genreKey !== 'string' || genreKey.length === 0) {
return NextResponse.json(
{ error: 'Invalid genreKey format' },
{ status: 400 }
);
}
// Serialize to JSON strings
const gameStateJson = JSON.stringify(gameState);
const statisticsJson = JSON.stringify(statistics);
// Upsert player state (update if exists, create if not)
await prisma.playerState.upsert({
where: {
identifier_genreKey: {
identifier: playerId,
genreKey: genreKey,
},
},
update: {
gameState: gameStateJson,
statistics: statisticsJson,
lastPlayed: new Date(),
},
create: {
identifier: playerId,
genreKey: genreKey,
gameState: gameStateJson,
statistics: statisticsJson,
},
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('[player-state] Error saving player state:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}