Finalize scoring system, release year integration, and fix song deletion

This commit is contained in:
Hördle Bot
2025-11-23 20:37:23 +01:00
parent e5b0512884
commit 7b975dc3e3
15 changed files with 772 additions and 98 deletions

View File

@@ -118,6 +118,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
genre: genreName
};
@@ -230,6 +231,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
releaseYear: dailyPuzzle.song.releaseYear,
special: specialName,
maxAttempts: special.maxAttempts,
unlockSteps: JSON.parse(special.unlockSteps),

View File

@@ -9,6 +9,11 @@ export interface GameState {
isSolved: boolean;
isFailed: boolean;
lastPlayed: number; // Timestamp
score: number;
replayCount: number;
skipCount: number;
scoreBreakdown: Array<{ value: number; reason: string }>;
yearGuessed: boolean;
}
export interface Statistics {
@@ -22,19 +27,31 @@ export interface Statistics {
failed: number;
}
const STORAGE_KEY = 'hoerdle_game_state';
const STATS_KEY = 'hoerdle_statistics';
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
const STATS_KEY_PREFIX = 'hoerdle_statistics';
const INITIAL_SCORE = 90;
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
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;
const createNewState = (date: string): GameState => ({
date,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
score: INITIAL_SCORE,
replayCount: 0,
skipCount: 0,
scoreBreakdown: [{ value: INITIAL_SCORE, reason: 'Start value' }],
yearGuessed: false
});
useEffect(() => {
// Load game state
const storageKey = getStorageKey();
@@ -42,30 +59,29 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
const today = getTodayISOString();
if (stored) {
const parsed: GameState = JSON.parse(stored);
const parsed = JSON.parse(stored);
if (parsed.date === today) {
setGameState(parsed);
// 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;
// Retroactively deduct points for existing guesses if possible,
// but simpler to just start at 90 for active games to avoid confusion
}
setGameState(parsed as GameState);
} else {
// New day
const newState: GameState = {
date: today,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
};
const newState = createNewState(today);
setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState));
}
} else {
// No state
const newState: GameState = {
date: today,
guesses: [],
isSolved: false,
isFailed: false,
lastPlayed: Date.now(),
};
const newState = createNewState(today);
setGameState(newState);
localStorage.setItem(storageKey, JSON.stringify(newState));
}
@@ -116,8 +132,6 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
case 6: newStats.solvedIn6++; break;
case 7: newStats.solvedIn7++; break;
default:
// For custom attempts > 7, we currently don't have specific stats buckets
// We could add a 'solvedInOther' or just ignore for now
break;
}
} else {
@@ -135,12 +149,43 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
const isSolved = correct;
const isFailed = !correct && newGuesses.length >= maxAttempts;
let newScore = gameState.score;
const newBreakdown = [...gameState.scoreBreakdown];
if (correct) {
newScore += 20;
newBreakdown.push({ value: 20, reason: 'Correct Answer' });
} else {
if (guess === 'SKIPPED') {
newScore -= 5;
newBreakdown.push({ value: -5, reason: 'Skip' });
} else {
newScore -= 3;
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
}
}
// If failed, reset score to 0
if (isFailed) {
if (newScore > 0) {
newBreakdown.push({ value: -newScore, reason: 'Game Over' });
newScore = 0;
}
}
// Ensure score doesn't go below 0
newScore = Math.max(0, newScore);
const newState = {
...gameState,
guesses: newGuesses,
isSolved,
isFailed,
lastPlayed: Date.now(),
score: newScore,
scoreBreakdown: newBreakdown,
// Update skip count if skipped
skipCount: guess === 'SKIPPED' ? gameState.skipCount + 1 : gameState.skipCount
};
saveState(newState);
@@ -151,5 +196,66 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
}
};
return { gameState, statistics, addGuess };
const giveUp = () => {
if (!gameState || gameState.isSolved || gameState.isFailed) return;
let newScore = 0;
const newBreakdown = [...gameState.scoreBreakdown];
if (gameState.score > 0) {
newBreakdown.push({ value: -gameState.score, reason: 'Gave Up' });
}
const newState = {
...gameState,
isFailed: true,
score: 0,
scoreBreakdown: newBreakdown,
lastPlayed: Date.now()
};
saveState(newState);
updateStatistics(gameState.guesses.length, false);
};
const addReplay = () => {
if (!gameState || gameState.isSolved || gameState.isFailed) return;
let newScore = gameState.score - 1;
// Ensure score doesn't go below 0
newScore = Math.max(0, newScore);
const newBreakdown = [...gameState.scoreBreakdown, { value: -1, reason: 'Replay snippet' }];
const newState = {
...gameState,
replayCount: gameState.replayCount + 1,
score: newScore,
scoreBreakdown: newBreakdown
};
saveState(newState);
};
const addYearBonus = (correct: boolean) => {
if (!gameState) return;
let newScore = gameState.score;
const newBreakdown = [...gameState.scoreBreakdown];
if (correct) {
newScore += 10;
newBreakdown.push({ value: 10, reason: 'Bonus: Correct Year' });
} else {
newBreakdown.push({ value: 0, reason: 'Bonus: Wrong Year' });
}
const newState = {
...gameState,
score: newScore,
scoreBreakdown: newBreakdown,
yearGuessed: true
};
saveState(newState);
};
return { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus };
}

121
lib/musicbrainz.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* MusicBrainz API integration for fetching release years
* API Documentation: https://musicbrainz.org/doc/MusicBrainz_API
* Rate Limiting: 50 requests per second for meaningful User-Agent strings
*/
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
const RATE_LIMIT_DELAY = 25; // 25ms between requests = ~40 req/s (safe margin)
/**
* Sleep utility for rate limiting
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetch with retry logic for HTTP 503 (rate limit exceeded)
*/
async function fetchWithRetry(url: string, maxRetries = 5): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'application/json'
}
});
// If rate limited (503), wait with exponential backoff
if (response.status === 503) {
const waitTime = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries - 1) {
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
}
}
}
throw lastError || new Error('Max retries exceeded');
}
/**
* Get the earliest release year for a song from MusicBrainz
* @param artist Artist name
* @param title Song title
* @returns Release year or null if not found
*/
export async function getReleaseYear(artist: string, title: string): Promise<number | null> {
try {
// Build search query using Lucene syntax
const query = `artist:"${artist}" AND recording:"${title}"`;
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
// Add rate limiting delay
await sleep(RATE_LIMIT_DELAY);
const response = await fetchWithRetry(url);
if (!response.ok) {
console.error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
if (!data.recordings || data.recordings.length === 0) {
console.log(`No recordings found for "${title}" by "${artist}"`);
return null;
}
// Find the earliest release year from all recordings
let earliestYear: number | null = null;
for (const recording of data.recordings) {
// Check if recording has releases
if (recording.releases && recording.releases.length > 0) {
for (const release of recording.releases) {
if (release.date) {
// Extract year from date (format: YYYY-MM-DD or YYYY)
const year = parseInt(release.date.split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
}
// Also check first-release-date on the recording itself
if (recording['first-release-date']) {
const year = parseInt(recording['first-release-date'].split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
if (earliestYear) {
console.log(`Found release year ${earliestYear} for "${title}" by "${artist}"`);
} else {
console.log(`No release year found for "${title}" by "${artist}"`);
}
return earliestYear;
} catch (error) {
console.error(`Error fetching release year for "${title}" by "${artist}":`, error);
return null;
}
}