Compare commits

..

9 Commits

Author SHA1 Message Date
Hördle Bot
28afaf598b Bump version to v0.1.4.6 2025-12-02 11:10:13 +01:00
Hördle Bot
8239753911 feat: Enhance Game component with extra puzzles feature
- Introduce requiredDailyKeys to track daily puzzle completion across genres.
- Implement logic to show an ExtraPuzzlesPopover when all daily puzzles are completed.
- Add localized messages for extra puzzles in both English and German.
- Update GenrePage and Home components to pass requiredDailyKeys to the Game component.
2025-12-02 10:59:22 +01:00
Hördle Bot
0bfcf0737e Bump version to v0.1.4.5 2025-12-02 10:00:42 +01:00
Hördle Bot
5409196008 fix: Update domain handling for sharing URLs
- Modify currentHost logic to always share "hördle.de" instead of the Punycode variant when applicable.
- Ensure compatibility with both hoerdle.de and hördle.de for improved user experience.
2025-12-02 09:40:15 +01:00
Hördle Bot
a59f6f747e chore: Bump version to 0.1.4.4 2025-12-02 01:51:43 +01:00
Hördle Bot
dc763c88a3 feat: Add device-specific isolation for player IDs
- 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
2025-12-02 01:49:45 +01:00
Hördle Bot
1613bf0dda chore: Bump version to 0.1.4.3 2025-12-02 01:28:34 +01:00
Hördle Bot
b872e87b50 feat: Add -5 points penalty for track extension on wrong guesses
- Add -5 points penalty for track extension (unlock steps) on wrong guesses
- Wrong guess now costs -8 points total (-3 for wrong + -5 for extension)
- Skip remains at -5 points (no additional penalty)
- Update documentation (README.md, SCORING_OPTIONS.md)
- Add SCORING_OPTIONS.md with detailed scoring system analysis
2025-12-02 01:28:27 +01:00
Hördle Bot
87c1ee63ec feat: Add tooltip to star rating and support section updates
- Add tooltip to star rating component encouraging users to help curators
- Add curator application information to support section
- Add bug report email link to support section
- All changes localized (de/en)
2025-12-02 01:00:08 +01:00
17 changed files with 876 additions and 59 deletions

View File

@@ -77,13 +77,15 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
- **Start-Punktestand:** 90 Punkte
- **Richtige Antwort:** +20 Punkte
- **Falsche Antwort:** -3 Punkte
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
- **Ü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.
**Hinweis:** Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die automatische Verlängerung des Audio-Snippets (unlockSteps) abgezogen, um die Verwendung dieses Hilfsmittels zu reflektieren.
## Tech Stack
- **Framework:** Next.js 16 (App Router)

View File

@@ -87,6 +87,9 @@ export default async function GenrePage({ params }: PageProps) {
return s.launchDate && s.launchDate > now;
});
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
return (
<>
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
@@ -156,7 +159,7 @@ export default async function GenrePage({ params }: PageProps) {
)}
</div>
<NewsSection locale={locale} />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} requiredDailyKeys={requiredDailyKeys} />
</>
);
}

View File

