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

1
.release-years-migrated Normal file
View File

@@ -0,0 +1 @@
2025-11-23T18:51:44.711Z

View File

@@ -39,6 +39,19 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Einzelne Segmente zum Testen abspielen.
- Manuelle Speicherung mit visueller Bestätigung.
## Spielregeln & Punktesystem
Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen.
- **Start-Punktestand:** 90 Punkte
- **Richtige Antwort:** +20 Punkte
- **Falsche Antwort:** -3 Punkte
- **Überspringen (Skip):** -5 Punkte
- **Snippet erneut abspielen (Replay):** -1 Punkt
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
## Tech Stack
- **Framework:** Next.js 16 (App Router)

View File

@@ -3,13 +3,14 @@
const GOTIFY_URL = process.env.GOTIFY_URL;
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null) {
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null, score?: number) {
try {
const genreText = genre ? `[${genre}] ` : '';
const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
const scoreText = score !== undefined ? ` with a score of ${score}` : '';
const message = status === 'won'
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s).`
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s).`;
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s)${scoreText}.`
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s)${scoreText}.`;
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
method: 'POST',

View File

@@ -40,6 +40,7 @@ interface Song {
artist: string;
filename: string;
createdAt: string;
releaseYear: number | null;
activations: number;
puzzles: DailyPuzzle[];
genres: Genre[];
@@ -48,7 +49,7 @@ interface Song {
ratingCount: number;
}
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
type SortDirection = 'asc' | 'desc';
export default function AdminPage() {
@@ -91,6 +92,7 @@ export default function AdminPage() {
const [editingId, setEditingId] = useState<number | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editArtist, setEditArtist] = useState('');
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
@@ -577,6 +579,7 @@ export default function AdminPage() {
setEditingId(song.id);
setEditTitle(song.title);
setEditArtist(song.artist);
setEditReleaseYear(song.releaseYear || '');
setEditGenreIds(song.genres.map(g => g.id));
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
};
@@ -585,6 +588,7 @@ export default function AdminPage() {
setEditingId(null);
setEditTitle('');
setEditArtist('');
setEditReleaseYear('');
setEditGenreIds([]);
setEditSpecialIds([]);
};
@@ -597,6 +601,7 @@ export default function AdminPage() {
id,
title: editTitle,
artist: editArtist,
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
genreIds: editGenreIds,
specialIds: editSpecialIds
}),
@@ -706,10 +711,15 @@ export default function AdminPage() {
});
const sortedSongs = [...filteredSongs].sort((a, b) => {
// Handle numeric sorting for ID
// Handle numeric sorting for ID and Release Year
if (sortField === 'id') {
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
}
if (sortField === 'releaseYear') {
const yearA = a.releaseYear || 0;
const yearB = b.releaseYear || 0;
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
}
// String sorting for other fields
const valA = String(a[sortField]).toLowerCase();
@@ -1223,10 +1233,15 @@ export default function AdminPage() {
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('title')}
>
Title {sortField === 'title' && (sortDirection === 'asc' ? '' : '')}
Song {sortField === 'title' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Genres</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('releaseYear')}
>
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '' : '')}
</th>
<th style={{ padding: '0.75rem' }}>Genres / Specials</th>
<th
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('createdAt')}
@@ -1263,6 +1278,16 @@ export default function AdminPage() {
placeholder="Artist"
/>
</td>
<td style={{ padding: '0.75rem' }}>
<input
type="number"
value={editReleaseYear}
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
className="form-input"
style={{ padding: '0.25rem', width: '80px' }}
placeholder="Year"
/>
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres.map(genre => (
@@ -1369,6 +1394,9 @@ export default function AdminPage() {
})}
</div>
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
{song.releaseYear || '-'}
</td>
<td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{song.genres?.map(g => (

View File

@@ -29,6 +29,7 @@ export async function GET() {
filename: song.filename,
createdAt: song.createdAt,
coverImage: song.coverImage,
releaseYear: song.releaseYear,
activations: song.puzzles.length,
puzzles: song.puzzles,
genres: song.genres,
@@ -179,12 +180,25 @@ export async function POST(request: Request) {
console.error('Failed to extract cover image:', e);
}
// Fetch release year from MusicBrainz
let releaseYear = null;
try {
const { getReleaseYear } = await import('@/lib/musicbrainz');
releaseYear = await getReleaseYear(artist, title);
if (releaseYear) {
console.log(`Fetched release year ${releaseYear} for "${title}" by "${artist}"`);
}
} catch (e) {
console.error('Failed to fetch release year from MusicBrainz:', e);
}
const song = await prisma.song.create({
data: {
title,
artist,
filename,
coverImage,
releaseYear,
},
include: { genres: true, specials: true }
});
@@ -201,7 +215,7 @@ export async function POST(request: Request) {
export async function PUT(request: Request) {
try {
const { id, title, artist, genreIds, specialIds } = await request.json();
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json();
if (!id || !title || !artist) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
@@ -209,6 +223,11 @@ export async function PUT(request: Request) {
const data: any = { title, artist };
// Update releaseYear if provided (can be null to clear it)
if (releaseYear !== undefined) {
data.releaseYear = releaseYear;
}
if (genreIds) {
data.genres = {
set: genreIds.map((gId: number) => ({ id: gId }))

View File

@@ -7,13 +7,15 @@ interface AudioPlayerProps {
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
startTime?: number; // Start offset in seconds (for curated specials)
onPlay?: () => void;
onReplay?: () => void;
autoPlay?: boolean;
}
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, autoPlay = false }: AudioPlayerProps) {
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
useEffect(() => {
if (audioRef.current) {
@@ -21,6 +23,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
audioRef.current.currentTime = startTime;
setIsPlaying(false);
setProgress(0);
setHasPlayedOnce(false); // Reset for new segment
if (autoPlay) {
const playPromise = audioRef.current.play();
@@ -29,6 +32,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
.then(() => {
setIsPlaying(true);
onPlay?.();
setHasPlayedOnce(true);
})
.catch(error => {
console.log("Autoplay prevented:", error);
@@ -47,6 +51,12 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
} else {
audioRef.current.play();
onPlay?.();
if (hasPlayedOnce) {
onReplay?.();
} else {
setHasPlayedOnce(true);
}
}
setIsPlaying(!isPlaying);
};

View File

@@ -16,6 +16,7 @@ interface GameProps {
title: string;
artist: string;
coverImage: string | null;
releaseYear?: number | null;
startTime?: number;
} | null;
genre?: string | null;
@@ -27,7 +28,7 @@ interface GameProps {
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) {
const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts);
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus } = useGameState(genre, maxAttempts);
const [hasWon, setHasWon] = useState(false);
const [hasLost, setHasLost] = useState(false);
const [shareText, setShareText] = useState('🔗 Share');
@@ -35,6 +36,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
const [timeUntilNext, setTimeUntilNext] = useState('');
const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false);
useEffect(() => {
const updateCountdown = () => {
@@ -50,7 +52,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
};
updateCountdown();
const interval = setInterval(updateCountdown, 1000); // Update every second to be accurate
const interval = setInterval(updateCountdown, 1000);
return () => clearInterval(interval);
}, []);
@@ -58,6 +60,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (gameState && dailyPuzzle) {
setHasWon(gameState.isSolved);
setHasLost(gameState.isFailed);
// Show year modal if won but year not guessed yet and release year is available
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) {
setShowYearModal(true);
}
}
}, [gameState, dailyPuzzle]);
@@ -87,37 +94,61 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
if (!gameState) return <div>Loading state...</div>;
const handleGuess = (song: any) => {
if (isProcessingGuess) return; // Prevent multiple guesses
if (isProcessingGuess) return;
setIsProcessingGuess(true);
setLastAction('GUESS');
if (song.id === dailyPuzzle.songId) {
addGuess(song.title, true);
setHasWon(true);
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre);
// Notification sent after year guess or skip
if (!dailyPuzzle.releaseYear) {
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
}
} else {
addGuess(song.title, false);
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false); // Ensure won is false
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
}
// Reset after a short delay to allow UI update
setTimeout(() => setIsProcessingGuess(false), 500);
};
const handleSkip = () => {
setLastAction('SKIP');
addGuess("SKIPPED", false);
if (gameState.guesses.length + 1 >= maxAttempts) {
setHasLost(true);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
}
};
const handleGiveUp = () => {
setLastAction('SKIP');
addGuess("SKIPPED", false);
giveUp(); // Ensure game is marked as failed and score reset to 0
setHasLost(true);
setHasWon(false);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
};
const handleYearGuess = (year: number) => {
const correct = year === dailyPuzzle.releaseYear;
addYearBonus(correct);
setShowYearModal(false);
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
};
const handleYearSkip = () => {
setShowYearModal(false);
// Send notification now that game is fully complete
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
};
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
@@ -126,21 +157,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
let emojiGrid = '';
const totalGuesses = maxAttempts;
// Build the grid
for (let i = 0; i < totalGuesses; i++) {
if (i < gameState.guesses.length) {
// If this was the winning guess (last one and won)
if (hasWon && i === gameState.guesses.length - 1) {
emojiGrid += '🟩';
} else if (gameState.guesses[i] === 'SKIPPED') {
// Skipped
emojiGrid += '⬛';
} else {
// Wrong guess
emojiGrid += '🟥';
}
} else {
// Unused attempts
emojiGrid += '⬜';
}
}
@@ -148,7 +174,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const speaker = hasWon ? '🔉' : '🔇';
const genreText = genre ? `Genre: ${genre}\n` : '';
// Generate URL with genre/special path
let shareUrl = 'https://hoerdle.elpatron.me';
if (genre) {
if (isSpecial) {
@@ -158,9 +183,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
}
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\n${shareUrl}`;
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
// Try native Web Share API only on mobile devices
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile && navigator.share) {
@@ -173,14 +197,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
setTimeout(() => setShareText('🔗 Share'), 2000);
return;
} catch (err) {
// User cancelled or error - fall through to clipboard
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
}
}
}
// Fallback: Copy to clipboard
try {
await navigator.clipboard.writeText(text);
setShareText('✓ Copied!');
@@ -192,8 +214,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
};
const handleRatingSubmit = async (rating: number) => {
if (!dailyPuzzle) return;
@@ -201,7 +221,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
setHasRated(true);
// Persist to localStorage
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
ratedPuzzles.push(dailyPuzzle.id);
@@ -222,17 +241,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</header>
<main className="game-board">
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
<div className="status-bar">
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
<span>{unlockedSeconds}s unlocked</span>
</div>
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
<AudioPlayer
src={dailyPuzzle.audioUrl}
unlockedSeconds={unlockedSeconds}
startTime={dailyPuzzle.startTime}
autoPlay={lastAction === 'SKIP'}
onReplay={addReplay}
/>
</div>
@@ -253,7 +275,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
{!hasWon && !hasLost && (
<>
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
{gameState.guesses.length < 6 ? (
{gameState.guesses.length < maxAttempts - 1 ? (
<button
onClick={handleSkip}
className="skip-button"
@@ -275,12 +297,32 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</>
)}
{hasWon && (
<div className="message-box success">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
<p>Come back tomorrow for a new song.</p>
{(hasWon || hasLost) && (
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{hasWon ? 'You won!' : 'Game Over'}
</h2>
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
Score: {gameState.score}
</div>
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
<summary>Score Breakdown</summary>
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
{gameState.scoreBreakdown.map((item, i) => (
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
<span>{item.reason}</span>
<span style={{ fontWeight: 'bold', color: item.value >= 0 ? 'green' : 'red' }}>
{item.value > 0 ? '+' : ''}{item.value}
</span>
</li>
))}
</ul>
</details>
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<img
src={dailyPuzzle.coverImage || '/favicon.ico'}
@@ -288,14 +330,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>{dailyPuzzle.artist}</p>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
{dailyPuzzle.releaseYear && (
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
)}
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{/* Rating Component */}
<div style={{ marginBottom: '1rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
@@ -306,40 +350,150 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
</button>
</div>
)}
{hasLost && (
<div className="message-box failure">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
<p>The song was:</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<img
src={dailyPuzzle.coverImage || '/favicon.ico'}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>{dailyPuzzle.artist}</p>
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{/* Rating Component */}
<div style={{ marginBottom: '1rem' }}>
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
</div>
{statistics && <Statistics statistics={statistics} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText}
</button>
</div>
)}
</main>
{showYearModal && dailyPuzzle.releaseYear && (
<YearGuessModal
correctYear={dailyPuzzle.releaseYear}
onGuess={handleYearGuess}
onSkip={handleYearSkip}
/>
)}
</div>
);
}
function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{ value: number, reason: string }> }) {
const tooltipText = breakdown.map(item => `${item.reason}: ${item.value > 0 ? '+' : ''}${item.value}`).join('\n');
// Create expression: "90 - 2 - 5 + 10"
// Limit to last 5 items to avoid overflow if too long
const displayItems = breakdown.length > 5 ?
[{ value: breakdown[0].value, reason: 'Start' }, ...breakdown.slice(-4)] :
breakdown;
const expression = displayItems.map((item, index) => {
if (index === 0 && breakdown.length <= 5) return item.value.toString();
if (index === 0 && breakdown.length > 5) return `${item.value} ...`;
return item.value >= 0 ? `+ ${item.value}` : `- ${Math.abs(item.value)}`;
}).join(' ');
return (
<div className="score-display" title={tooltipText} style={{
textAlign: 'center',
margin: '0.5rem 0',
padding: '0.5rem',
background: '#f3f4f6',
borderRadius: '0.5rem',
fontSize: '0.9rem',
fontFamily: 'monospace',
cursor: 'help'
}}>
<span style={{ color: '#666' }}>{expression} = </span>
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
</div>
);
}
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
const [options, setOptions] = useState<number[]>([]);
useEffect(() => {
const currentYear = new Date().getFullYear();
const minYear = 1950;
const closeOptions = new Set<number>();
closeOptions.add(correctYear);
// Add 2 close years (+/- 2)
while (closeOptions.size < 3) {
const offset = Math.floor(Math.random() * 5) - 2;
const year = correctYear + offset;
if (year <= currentYear && year >= minYear && year !== correctYear) {
closeOptions.add(year);
}
}
const allOptions = new Set(closeOptions);
// Fill up to 10 with random years
while (allOptions.size < 10) {
const year = Math.floor(Math.random() * (currentYear - minYear + 1)) + minYear;
allOptions.add(year);
}
setOptions(Array.from(allOptions).sort((a, b) => a - b));
}, [correctYear]);
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem'
}}>
<div style={{
background: 'white',
padding: '2rem',
borderRadius: '1rem',
maxWidth: '500px',
width: '100%',
textAlign: 'center',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
}}>
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
gap: '0.75rem',
marginBottom: '1.5rem'
}}>
{options.map(year => (
<button
key={year}
onClick={() => onGuess(year)}
style={{
padding: '0.75rem',
background: '#f3f4f6',
border: '2px solid #e5e7eb',
borderRadius: '0.5rem',
fontSize: '1.1rem',
fontWeight: 'bold',
color: '#374151',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
>
{year}
</button>
))}
</div>
<button
onClick={onSkip}
style={{
background: 'none',
border: 'none',
color: '#6b7280',
textDecoration: 'underline',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
Skip Bonus
</button>
</div>
</div>
);
}

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;
}
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Song" ADD COLUMN "releaseYear" INTEGER;

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_DailyPuzzle" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"date" TEXT NOT NULL,
"songId" INTEGER NOT NULL,
"genreId" INTEGER,
"specialId" INTEGER,
CONSTRAINT "DailyPuzzle_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "DailyPuzzle_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_DailyPuzzle" ("date", "genreId", "id", "songId", "specialId") SELECT "date", "genreId", "id", "songId", "specialId" FROM "DailyPuzzle";
DROP TABLE "DailyPuzzle";
ALTER TABLE "new_DailyPuzzle" RENAME TO "DailyPuzzle";
CREATE UNIQUE INDEX "DailyPuzzle_date_genreId_specialId_key" ON "DailyPuzzle"("date", "genreId", "specialId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -16,6 +16,7 @@ model Song {
artist String
filename String // Filename in public/uploads
coverImage String? // Filename in public/uploads/covers
releaseYear Int? // Release year from MusicBrainz
createdAt DateTime @default(now())
puzzles DailyPuzzle[]
genres Genre[]
@@ -62,7 +63,7 @@ model DailyPuzzle {
id Int @id @default(autoincrement())
date String // Format: YYYY-MM-DD
songId Int
song Song @relation(fields: [songId], references: [id])
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
genreId Int?
genre Genre? @relation(fields: [genreId], references: [id])
specialId Int?

View File

@@ -7,6 +7,12 @@ echo "Starting deployment..."
echo "Running database migrations..."
npx prisma migrate deploy
# Run release year migration (only if not already done)
if [ ! -f /app/.release-years-migrated ]; then
echo "Running release year migration (this will take ~12 seconds for 600 songs)..."
node scripts/migrate-release-years.mjs
fi
# Start the application
echo "Starting application..."
exec node server.js

View File

@@ -0,0 +1,190 @@
import { PrismaClient } from '@prisma/client';
import { writeFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const prisma = new PrismaClient();
// --- MusicBrainz Logic (Embedded to avoid TS import issues in Docker) ---
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
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchWithRetry(url, maxRetries = 5) {
let lastError = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, {
headers: {
'User-Agent': USER_AGENT,
'Accept': 'application/json'
}
});
if (response.status === 503) {
const waitTime = Math.pow(2, attempt) * 1000;
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
await sleep(waitTime);
continue;
}
return response;
} catch (error) {
lastError = 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');
}
async function getReleaseYear(artist, title) {
try {
const query = `artist:"${artist}" AND recording:"${title}"`;
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
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;
}
let earliestYear = null;
for (const recording of data.recordings) {
if (recording.releases && recording.releases.length > 0) {
for (const release of recording.releases) {
if (release.date) {
const year = parseInt(release.date.split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
}
if (recording['first-release-date']) {
const year = parseInt(recording['first-release-date'].split('-')[0]);
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
earliestYear = year;
}
}
}
return earliestYear;
} catch (error) {
console.error(`Error fetching release year for "${title}" by "${artist}":`, error.message);
return null;
}
}
// --- Migration Logic ---
async function migrate() {
console.log('🎵 Starting release year migration...');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
try {
// Find songs without release year
const songs = await prisma.song.findMany({
where: {
releaseYear: null
},
orderBy: {
id: 'asc'
}
});
console.log(`📊 Found ${songs.length} songs without release year.\n`);
if (songs.length === 0) {
console.log('✅ All songs already have release years!');
await createFlagFile();
return;
}
let processed = 0;
let successful = 0;
let failed = 0;
const startTime = Date.now();
for (const song of songs) {
processed++;
// const progress = `[${processed}/${songs.length}]`;
try {
// console.log(`${progress} Processing: "${song.title}" by "${song.artist}"`);
const releaseYear = await getReleaseYear(song.artist, song.title);
if (releaseYear) {
await prisma.song.update({
where: { id: song.id },
data: { releaseYear }
});
successful++;
// console.log(` ✅ Updated with year: ${releaseYear}`);
} else {
failed++;
// console.log(` ⚠️ No release year found`);
}
} catch (error) {
failed++;
console.error(` ❌ Error processing song:`, error instanceof Error ? error.message : error);
}
// Progress update every 10 songs (less verbose)
if (processed % 10 === 0 || processed === songs.length) {
const elapsed = Math.round((Date.now() - startTime) / 1000);
const rate = processed / (elapsed || 1);
const remaining = songs.length - processed;
const eta = Math.round(remaining / rate);
process.stdout.write(`\r📈 Progress: ${processed}/${songs.length} | Success: ${successful} | Failed: ${failed} | ETA: ${eta}s`);
}
}
const totalTime = Math.round((Date.now() - startTime) / 1000);
console.log('\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ Migration completed!');
console.log(`📊 Total: ${processed} | Success: ${successful} | Failed: ${failed}`);
console.log(`⏱️ Time: ${totalTime}s (${(processed / (totalTime || 1)).toFixed(2)} songs/s)`);
await createFlagFile();
} catch (error) {
console.error('❌ Migration failed:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
async function createFlagFile() {
const flagPath = path.join(process.cwd(), '.release-years-migrated');
await writeFile(flagPath, new Date().toISOString());
console.log(`\n🏁 Created flag file: ${flagPath}`);
}
migrate();