feat: Add backend storage for cross-domain player state synchronization
- Add PlayerState model to database schema for storing game states - Create player identifier system (UUID-based) for cross-domain sync - Implement API endpoints for loading/saving player states - Refactor gameState hook to use backend storage with localStorage fallback - Support synchronization between hoerdle.de and hördle.de - Migration automatically runs on Docker container start
This commit is contained in:
172
app/api/player-state/route.ts
Normal file
172
app/api/player-state/route.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
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)
|
||||||
|
*/
|
||||||
|
function isValidUUID(uuid: string): boolean {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 || !isValidUUID(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 || !isValidUUID(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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
|||||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
||||||
const t = useTranslations('Game');
|
const t = useTranslations('Game');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||||
const [hasWon, setHasWon] = useState(false);
|
const [hasWon, setHasWon] = useState(false);
|
||||||
const [hasLost, setHasLost] = useState(false);
|
const [hasLost, setHasLost] = useState(false);
|
||||||
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
||||||
|
|||||||
163
lib/gameState.ts
163
lib/gameState.ts
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getTodayISOString } from './dateUtils';
|
import { getTodayISOString } from './dateUtils';
|
||||||
|
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -32,12 +33,20 @@ const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
|||||||
|
|
||||||
const INITIAL_SCORE = 90;
|
const INITIAL_SCORE = 90;
|
||||||
|
|
||||||
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
|
export function useGameState(
|
||||||
|
genre: string | null = null,
|
||||||
|
maxAttempts: number = 7,
|
||||||
|
isSpecial: boolean = false
|
||||||
|
) {
|
||||||
const [gameState, setGameState] = useState<GameState | null>(null);
|
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||||
|
|
||||||
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
||||||
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
||||||
|
|
||||||
|
// Get genre key for backend storage
|
||||||
|
// For specials, genre contains the special name
|
||||||
|
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||||
|
|
||||||
const createNewState = (date: string): GameState => ({
|
const createNewState = (date: string): GameState => ({
|
||||||
date,
|
date,
|
||||||
@@ -53,71 +62,110 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load game state
|
|
||||||
const storageKey = getStorageKey();
|
|
||||||
const stored = localStorage.getItem(storageKey);
|
|
||||||
const today = getTodayISOString();
|
const today = getTodayISOString();
|
||||||
|
|
||||||
if (stored) {
|
// Try to load from backend first
|
||||||
const parsed = JSON.parse(stored);
|
const loadFromBackend = async () => {
|
||||||
if (parsed.date === today) {
|
try {
|
||||||
// Migration for existing states without score
|
const backendState = await loadPlayerState(genreKey);
|
||||||
if (parsed.score === undefined) {
|
|
||||||
parsed.score = INITIAL_SCORE;
|
if (backendState) {
|
||||||
parsed.replayCount = 0;
|
const { gameState: loadedState, statistics: loadedStats } = backendState;
|
||||||
parsed.skipCount = 0;
|
|
||||||
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
|
// Check if the loaded state is for today
|
||||||
parsed.yearGuessed = false;
|
if (loadedState.date === today) {
|
||||||
|
setGameState(loadedState);
|
||||||
// Retroactively deduct points for existing guesses if possible,
|
setStatistics(loadedStats);
|
||||||
// but simpler to just start at 90 for active games to avoid confusion
|
return; // Successfully loaded from backend
|
||||||
|
} else {
|
||||||
|
// State is for a different day - create new state
|
||||||
|
const newState = createNewState(today);
|
||||||
|
setGameState(newState);
|
||||||
|
setStatistics(loadedStats); // Keep statistics across days
|
||||||
|
// Save new state to backend
|
||||||
|
await savePlayerState(genreKey, newState, loadedStats);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[gameState] Failed to load from backend, falling back to localStorage:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to localStorage
|
||||||
|
const storageKey = getStorageKey();
|
||||||
|
const stored = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (parsed.date === today) {
|
||||||
|
// Migration for existing states without score
|
||||||
|
if (parsed.score === undefined) {
|
||||||
|
parsed.score = INITIAL_SCORE;
|
||||||
|
parsed.replayCount = 0;
|
||||||
|
parsed.skipCount = 0;
|
||||||
|
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
|
||||||
|
parsed.yearGuessed = false;
|
||||||
|
}
|
||||||
|
setGameState(parsed as GameState);
|
||||||
|
} else {
|
||||||
|
// New day
|
||||||
|
const newState = createNewState(today);
|
||||||
|
setGameState(newState);
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||||
}
|
}
|
||||||
setGameState(parsed as GameState);
|
|
||||||
} else {
|
} else {
|
||||||
// New day
|
// No state
|
||||||
const newState = createNewState(today);
|
const newState = createNewState(today);
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// No state
|
|
||||||
const newState = createNewState(today);
|
|
||||||
setGameState(newState);
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load statistics
|
// Load statistics from localStorage
|
||||||
const statsKey = getStatsKey();
|
const statsKey = getStatsKey();
|
||||||
const storedStats = localStorage.getItem(statsKey);
|
const storedStats = localStorage.getItem(statsKey);
|
||||||
if (storedStats) {
|
if (storedStats) {
|
||||||
const parsedStats = JSON.parse(storedStats);
|
const parsedStats = JSON.parse(storedStats);
|
||||||
// Migration for existing stats without solvedIn7
|
// Migration for existing stats without solvedIn7
|
||||||
if (parsedStats.solvedIn7 === undefined) {
|
if (parsedStats.solvedIn7 === undefined) {
|
||||||
parsedStats.solvedIn7 = 0;
|
parsedStats.solvedIn7 = 0;
|
||||||
|
}
|
||||||
|
setStatistics(parsedStats);
|
||||||
|
} else {
|
||||||
|
const newStats: Statistics = {
|
||||||
|
solvedIn1: 0,
|
||||||
|
solvedIn2: 0,
|
||||||
|
solvedIn3: 0,
|
||||||
|
solvedIn4: 0,
|
||||||
|
solvedIn5: 0,
|
||||||
|
solvedIn6: 0,
|
||||||
|
solvedIn7: 0,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
setStatistics(newStats);
|
||||||
|
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
||||||
}
|
}
|
||||||
setStatistics(parsedStats);
|
};
|
||||||
} else {
|
|
||||||
const newStats: Statistics = {
|
loadFromBackend();
|
||||||
solvedIn1: 0,
|
}, [genre, isSpecial, genreKey]); // Re-run when genre or isSpecial changes
|
||||||
solvedIn2: 0,
|
|
||||||
solvedIn3: 0,
|
|
||||||
solvedIn4: 0,
|
|
||||||
solvedIn5: 0,
|
|
||||||
solvedIn6: 0,
|
|
||||||
solvedIn7: 0,
|
|
||||||
failed: 0,
|
|
||||||
};
|
|
||||||
setStatistics(newStats);
|
|
||||||
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
|
||||||
}
|
|
||||||
}, [genre]); // Re-run when genre changes
|
|
||||||
|
|
||||||
const saveState = (newState: GameState) => {
|
const saveState = async (newState: GameState) => {
|
||||||
setGameState(newState);
|
setGameState(newState);
|
||||||
|
|
||||||
|
// Save to backend (primary)
|
||||||
|
if (statistics) {
|
||||||
|
try {
|
||||||
|
await savePlayerState(genreKey, newState, statistics);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[gameState] Failed to save to backend, using localStorage fallback:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also save to localStorage as backup
|
||||||
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStatistics = (attempts: number, solved: boolean) => {
|
const updateStatistics = async (attempts: number, solved: boolean) => {
|
||||||
if (!statistics) return;
|
if (!statistics) return;
|
||||||
|
|
||||||
const newStats = { ...statistics };
|
const newStats = { ...statistics };
|
||||||
@@ -139,6 +187,17 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStatistics(newStats);
|
setStatistics(newStats);
|
||||||
|
|
||||||
|
// Save to backend (primary)
|
||||||
|
if (gameState) {
|
||||||
|
try {
|
||||||
|
await savePlayerState(genreKey, gameState, newStats);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[gameState] Failed to save statistics to backend, using localStorage fallback:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also save to localStorage as backup
|
||||||
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
56
lib/playerId.ts
Normal file
56
lib/playerId.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Player Identifier Management
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hoerdle_player_id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a UUID v4
|
||||||
|
*/
|
||||||
|
function generatePlayerId(): 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;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a player identifier
|
||||||
|
*
|
||||||
|
* If no identifier exists in localStorage, a new UUID is generated and stored.
|
||||||
|
* The same identifier is used across all domains (hoerdle.de and hördle.de).
|
||||||
|
*
|
||||||
|
* @returns Player identifier (UUID v4)
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current player identifier without creating a new one
|
||||||
|
*
|
||||||
|
* @returns Player identifier or null if not set
|
||||||
|
*/
|
||||||
|
export function getPlayerId(): string | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return localStorage.getItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
130
lib/playerStorage.ts
Normal file
130
lib/playerStorage.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Player Storage API
|
||||||
|
*
|
||||||
|
* Handles loading and saving player game states from/to the backend.
|
||||||
|
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getOrCreatePlayerId } from './playerId';
|
||||||
|
import type { GameState, Statistics } from './gameState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get genre key for storage
|
||||||
|
*
|
||||||
|
* Formats the genre/special into a consistent key format:
|
||||||
|
* - Global: "global"
|
||||||
|
* - Genre: "Rock" (localized name)
|
||||||
|
* - Special: "special:00725" (special name)
|
||||||
|
*/
|
||||||
|
export function getGenreKey(
|
||||||
|
genre: string | null,
|
||||||
|
isSpecial: boolean,
|
||||||
|
specialName?: string
|
||||||
|
): string {
|
||||||
|
if (isSpecial && specialName) {
|
||||||
|
return `special:${specialName}`;
|
||||||
|
}
|
||||||
|
return genre || 'global';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load player state from backend
|
||||||
|
*
|
||||||
|
* @param genreKey - Genre key (from getGenreKey)
|
||||||
|
* @returns GameState and Statistics, or null if not found
|
||||||
|
*/
|
||||||
|
export async function loadPlayerState(
|
||||||
|
genreKey: string
|
||||||
|
): Promise<{ gameState: GameState; statistics: Statistics } | null> {
|
||||||
|
try {
|
||||||
|
const playerId = getOrCreatePlayerId();
|
||||||
|
if (!playerId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if it's a special or genre
|
||||||
|
const isSpecial = genreKey.startsWith('special:');
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (isSpecial) {
|
||||||
|
const specialName = genreKey.replace('special:', '');
|
||||||
|
params.append('special', specialName);
|
||||||
|
} else if (genreKey !== 'global') {
|
||||||
|
params.append('genre', genreKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/player-state?${params.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Player-Id': playerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
// No state found - this is normal for new players
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Other errors: log and return null (will fallback to localStorage)
|
||||||
|
console.warn('[playerStorage] Failed to load player state:', response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data || !data.gameState || !data.statistics) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameState: data.gameState as GameState,
|
||||||
|
statistics: data.statistics as Statistics,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Network errors or other issues: fallback to localStorage
|
||||||
|
console.warn('[playerStorage] Error loading player state:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save player state to backend
|
||||||
|
*
|
||||||
|
* @param genreKey - Genre key (from getGenreKey)
|
||||||
|
* @param gameState - Current game state
|
||||||
|
* @param statistics - Current statistics
|
||||||
|
*/
|
||||||
|
export async function savePlayerState(
|
||||||
|
genreKey: string,
|
||||||
|
gameState: GameState,
|
||||||
|
statistics: Statistics
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const playerId = getOrCreatePlayerId();
|
||||||
|
if (!playerId) {
|
||||||
|
console.warn('[playerStorage] No player ID available, cannot save state');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/player-state', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Player-Id': playerId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
genreKey,
|
||||||
|
gameState,
|
||||||
|
statistics,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('[playerStorage] Failed to save player state:', response.status);
|
||||||
|
// Don't throw - allow fallback to localStorage
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Network errors: log but don't throw (will fallback to localStorage)
|
||||||
|
console.warn('[playerStorage] Error saving player state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PlayerState" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"genreKey" TEXT NOT NULL,
|
||||||
|
"gameState" TEXT NOT NULL,
|
||||||
|
"statistics" TEXT NOT NULL,
|
||||||
|
"lastPlayed" DATETIME NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PlayerState_identifier_idx" ON "PlayerState"("identifier");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PlayerState_identifier_genreKey_key" ON "PlayerState"("identifier", "genreKey");
|
||||||
@@ -88,3 +88,16 @@ model News {
|
|||||||
|
|
||||||
@@index([publishedAt])
|
@@index([publishedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PlayerState {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
identifier String // UUID des Spielers (für Cross-Domain-Sync)
|
||||||
|
genreKey String // Genre-Name oder "global" oder "special:<name>"
|
||||||
|
gameState String // JSON-String des GameState
|
||||||
|
statistics String // JSON-String der Statistics
|
||||||
|
lastPlayed DateTime @updatedAt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([identifier, genreKey])
|
||||||
|
@@index([identifier])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user