@@ -206,6 +206,58 @@ export default async function AboutPage({ params }: AboutPageProps) {
</p>
</div>
</div>
<div
style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
backgroundColor: "#f9fafb",
marginBottom: "0.5rem",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
{t("supportCuratorTitle")}
</h3>
<p style={{ marginBottom: 0 }}>
{t("supportCuratorText")}
</p>
</div>
<div
style={{
padding: "1rem",
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
backgroundColor: "#f9fafb",
}}
>
<h3
style={{
fontSize: "1.125rem",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
{t("supportReportBugTitle")}
</h3>
<p style={{ marginBottom: 0 }}>
{t.rich("supportReportBugText", {
email: (chunks) => (
<a
href="mailto:admin@hoerdle.de"
style={{ textDecoration: "underline" }}
>
{chunks}
</a>
),
})}
</p>
</div>
</section>
<section style={{ marginBottom: "2rem" }}>

View File

@@ -61,6 +61,9 @@ export default async function Home({
return s.launchDate && s.launchDate > now;
});
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
return (
<>
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
@@ -149,7 +152,7 @@ export default async function Home({
<NewsSection locale={locale} />
</div>
<Game dailyPuzzle={dailyPuzzle} genre={null} />
<Game dailyPuzzle={dailyPuzzle} genre={null} requiredDailyKeys={requiredDailyKeys} />
<OnboardingTour />
</>
);

View File

@@ -6,19 +6,21 @@ const prisma = new PrismaClient();
/**
* POST /api/player-id/suggest
*
* Tries to find a player ID based on recently updated states for a genre.
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de).
* Tries to find a base player ID based on recently updated states for a genre and device.
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de)
* on the same device.
*
* Request body:
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
* - deviceId: Device identifier (UUID)
*
* Returns:
* - playerId: Suggested player ID (UUID) if found, null otherwise
* - basePlayerId: Suggested base player ID (UUID) if found, null otherwise
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { genreKey } = body;
const { genreKey, deviceId } = body;
if (!genreKey || typeof genreKey !== 'string') {
return NextResponse.json(
@@ -32,6 +34,41 @@ export async function POST(request: Request) {
const cutoffDate = new Date();
cutoffDate.setHours(cutoffDate.getHours() - 48);
// If deviceId is provided, search for states with matching device ID
// Format: {basePlayerId}:{deviceId}
if (deviceId && typeof deviceId === 'string') {
// Search for states with the same device ID
const recentStates = await prisma.playerState.findMany({
where: {
genreKey: genreKey,
lastPlayed: {
gte: cutoffDate,
},
identifier: {
endsWith: `:${deviceId}`,
},
},
orderBy: {
lastPlayed: 'desc',
},
take: 1,
});
if (recentStates.length > 0) {
const recentState = recentStates[0];
// Extract base player ID from full identifier
const colonIndex = recentState.identifier.indexOf(':');
if (colonIndex !== -1) {
const basePlayerId = recentState.identifier.substring(0, colonIndex);
return NextResponse.json({
basePlayerId: basePlayerId,
lastPlayed: recentState.lastPlayed,
});
}
}
}
// Fallback: Find any recent state for this genre (legacy support)
const recentState = await prisma.playerState.findFirst({
where: {
genreKey: genreKey,
@@ -45,16 +82,26 @@ export async function POST(request: Request) {
});
if (recentState) {
// Return the player ID from the most recent state
return NextResponse.json({
playerId: recentState.identifier,
lastPlayed: recentState.lastPlayed,
});
// Extract base player ID if format is basePlayerId:deviceId
const colonIndex = recentState.identifier.indexOf(':');
if (colonIndex !== -1) {
const basePlayerId = recentState.identifier.substring(0, colonIndex);
return NextResponse.json({
basePlayerId: basePlayerId,
lastPlayed: recentState.lastPlayed,
});
} else {
// Legacy format: return as-is
return NextResponse.json({
basePlayerId: recentState.identifier,
lastPlayed: recentState.lastPlayed,
});
}
}
// No recent state found
return NextResponse.json({
playerId: null,
basePlayerId: null,
});
} catch (error) {
console.error('[player-id/suggest] Error finding player ID:', error);

View File

@@ -7,10 +7,30 @@ const prisma = new PrismaClient();
/**
* Validate UUID format (basic check)
* Supports both legacy format (single UUID) and new format (basePlayerId:deviceId)
*/
function isValidUUID(uuid: string): boolean {
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;
return uuidRegex.test(uuid);
// 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);
}
/**
@@ -33,7 +53,7 @@ export async function GET(request: Request) {
// Get player identifier from header
const playerId = request.headers.get('X-Player-Id');
if (!playerId || !isValidUUID(playerId)) {
if (!playerId || !isValidPlayerId(playerId)) {
return NextResponse.json(
{ error: 'Invalid or missing player identifier' },
{ status: 400 }
@@ -109,7 +129,7 @@ export async function POST(request: Request) {
try {
// Get player identifier from header
const playerId = request.headers.get('X-Player-Id');
if (!playerId || !isValidUUID(playerId)) {
if (!playerId || !isValidPlayerId(playerId)) {
return NextResponse.json(
{ error: 'Invalid or missing player identifier' },
{ status: 400 }

View File

@@ -0,0 +1,98 @@
'use client';
import { useTranslations, useLocale } from 'next-intl';
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
interface ExtraPuzzlesPopoverProps {
puzzle: ExternalPuzzle;
onClose: () => void;
}
export default function ExtraPuzzlesPopover({ puzzle, onClose }: ExtraPuzzlesPopoverProps) {
const t = useTranslations('ExtraPuzzles');
const locale = useLocale();
const name = locale === 'de' ? puzzle.nameDe : puzzle.nameEn;
const handleClick = () => {
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('extra_puzzles_click', {
props: {
partner: puzzle.id,
url: puzzle.url,
},
});
}
onClose();
};
return (
<div
style={{
position: 'fixed',
bottom: '1.5rem',
right: '1.5rem',
zIndex: 1100,
maxWidth: '320px',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
borderRadius: '0.75rem',
background: 'white',
padding: '1rem 1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>
{t('title')}
</h3>
<button
onClick={onClose}
aria-label={t('close')}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: '1.1rem',
lineHeight: 1,
color: '#6b7280',
}}
>
×
</button>
</div>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#4b5563' }}>
{t('message', { name })}
</p>
<a
href={puzzle.url}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.4rem',
marginTop: '0.25rem',
padding: '0.5rem 0.75rem',
borderRadius: '999px',
border: 'none',
background: 'linear-gradient(135deg, #4f46e5, #ec4899)',
color: 'white',
fontSize: '0.9rem',
fontWeight: 600,
textDecoration: 'none',
cursor: 'pointer',
}}
>
{t('cta', { name })}
</a>
</div>
);
}

View File

@@ -6,7 +6,12 @@ import { useTranslations, useLocale } from 'next-intl';
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
import GuessInput from './GuessInput';
import Statistics from './Statistics';
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
import { useGameState } from '../lib/gameState';
import { getGenreKey } from '@/lib/playerStorage';
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
import { sendGotifyNotification, submitRating } from '../app/actions';
// Plausible Analytics
@@ -32,11 +37,14 @@ interface GameProps {
isSpecial?: boolean;
maxAttempts?: number;
unlockSteps?: number[];
// List of genre keys that zusammen alle Tagesrätsel des Tages repräsentieren (z. B. ['global', 'Rock', 'Pop']).
// Wird genutzt, um zu prüfen, ob der Spieler alle Tagesrätsel gespielt hat.
requiredDailyKeys?: string[];
}
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, requiredDailyKeys }: GameProps) {
const t = useTranslations('Game');
const locale = useLocale();
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
@@ -49,6 +57,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const [hasRated, setHasRated] = useState(false);
const [showYearModal, setShowYearModal] = useState(false);
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
useEffect(() => {
@@ -81,6 +91,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
}
}, [gameState, dailyPuzzle]);
// Track gespielte Tagesrätsel & entscheide, ob das Partner-Popover gezeigt werden soll
useEffect(() => {
if (!gameState || !dailyPuzzle) return;
const gameEnded = gameState.isSolved || gameState.isFailed;
if (!gameEnded) return;
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
markDailyPuzzlePlayedToday(genreKey);
if (!requiredDailyKeys || requiredDailyKeys.length === 0) return;
if (hasSeenExtraPuzzlesPopoverToday()) return;
if (!hasPlayedAllDailyPuzzlesForToday(requiredDailyKeys)) return;
const partnerPuzzle = getRandomExternalPuzzle();
if (!partnerPuzzle) return;
setExtraPuzzle(partnerPuzzle);
setShowExtraPuzzlesPopover(true);
markExtraPuzzlesPopoverShownToday();
if (typeof window !== 'undefined' && window.plausible) {
window.plausible('extra_puzzles_popover_shown', {
props: {
partner: partnerPuzzle.id,
url: partnerPuzzle.url,
},
});
}
}, [gameState?.isSolved, gameState?.isFailed, dailyPuzzle?.id, genre, isSpecial, requiredDailyKeys]);
useEffect(() => {
setLastAction(null);
}, [dailyPuzzle?.id]);
@@ -284,8 +325,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
// Use current domain from window.location to support both hoerdle.de and hördle.de
const currentHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
// Use current domain from window.location to support both hoerdle.de and hördle.de,
// but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant.
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
let shareUrl = `${protocol}//${currentHost}`;
// Add locale prefix if not default (en)
@@ -488,6 +531,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
onSkip={handleYearSkip}
/>
)}
{showExtraPuzzlesPopover && extraPuzzle && (
<ExtraPuzzlesPopover
puzzle={extraPuzzle}
onClose={() => setShowExtraPuzzlesPopover(false)}
/>
)}
</div>
);
}
@@ -687,7 +737,11 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
}
return (
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
<div
className="star-rating"
title={t('ratingTooltip')}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
>
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
{[...Array(5)].map((_, index) => {

293
docs/SCORING_OPTIONS.md Normal file
View File

@@ -0,0 +1,293 @@
# Scoring-System Optionen
## Problem-Analyse
### Aktuelle Situation
- **Start:** 90 Punkte
- **Richtige Antwort:** +20 Punkte
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
- **Skip:** -5 Punkte
- **Replay:** -1 Punkt
### Problem (vor der Änderung)
Bei vielen Versuchen kam man mit einem relativ hohen Score heraus:
- Beispiel (alt): 7 Versuche = 90 + 20 - (6 × 3) = **92 Punkte**
### Lösung (aktuell implementiert)
Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die Track-Verlängerung (unlockSteps) abgezogen:
- Beispiel (neu): 7 Versuche = 90 + 20 - (6 × 8) = **62 Punkte**
- Start: 90 Punkte
- 6 falsche Versuche: -48 Punkte (6 × -8, bestehend aus -3 für falsch + -5 für Verlängerung)
- 1 richtiger Versuch: +20 Punkte
- **Ergebnis: 62 Punkte**
Dies spiegelt nun besser die tatsächliche Leistung wider. Das System bleibt motivierend, da richtige Antworten weiterhin belohnt werden.
---
## Option 1: Progressive Abzüge ⚠️ (Intransparent)
### Konzept
Abzüge steigen mit jedem Versuch, aber das System ist schwer nachvollziehbar.
```
- Versuch 1-2: -2 Punkte pro falscher Antwort
- Versuch 3-4: -4 Punkte pro falscher Antwort
- Versuch 5-6: -6 Punkte pro falscher Antwort
- Versuch 7: -8 Punkte
```
### Beispiel
Bei 7 Versuchen: 90 + 20 - (2+2+4+4+6+6) = **86 Punkte**
### Probleme
- **Intransparent**: Spieler müssen sich merken, welche Abzüge in welcher Runde gelten
- **Schwer erklärbar**: Das Regelwerk ist komplex
- **Unklar im UI**: Aktuelle Abzüge sind nicht sofort ersichtlich
### Vorteile
- Progressive Bestrafung für viele Versuche
- Fairer als aktuelles System
---
## Option 2: Bonus-Malus-System
### Konzept
Höhere Belohnungen für frühe Erfolge + progressive Abzüge.
```
Start: 90 Punkte
Richtige Antwort (Bonus abhängig vom Versuch):
- Versuch 1: +30 Punkte (sehr gut!)
- Versuch 2: +25 Punkte (gut!)
- Versuch 3: +20 Punkte (okay)
- Versuch 4: +15 Punkte
- Versuch 5+: +10 Punkte
Falsche Antwort (progressive Abzüge):
- Versuch 1-2: -3 Punkte
- Versuch 3-4: -5 Punkte
- Versuch 5-6: -8 Punkte
- Versuch 7: -10 Punkte
```
### Beispiele
- Gelöst in Versuch 1: 90 + 30 = **120 Punkte**
- Gelöst in Versuch 4 (nach 3 Fehlern): 90 + 15 - (3+5+5) = **92 Punkte**
- Gelöst in Versuch 7 (nach 6 Fehlern): 90 + 10 - (3+5+5+8+8+10) = **61 Punkte**
### Vorteile
- **Transparent**: Klare Regeln pro Versuch
- **Motivierend**: Hohe Belohnungen für schnelles Lösen
- **Fair**: Späte Erfolge werden abgewertet
### Nachteile
- Etwas komplexer als aktuelles System
- Muss im UI klar kommuniziert werden
---
## Option 3: Effizienz-Multiplikator
### Konzept
Basis-System bleibt, aber Multiplikator basierend auf Versuchszahl.
```
Basis-System (wie aktuell, aber mit höheren Abzügen):
- Falsche Antwort: -5 Punkte (statt -3)
- Skip: -7 Punkte (statt -5)
Bonus-Multiplikatoren (basierend auf Versuch, in dem gelöst wurde):
- Gelöst in 1-2 Versuchen: ×1.2 (20% Bonus)
- Gelöst in 3-4 Versuchen: ×1.1 (10% Bonus)
- Gelöst in 5-6 Versuchen: ×1.0 (kein Bonus)
- Gelöst in 7 Versuchen: ×0.9 (10% Abzug)
```
### Beispiele
- Gelöst in Versuch 2 (1 Fehler): (90 + 20 - 5) × 1.2 = **126 Punkte**
- Gelöst in Versuch 4 (3 Fehler): (90 + 20 - 15) × 1.1 = **104.5 → 105 Punkte**
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
### Vorteile
- Multiplikator ist einfach zu verstehen ("20% Bonus für schnelles Lösen")
- Basis-System bleibt ähnlich
- Gerechte Bestrafung für viele Versuche
### Nachteile
- Multiplikatoren müssen berechnet werden (könnte kompliziert wirken)
- Kombination aus Basis + Multiplikator kann verwirrend sein
---
## Option 4: Kombiniertes System
### Konzept
Höhere Abzüge + kleine Motivations-Boni.
```
Basis-System (höhere Abzüge):
- Falsche Antwort: -5 Punkte (statt -3)
- Skip: -7 Punkte (statt -5)
- Richtige Antwort: +20 Punkte (bleibt)
Motivations-Boni:
- "Erstversuch" Bonus: +2 Punkte wenn erster Versuch nicht skipped wurde
- "Perfekter Durchlauf": +5 Bonus wenn kein Skip verwendet wurde
- "Knapp daneben": +1 Punkt für Versuche, die fast richtig waren (optional, komplex)
```
### Beispiele
- Gelöst in Versuch 1: 90 + 20 + 2 + 5 = **117 Punkte**
- Gelöst in Versuch 4 (3 Fehler, kein Skip): 90 + 20 - 15 + 5 = **100 Punkte**
- Gelöst in Versuch 7 (6 Fehler, 2 Skips): 90 + 20 - 30 - 14 = **66 Punkte**
### Vorteile
- **Einfach verständlich**: Basis + kleine Boni
- **Motivierend**: Positive Verstärkung für gutes Verhalten
- **Fair**: Höhere Abzüge sorgen für differenzierten Score
### Nachteile
- Mehrere kleine Boni können unübersichtlich werden
- "Knapp daneben" ist schwer zu implementieren
---
## Option 5: Streak-System (Langfristige Motivation)
### Konzept
Zusätzliche Belohnungen für konsequentes Spielen über mehrere Tage.
```
Tägliche Streaks:
- 3 Tage in Folge gelöst: +5 Bonus-Punkte
- 7 Tage: +10 Bonus-Punkte
- 30 Tage: +15 Bonus-Punkte
```
**Kombiniert mit einem der anderen Systeme** (z.B. Option 2 oder 4).
### Vorteile
- Langfristige Spielermotivation
- Belohnt Engagement
### Nachteile
- Braucht Tracking über mehrere Tage
- Löst nicht das Hauptproblem (zu hoher Score bei vielen Versuchen)
---
## Option 6: Multiplikator-System (Vereinfacht)
### Konzept
Höhere Abzüge + einfache Multiplikatoren für Versuchszahl.
```
Höhere Basis-Abzüge:
- Falsche Antwort: -5 Punkte
- Skip: -7 Punkte
Multiplikator basierend auf Versuch, in dem gelöst wurde:
- Versuch 1: ×1.5 (50% Bonus) → Sehr schnelles Lösen
- Versuch 2: ×1.3 (30% Bonus)
- Versuch 3: ×1.1 (10% Bonus)
- Versuch 4: ×1.0 (kein Bonus/Aufschlag)
- Versuch 5+: ×0.9 (10% Abzug)
```
### Beispiele
- Gelöst in Versuch 1: (90 + 20) × 1.5 = **165 Punkte** ⭐⭐⭐
- Gelöst in Versuch 3 (2 Fehler): (90 + 20 - 10) × 1.1 = **110 Punkte**
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
### Vorteile
- **Sehr transparent**: "50% Bonus für Erstversuch" ist einfach zu verstehen
- **Stark motivierend**: Hohe Belohnungen für schnelles Lösen
- **Fair**: Viele Versuche = niedriger Score
### Nachteile
- Multiplikatoren könnten als zu komplex empfunden werden
- Hohe Scores bei frühen Erfolgen (könnte als "zu leicht" empfunden werden)
---
## Empfehlungen
### Für Transparenz und Einfachheit: **Option 2 oder Option 4**
**Option 2 (Bonus-Malus)** ist am transparentesten:
- Klare Werte pro Versuch
- Einfach zu kommunizieren: "Erstversuch gibt +30, jeder weitere Versuch reduziert den Bonus"
- Fair und motivierend
**Option 4 (Kombiniert)** ist am einfachsten:
- Basis-System bleibt ähnlich (nur höhere Abzüge)
- Zusätzliche kleine Boni sind optional und motivierend
- Sehr einfach zu verstehen
### Für maximale Motivation: **Option 6**
- Hohe Belohnungen für schnelles Lösen
- Einfache Multiplikatoren ("50% Bonus")
- Sehr fair für viele Versuche
---
## Implementierungs-Hinweise
### UI-Kommunikation
Welche Option auch gewählt wird - sie muss im Spiel klar kommuniziert werden:
- Tooltips bei Versuchen
- Score-Breakdown zeigt Abzüge/Boni pro Versuch
- Vorschau: "Dieser Versuch würde X Punkte kosten/geben"
### Testing
Vor der Implementierung sollten verschiedene Szenarien durchgespielt werden:
- Erstversuch-Lösung
- Mittlere Versuche (3-4)
- Knappe Lösung (6-7 Versuche)
- Mit/ohne Skips
- Mit/ohne Replays
### Migration
- Bestehende Scores können nicht einfach migriert werden
- Neue Regeln gelten ab Start des neuen Systems
- Eventuell: "New Scoring System" Ankündigung
---
## ✅ Implementiert: Abzüge für zusätzliche Sekunden
**Status:****Aktuell implementiert**
Bei falschen Rateversuchen werden zusätzlich **-5 Punkte für die Track-Verlängerung** abgezogen:
- Falsche Antwort (Rateversuch): -3 Punkte (falsch) + -5 Punkte (Verlängerung) = **-8 Punkte total**
- Skip: -5 Punkte (kein zusätzlicher Abzug, da Skip keine Verlängerung bedeutet)
**Vorteile:**
- ✅ Reflektiert den "Hilfsmittel"-Charakter der zusätzlichen Sekunden
- ✅ Macht viele Versuche deutlich teurer
- ✅ Fairer Score bei vielen Versuchen
- ✅ Transparent: Klar getrennt als "Wrong guess" und "Track extension"
**Hinweis:** Dies ist die erste Anpassung des Scoring-Systems. Weitere Optionen (siehe oben) können in Zukunft ergänzt werden.
## Offene Fragen
1. Sollen Replays weiterhin -1 Punkt kosten?
2. Soll das Jahr-Bonus-System (+10) beibehalten werden?
3. Wie wichtig ist Backward-Compatibility mit bestehenden Scores?
4. Soll es eine "Preview"-Funktion geben ("Dieser Versuch kostet X Punkte")?
5. Sollen zusätzlich freigeschaltete Sekunden (Unlock-Steps) zusätzlich Punkte kosten?
---
## Status
📝 **Erstellt:** 2024-12-01
**Erste Änderung implementiert:** 2024-12-01 - Track-Verlängerung kostet jetzt -5 Punkte bei falschen Rateversuchen
🔄 **Status:** Teilweise umgesetzt
💡 **Nächste Schritte:** Weitere Optionen können bei Bedarf ergänzt werden (siehe Optionen oben)

47
lib/externalPuzzles.ts Normal file
View File

@@ -0,0 +1,47 @@
export type ExternalPuzzle = {
id: string;
nameDe: string;
nameEn: string;
url: string;
isActive?: boolean;
};
/**
* Zentrale Liste externer Rätselangebote.
*
* Erweiterung: Einfach neuen Eintrag in dieses Array hinzufügen.
*/
export const externalPuzzles: ExternalPuzzle[] = [
{
id: 'pastpuzzle',
nameDe: 'Past Puzzle',
nameEn: 'Past Puzzle',
url: 'https://www.pastpuzzle.de/#/',
isActive: true,
},
{
id: 'woerdle',
nameDe: 'Wördle',
nameEn: 'Wördle',
url: 'https://www.wördle.de',
isActive: true,
},
{
id: 'ciddle',
nameDe: 'Ciddle',
nameEn: 'Ciddle',
url: 'https://ciddle.winklerweb.net',
isActive: true,
},
];
export function getRandomExternalPuzzle(): ExternalPuzzle | null {
const activePuzzles = externalPuzzles.filter(p => p.isActive !== false);
if (activePuzzles.length === 0) {
return null;
}
const index = Math.floor(Math.random() * activePuzzles.length);
return activePuzzles[index] ?? null;
}

View File

@@ -0,0 +1,68 @@
import { getTodayISOString } from './dateUtils';
const DAILY_PLAYED_PREFIX = 'hoerdle_daily_played_';
const EXTRA_POPOVER_PREFIX = 'hoerdle_extra_puzzles_shown_';
function getTodayKey(prefix: string): string | null {
if (typeof window === 'undefined') return null;
const today = getTodayISOString();
return `${prefix}${today}`;
}
export function markDailyPuzzlePlayedToday(genreKey: string) {
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
if (!storageKey) return;
try {
const raw = window.localStorage.getItem(storageKey);
const list: string[] = raw ? JSON.parse(raw) : [];
if (!list.includes(genreKey)) {
list.push(genreKey);
window.localStorage.setItem(storageKey, JSON.stringify(list));
}
} catch (e) {
console.warn('[extraPuzzles] Failed to mark daily puzzle as played', e);
}
}
export function hasPlayedAllDailyPuzzlesForToday(requiredGenreKeys: string[]): boolean {
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
if (!storageKey) return false;
try {
const raw = window.localStorage.getItem(storageKey);
const played: string[] = raw ? JSON.parse(raw) : [];
if (!Array.isArray(played) || played.length === 0) {
return false;
}
return requiredGenreKeys.every(key => played.includes(key));
} catch (e) {
console.warn('[extraPuzzles] Failed to read played puzzles', e);
return false;
}
}
export function hasSeenExtraPuzzlesPopoverToday(): boolean {
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
if (!storageKey) return false;
try {
return window.localStorage.getItem(storageKey) === 'true';
} catch (e) {
console.warn('[extraPuzzles] Failed to read popover state', e);
return false;
}
}
export function markExtraPuzzlesPopoverShownToday() {
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
if (!storageKey) return;
try {
window.localStorage.setItem(storageKey, 'true');
} catch (e) {
console.warn('[extraPuzzles] Failed to persist popover state', e);
}
}

View File

@@ -200,6 +200,9 @@ export function useGameState(
} else {
newScore -= 3;
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
// Additional penalty for track extension (unlock steps)
newScore -= 5;
newBreakdown.push({ value: -5, reason: 'Track extension' });
}
}

View File

@@ -4,14 +4,20 @@
* 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).
*
* Device-specific isolation:
* - Each device has its own device ID stored in localStorage
* - Player ID format: {basePlayerId}:{deviceId}
* - This allows cross-domain sync on the same device while keeping devices isolated
*/
const STORAGE_KEY = 'hoerdle_player_id';
const STORAGE_KEY_PLAYER = 'hoerdle_player_id';
const STORAGE_KEY_DEVICE = 'hoerdle_device_id';
/**
* Generate a UUID v4
*/
function generatePlayerId(): string {
function generateUUID(): 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;
@@ -21,68 +27,143 @@ function generatePlayerId(): string {
}
/**
* Try to find an existing player ID from the backend
* Get or create a device ID (unique per device)
*
* The device ID is stored in localStorage and persists across sessions.
* This allows device-specific isolation of game states.
*
* @returns Device identifier (UUID v4)
*/
export function getOrCreateDeviceId(): string {
if (typeof window === 'undefined') {
return '';
}
let deviceId = localStorage.getItem(STORAGE_KEY_DEVICE);
if (!deviceId) {
deviceId = generateUUID();
localStorage.setItem(STORAGE_KEY_DEVICE, deviceId);
}
return deviceId;
}
/**
* Get the device ID without creating a new one
*
* @returns Device identifier or null if not set
*/
export function getDeviceId(): string | null {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem(STORAGE_KEY_DEVICE);
}
/**
* Generate a base player ID (for cross-domain sync)
*/
function generateBasePlayerId(): string {
return generateUUID();
}
/**
* Try to find an existing base player ID from the backend
*
* Extracts the base player ID from a full player ID (format: {basePlayerId}:{deviceId})
*
* @param genreKey - Genre key to search for
* @returns Player ID if found, null otherwise
* @returns Base player ID if found, null otherwise
*/
async function findExistingPlayerId(genreKey: string): Promise<string | null> {
async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
try {
const deviceId = getOrCreateDeviceId();
const response = await fetch('/api/player-id/suggest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ genreKey }),
body: JSON.stringify({ genreKey, deviceId }),
});
if (response.ok) {
const data = await response.json();
if (data.playerId) {
return data.playerId;
if (data.basePlayerId) {
return data.basePlayerId;
}
}
} catch (error) {
console.warn('[playerId] Failed to find existing player ID:', error);
console.warn('[playerId] Failed to find existing base player ID:', error);
}
return null;
}
/**
* Combine base player ID and device ID into full player ID
* Format: {basePlayerId}:{deviceId}
*/
function combinePlayerId(basePlayerId: string, deviceId: string): string {
return `${basePlayerId}:${deviceId}`;
}
/**
* Extract base player ID from full player ID
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
*/
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 or create a player identifier
*
* If no identifier exists in localStorage, tries to find an existing one from the backend
* (based on recently updated states). If none found, generates a new UUID.
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
* Player ID format: {basePlayerId}:{deviceId}
*
* @param genreKey - Optional genre key to search for existing player ID
* @returns Player identifier (UUID v4)
* If no identifier exists in localStorage, tries to find an existing base player ID
* from the backend (for cross-domain sync). If none found, generates a new base ID.
* The device ID is always device-specific.
*
* This enables:
* - Cross-domain synchronization on the same device (same base player ID)
* - Device isolation (different device IDs)
*
* @param genreKey - Optional genre key to search for existing base player ID
* @returns Full player identifier ({basePlayerId}:{deviceId})
*/
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
if (typeof window === 'undefined') {
// Server-side: return empty string (not used on server)
return '';
}
let playerId = localStorage.getItem(STORAGE_KEY);
// Always get/create device ID (device-specific)
const deviceId = getOrCreateDeviceId();
if (!playerId) {
// Try to find an existing player ID from backend if genreKey is provided
// Try to get base player ID from localStorage
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
if (!basePlayerId) {
// Try to find an existing base player ID from backend if genreKey is provided
if (genreKey) {
const existingId = await findExistingPlayerId(genreKey);
if (existingId) {
playerId = existingId;
localStorage.setItem(STORAGE_KEY, playerId);
return playerId;
const existingBaseId = await findExistingBasePlayerId(genreKey);
if (existingBaseId) {
basePlayerId = existingBaseId;
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
}
}
// Generate new UUID if no existing ID found
playerId = generatePlayerId();
localStorage.setItem(STORAGE_KEY, playerId);
// Generate new base player ID if no existing one found
if (!basePlayerId) {
basePlayerId = generateBasePlayerId();
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
}
}
return playerId;
// Combine base player ID with device ID
return combinePlayerId(basePlayerId, deviceId);
}
/**
@@ -90,31 +171,53 @@ export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<strin
*
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
*
* @returns Player identifier (UUID v4)
* @returns Full player identifier ({basePlayerId}:{deviceId})
*/
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);
const deviceId = getOrCreateDeviceId();
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
if (!basePlayerId) {
basePlayerId = generateBasePlayerId();
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
}
return playerId;
return combinePlayerId(basePlayerId, deviceId);
}
/**
* Get the current player identifier without creating a new one
*
* @returns Player identifier or null if not set
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
*/
export function getPlayerId(): string | null {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem(STORAGE_KEY);
const deviceId = getDeviceId();
const basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
if (!deviceId || !basePlayerId) {
return null;
}
return combinePlayerId(basePlayerId, deviceId);
}
/**
* Get base player ID (for debugging/logging)
*
* @returns Base player ID or null if not set
*/
export function getBasePlayerId(): string | null {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem(STORAGE_KEY_PLAYER);
}

View File

@@ -101,7 +101,9 @@ export async function savePlayerState(
statistics: Statistics
): Promise<void> {
try {
const playerId = getOrCreatePlayerId();
// Use async version to ensure device ID is included
const { getOrCreatePlayerIdAsync } = await import('./playerId');
const playerId = await getOrCreatePlayerIdAsync();
if (!playerId) {
console.warn('[playerStorage] No player ID available, cannot save state');
return;

View File

@@ -47,6 +47,7 @@
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
"thanksForRating": "Danke für die Bewertung!",
"rateThisPuzzle": "Bewerte dieses Rätsel:",
"ratingTooltip": "Hilf unseren Kuratoren, gute Rätsel zu machen!",
"shared": "✓ Geteilt!",
"copied": "✓ Kopiert!",
"shareFailed": "✗ Fehlgeschlagen",
@@ -63,6 +64,12 @@
"special": "Special",
"genre": "Genre"
},
"ExtraPuzzles": {
"title": "Noch nicht genug Rätsel?",
"message": "Hey, hast du Lust auf weitere Rätsel? Dann schau doch mal bei {name} vorbei!",
"cta": "Zu {name}",
"close": "Schließen"
},
"Statistics": {
"yourStatistics": "Deine Statistiken",
"totalPuzzles": "Gesamte Rätsel",
@@ -179,6 +186,10 @@
"supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
"supportCuratorTitle": "Als Kurator bewerben",
"supportCuratorText": "Du hast gute Kenntnisse in einem Genre und möchtest dich als Kurator bewerben? Wir freuen uns über deine Nachricht!",
"supportReportBugTitle": "Fehler melden",
"supportReportBugText": "Fehler in der App gefunden? Bitte melde sie per E-Mail an <email>admin@hoerdle.de</email>.",
"privacyTitle": "Datenschutz",
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",

View File

@@ -47,6 +47,7 @@
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
"thanksForRating": "Thanks for rating!",
"rateThisPuzzle": "Rate this puzzle:",
"ratingTooltip": "Help our curators create good puzzles!",
"shared": "✓ Shared!",
"copied": "✓ Copied!",
"shareFailed": "✗ Failed",
@@ -63,6 +64,12 @@
"special": "Special",
"genre": "Genre"
},
"ExtraPuzzles": {
"title": "Still in the mood for puzzles?",
"message": "Hey, would you like to try some more puzzles? Then take a look at {name}!",
"cta": "Go to {name}",
"close": "Close"
},
"Statistics": {
"yourStatistics": "Your Statistics",
"totalPuzzles": "Total puzzles",
@@ -179,6 +186,10 @@
"supportPaypalLink": "paypal.me/MBusche",
"supportSteadyTitle": "Steady",
"supportSteadyDescription": "Regular support via Steady",
"supportCuratorTitle": "Apply as Curator",
"supportCuratorText": "Do you have good knowledge in a genre and would like to apply as a curator? We'd be happy to hear from you!",
"supportReportBugTitle": "Report Bugs",
"supportReportBugText": "Found a bug in the app? Please report it via email to <email>admin@hoerdle.de</email>.",
"privacyTitle": "Privacy",
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
"privacyPlausibleTitle": "Self-hosted Plausible Analytics",

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.4.2",
"version": "0.1.4.6",
"private": true,
"scripts": {
"dev": "next dev",