- 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
193 lines
5.9 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|
|
|