Feat: Genre system with per-genre daily puzzles

This commit is contained in:
Hördle Bot
2025-11-22 11:56:16 +01:00
parent dc69fd1498
commit 8c720e287f
9 changed files with 294 additions and 155 deletions

113
lib/dailyPuzzle.ts Normal file
View File

@@ -0,0 +1,113 @@
import { PrismaClient } from '@prisma/client';
import { getTodayISOString } from './dateUtils';
const prisma = new PrismaClient();
export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
try {
const today = getTodayISOString();
let genreId: number | null = null;
if (genreName) {
const genre = await prisma.genre.findUnique({
where: { name: genreName }
});
if (genre) {
genreId = genre.id;
} else {
return null; // Genre not found
}
}
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
where: {
date: today,
genreId: genreId
},
include: { song: true },
});
console.log(`[Daily Puzzle] Date: ${today}, Genre: ${genreName || 'Global'}, Found existing: ${!!dailyPuzzle}`);
if (!dailyPuzzle) {
// Get songs available for this genre
const whereClause = genreId
? { genres: { some: { id: genreId } } }
: {}; // Global puzzle picks from ALL songs
const allSongs = await prisma.song.findMany({
where: whereClause,
include: {
puzzles: {
where: { genreId: genreId }
},
},
});
if (allSongs.length === 0) {
console.log(`[Daily Puzzle] No songs available for genre: ${genreName || 'Global'}`);
return null;
}
// Calculate weights
const weightedSongs = allSongs.map(song => ({
song,
weight: 1.0 / (song.puzzles.length + 1),
}));
// Calculate total weight
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
// Pick a random song based on weights
let random = Math.random() * totalWeight;
let selectedSong = weightedSongs[0].song;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
selectedSong = item.song;
break;
}
}
// Create the daily puzzle
try {
dailyPuzzle = await prisma.dailyPuzzle.create({
data: {
date: today,
songId: selectedSong.id,
genreId: genreId
},
include: { song: true },
});
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genreName || 'Global'}) with song: ${selectedSong.title}`);
} catch (e) {
// Handle race condition
console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely race condition)');
dailyPuzzle = await prisma.dailyPuzzle.findFirst({
where: {
date: today,
genreId: genreId
},
include: { song: true },
});
}
}
if (!dailyPuzzle) return null;
return {
id: dailyPuzzle.id,
audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
songId: dailyPuzzle.songId,
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
genre: genreName
};
} catch (error) {
console.error('Error in getOrCreateDailyPuzzle:', error);
return null;
}
}

View File

@@ -25,13 +25,20 @@ export interface Statistics {
const STORAGE_KEY = 'hoerdle_game_state';
const STATS_KEY = 'hoerdle_statistics';
export function useGameState() {
export function useGameState(genre: string | null = null) {
const [gameState, setGameState] = useState<GameState | null>(null);
const [statistics, setStatistics] = useState<Statistics | null>(null);
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
const STATS_KEY_PREFIX = 'hoerdle_statistics';
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
useEffect(() => {
// Load game state
const stored = localStorage.getItem(STORAGE_KEY);
const storageKey = getStorageKey();
const stored = localStorage.getItem(storageKey);
const today = getTodayISOString();
if (stored) {
@@ -48,7 +55,7 @@ export function useGameState() {
lastPlayed: Date.now(),
};
setGameState(newState);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
localStorage.setItem(storageKey, JSON.stringify(newState));
}
} else {
// No state
@@ -60,11 +67,12 @@ export function useGameState() {
lastPlayed: Date.now(),
};
setGameState(newState);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
localStorage.setItem(storageKey, JSON.stringify(newState));
}
// Load statistics
const storedStats = localStorage.getItem(STATS_KEY);
const statsKey = getStatsKey();
const storedStats = localStorage.getItem(statsKey);
if (storedStats) {
const parsedStats = JSON.parse(storedStats);
// Migration for existing stats without solvedIn7
@@ -84,13 +92,13 @@ export function useGameState() {
failed: 0,
};
setStatistics(newStats);
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
localStorage.setItem(statsKey, JSON.stringify(newStats));
}
}, []);
}, [genre]); // Re-run when genre changes
const saveState = (newState: GameState) => {
setGameState(newState);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newState));
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
};
const updateStatistics = (attempts: number, solved: boolean) => {
@@ -113,7 +121,7 @@ export function useGameState() {
}
setStatistics(newStats);
localStorage.setItem(STATS_KEY, JSON.stringify(newStats));
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
};
const addGuess = (guess: string, correct: boolean) => {