diff --git a/lib/achievementSystem.js b/lib/achievementSystem.js new file mode 100644 index 0000000..a94fc86 --- /dev/null +++ b/lib/achievementSystem.js @@ -0,0 +1,712 @@ +/** + * NinjaCross Achievement System + * + * JavaScript-basierte Achievement-Logik für das NinjaCross Parkour System. + * Liest Achievement-Definitionen aus der Datenbank und prüft/vergibt Achievements. + * + * @author NinjaCross Team + * @version 1.0.0 + */ + +const { Pool } = require('pg'); +require('dotenv').config(); + +// Database connection +const pool = new Pool({ + host: process.env.DB_HOST, + port: process.env.DB_PORT, + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false +}); + +class AchievementSystem { + constructor() { + this.achievements = new Map(); + this.playerAchievements = new Map(); + } + + /** + * Lädt alle aktiven Achievements aus der Datenbank + */ + async loadAchievements() { + try { + const result = await pool.query(` + SELECT id, name, description, category, condition_type, condition_value, icon, points + FROM achievements + WHERE is_active = true + ORDER BY category, condition_type + `); + + this.achievements.clear(); + result.rows.forEach(achievement => { + this.achievements.set(achievement.id, achievement); + }); + + console.log(`📋 ${this.achievements.size} Achievements geladen`); + return true; + } catch (error) { + console.error('❌ Fehler beim Laden der Achievements:', error); + return false; + } + } + + /** + * Lädt alle Spieler-Achievements für einen Spieler + * Jetzt werden alle Completions geladen (nicht nur die neueste) + */ + async loadPlayerAchievements(playerId) { + try { + const result = await pool.query(` + SELECT pa.achievement_id, pa.progress, pa.is_completed, pa.earned_at + FROM player_achievements pa + WHERE pa.player_id = $1 + ORDER BY pa.earned_at DESC + `, [playerId]); + + // Gruppiere nach achievement_id und zähle Completions + this.playerAchievements.set(playerId, new Map()); + const achievementCounts = new Map(); + + result.rows.forEach(pa => { + if (!achievementCounts.has(pa.achievement_id)) { + achievementCounts.set(pa.achievement_id, { + count: 0, + latest: pa + }); + } + achievementCounts.get(pa.achievement_id).count++; + }); + + // Speichere die neueste Completion und die Anzahl + achievementCounts.forEach((data, achievementId) => { + this.playerAchievements.get(playerId).set(achievementId, { + ...data.latest, + completion_count: data.count + }); + }); + + return true; + } catch (error) { + console.error(`❌ Fehler beim Laden der Spieler-Achievements für ${playerId}:`, error); + return false; + } + } + + /** + * Prüft alle Achievements für einen Spieler + */ + async checkAllAchievements(playerId) { + console.log(`🎯 Prüfe Achievements für Spieler ${playerId}...`); + + // Lade Spieler-Achievements + await this.loadPlayerAchievements(playerId); + + const newAchievements = []; + + // Prüfe alle Achievement-Kategorien + await this.checkConsistencyAchievements(playerId, newAchievements); + await this.checkImprovementAchievements(playerId, newAchievements); + await this.checkSeasonalAchievements(playerId, newAchievements); + await this.checkBestTimeAchievements(playerId, newAchievements); + + console.log(`✅ ${newAchievements.length} neue Achievements für Spieler ${playerId}`); + return newAchievements; + } + + /** + * Prüft sofortige Achievements für einen Spieler (bei neuer Zeit) + * Diese Achievements werden sofort vergeben, nicht nur um 19:00 Uhr + */ + async checkImmediateAchievements(playerId) { + console.log(`⚡ Prüfe sofortige Achievements für Spieler ${playerId}...`); + + // Lade Spieler-Achievements + await this.loadPlayerAchievements(playerId); + + const newAchievements = []; + + // Prüfe nur sofortige Achievement-Kategorien + await this.checkImmediateConsistencyAchievements(playerId, newAchievements); + await this.checkImprovementAchievements(playerId, newAchievements); + await this.checkSeasonalAchievements(playerId, newAchievements); + // Best-Time Achievements werden NICHT sofort geprüft (nur um 19:00 Uhr) + + if (newAchievements.length > 0) { + console.log(`🏆 ${newAchievements.length} sofortige Achievements für Spieler ${playerId}:`); + newAchievements.forEach(achievement => { + console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`); + }); + } + + return newAchievements; + } + + /** + * Prüft Konsistenz-basierte Achievements + */ + async checkConsistencyAchievements(playerId, newAchievements) { + // Erste Schritte + await this.checkFirstTimeAchievement(playerId, newAchievements); + + // Versuche pro Tag + await this.checkAttemptsPerDayAchievements(playerId, newAchievements); + + // Einzigartige Tage + await this.checkUniqueDaysAchievements(playerId, newAchievements); + } + + /** + * Prüft nur sofortige Konsistenz-basierte Achievements + */ + async checkImmediateConsistencyAchievements(playerId, newAchievements) { + // Erste Schritte + await this.checkFirstTimeAchievement(playerId, newAchievements); + + // Versuche pro Tag + await this.checkAttemptsPerDayAchievements(playerId, newAchievements); + + // Einzigartige Tage werden NICHT sofort geprüft (nur um 19:00 Uhr) + } + + /** + * Prüft "Erste Schritte" Achievement + */ + async checkFirstTimeAchievement(playerId, newAchievements) { + const achievement = Array.from(this.achievements.values()) + .find(a => a.category === 'consistency' && a.condition_type === 'first_time'); + + if (!achievement) return; + + // Prüfe ob bereits erreicht + if (this.isAchievementCompleted(playerId, achievement.id)) return; + + // Zähle Gesamtversuche + const result = await pool.query(` + SELECT COUNT(*) as count + FROM times t + WHERE t.player_id = $1 + `, [playerId]); + + const totalAttempts = parseInt(result.rows[0].count); + + if (totalAttempts >= achievement.condition_value) { + await this.awardAchievement(playerId, achievement, totalAttempts, newAchievements); + } + } + + /** + * Prüft "Versuche pro Tag" Achievements + */ + async checkAttemptsPerDayAchievements(playerId, newAchievements) { + const achievements = Array.from(this.achievements.values()) + .filter(a => a.category === 'consistency' && a.condition_type === 'attempts_per_day'); + + // Zähle Versuche heute + const result = await pool.query(` + SELECT COUNT(*) as count + FROM times t + WHERE t.player_id = $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE + `, [playerId]); + + const attemptsToday = parseInt(result.rows[0].count); + + for (const achievement of achievements) { + if (this.isAchievementCompleted(playerId, achievement.id)) continue; + + if (attemptsToday >= achievement.condition_value) { + await this.awardAchievement(playerId, achievement, attemptsToday, newAchievements); + } + } + } + + /** + * Prüft "Einzigartige Tage" Achievements + */ + async checkUniqueDaysAchievements(playerId, newAchievements) { + const achievements = Array.from(this.achievements.values()) + .filter(a => a.category === 'consistency' && a.condition_type === 'unique_days'); + + // Zähle einzigartige Tage + const result = await pool.query(` + SELECT COUNT(DISTINCT DATE(t.created_at AT TIME ZONE 'Europe/Berlin')) as count + FROM times t + WHERE t.player_id = $1 + `, [playerId]); + + const uniqueDays = parseInt(result.rows[0].count); + + for (const achievement of achievements) { + if (this.isAchievementCompleted(playerId, achievement.id)) continue; + + if (uniqueDays >= achievement.condition_value) { + await this.awardAchievement(playerId, achievement, uniqueDays, newAchievements); + } + } + } + + /** + * Prüft Verbesserungs-basierte Achievements + */ + async checkImprovementAchievements(playerId, newAchievements) { + const achievements = Array.from(this.achievements.values()) + .filter(a => a.category === 'improvement' && a.condition_type === 'time_improvement'); + + // Hole beste und zweitbeste Zeit + const result = await pool.query(` + SELECT + MIN(recorded_time) as best_time, + (SELECT MIN(recorded_time) + FROM times t2 + WHERE t2.player_id = $1 + AND t2.recorded_time > (SELECT MIN(recorded_time) FROM times t3 WHERE t3.player_id = $1) + ) as second_best_time + FROM times t + WHERE t.player_id = $1 + `, [playerId]); + + const { best_time, second_best_time } = result.rows[0]; + + if (!best_time || !second_best_time) return; + + // Berechne Verbesserung in Sekunden + const improvementSeconds = Math.floor((new Date(second_best_time) - new Date(best_time)) / 1000); + + for (const achievement of achievements) { + if (this.isAchievementCompleted(playerId, achievement.id)) continue; + + if (improvementSeconds >= achievement.condition_value) { + await this.awardAchievement(playerId, achievement, improvementSeconds, newAchievements); + } + } + } + + /** + * Prüft Saisonale Achievements + */ + async checkSeasonalAchievements(playerId, newAchievements) { + const now = new Date(); + const currentHour = now.getHours(); + const currentMonth = now.getMonth() + 1; // JavaScript months are 0-based + const currentDayOfWeek = now.getDay(); // 0 = Sunday + + // Zeit-basierte Achievements + await this.checkTimeBasedAchievements(playerId, currentHour, currentDayOfWeek, newAchievements); + + // Monatliche Achievements + await this.checkMonthlyAchievements(playerId, currentMonth, newAchievements); + + // Jahreszeiten-Achievements + await this.checkSeasonalTimeAchievements(playerId, currentMonth, newAchievements); + } + + /** + * Prüft zeit-basierte Achievements (Wochenende, Morgen, etc.) + */ + async checkTimeBasedAchievements(playerId, currentHour, currentDayOfWeek, newAchievements) { + const timeAchievements = [ + { condition: 'weekend_play', check: () => currentDayOfWeek === 0 || currentDayOfWeek === 6 }, + { condition: 'morning_play', check: () => currentHour < 10 }, + { condition: 'afternoon_play', check: () => currentHour >= 14 && currentHour < 18 }, + { condition: 'evening_play', check: () => currentHour >= 18 } + ]; + + for (const timeAchievement of timeAchievements) { + if (!timeAchievement.check()) continue; + + const achievements = Array.from(this.achievements.values()) + .filter(a => a.category === 'seasonal' && a.condition_type === timeAchievement.condition); + + for (const achievement of achievements) { + if (this.isAchievementCompleted(playerId, achievement.id)) continue; + + // Prüfe ob Spieler zu dieser Zeit gespielt hat + const hasPlayed = await this.checkTimeBasedPlay(playerId, timeAchievement.condition); + + if (hasPlayed) { + await this.awardAchievement(playerId, achievement, 1, newAchievements); + } + } + } + } + + /** + * Prüft ob Spieler zu einer bestimmten Zeit gespielt hat + */ + async checkTimeBasedPlay(playerId, conditionType) { + let query = ''; + + switch (conditionType) { + case 'weekend_play': + query = ` + SELECT EXISTS( + SELECT 1 FROM times t + WHERE t.player_id = $1 + AND EXTRACT(DOW FROM t.created_at AT TIME ZONE 'Europe/Berlin') IN (0, 6) + ) as has_played + `; + break; + case 'morning_play': + query = ` + SELECT EXISTS( + SELECT 1 FROM times t + WHERE t.player_id = $1 + AND EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Europe/Berlin') < 10 + ) as has_played + `; + break; + case 'afternoon_play': + query = ` + SELECT EXISTS( + SELECT 1 FROM times t + WHERE t.player_id = $1 + AND EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Europe/Berlin') BETWEEN 14 AND 17 + ) as has_played + `; + break; + case 'evening_play': + query = ` + SELECT EXISTS( + SELECT 1 FROM times t + WHERE t.player_id = $1 + AND EXTRACT(HOUR FROM t.created_at AT TIME ZONE 'Europe/Berlin') >= 18 + ) as has_played + `; + break; + } + + if (!query) return false; + + const result = await pool.query(query, [playerId]); + return result.rows[0].has_played; + } + + /** + * Prüft monatliche Achievements + */ + async checkMonthlyAchievements(playerId, currentMonth, newAchievements) { + const monthNames = [ + 'january', 'february', 'march', 'april', 'may', 'june', + 'july', 'august', 'september', 'october', 'november', 'december' + ]; + + const currentMonthName = monthNames[currentMonth - 1]; + + const achievements = Array.from(this.achievements.values()) + .filter(a => a.category === 'monthly' && a.condition_type === currentMonthName); + + for (const achievement of achievements) { + if (this.isAchievementCompleted(playerId, achievement.id)) continue; + + // Prüfe ob Spieler in diesem Monat gespielt hat + const result = await pool.query(` + SELECT EXISTS( + SELECT 1 FROM times t + WHERE t.player_id = $1 + AND EXTRACT(MONTH FROM t.created_at AT TIME ZONE 'Europe/Berlin') = $2 + ) as has_played + `, [playerId, currentMonth]); + + if (result.rows[0].has_played) { + await this.awardAchievement(playerId, achievement, 1, newAchievements); + } + } + } + + /** + * Prüft Jahreszeiten-Achievements + */ + async checkSeasonalTimeAchievements(playerId, currentMonth, newAchievements) { + const season = this.getSeason(currentMonth); + + const achievements = Array.from(this.achievements.values()) + .filter(a => a.category === 'seasonal' && a.condition_type === season); + + for (const achievement of achievements) { + if (this.isAchievementCompleted(playerId, achievement.id)) continue; + + // Prüfe ob Spieler in dieser Jahreszeit gespielt hat + const monthRanges = { + spring: [3, 4, 5], + summer: [6, 7, 8], + autumn: [9, 10, 11], + winter: [12, 1, 2] + }; + + const months = monthRanges[season]; + const result = await pool.query(` + SELECT EXISTS( + SELECT 1 FROM times t + WHERE t.player_id = $1 + AND EXTRACT(MONTH FROM t.created_at AT TIME ZONE 'Europe/Berlin') = ANY($2) + ) as has_played + `, [playerId, months]); + + if (result.rows[0].has_played) { + await this.awardAchievement(playerId, achievement, 1, newAchievements); + } + } + } + + /** + * Prüft Best-Time Achievements (nur um 19:00 Uhr) + */ + async checkBestTimeAchievements(playerId, newAchievements) { + const now = new Date(); + const currentHour = now.getHours(); + const currentDayOfWeek = now.getDay(); + const currentDate = now.toISOString().split('T')[0]; + const isLastDayOfMonth = this.isLastDayOfMonth(now); + + // Nur um 19:00 Uhr prüfen + if (currentHour !== 19) return; + + // Tageskönig (jeden Tag um 19:00) + await this.checkDailyBest(playerId, currentDate, newAchievements); + + // Wochenchampion (nur Sonntag um 19:00) + if (currentDayOfWeek === 0) { + await this.checkWeeklyBest(playerId, currentDate, newAchievements); + } + + // Monatsmeister (nur am letzten Tag des Monats um 19:00) + if (isLastDayOfMonth) { + await this.checkMonthlyBest(playerId, currentDate, newAchievements); + } + } + + /** + * Prüft Tageskönig Achievement + */ + async checkDailyBest(playerId, currentDate, newAchievements) { + const achievement = Array.from(this.achievements.values()) + .find(a => a.category === 'best_time' && a.condition_type === 'daily_best'); + + if (!achievement || this.isAchievementCompleted(playerId, achievement.id)) return; + + // Hole beste Zeit des Spielers heute + const playerResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE t.player_id = $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $2 + `, [playerId, currentDate]); + + // Hole beste Zeit des Tages + const dailyResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $1 + `, [currentDate]); + + const playerBest = playerResult.rows[0].best_time; + const dailyBest = dailyResult.rows[0].best_time; + + if (playerBest && dailyBest && playerBest === dailyBest) { + await this.awardAchievement(playerId, achievement, 1, newAchievements); + } + } + + /** + * Prüft Wochenchampion Achievement + */ + async checkWeeklyBest(playerId, currentDate, newAchievements) { + const achievement = Array.from(this.achievements.values()) + .find(a => a.category === 'best_time' && a.condition_type === 'weekly_best'); + + if (!achievement || this.isAchievementCompleted(playerId, achievement.id)) return; + + // Berechne Wochenstart (Montag) + const currentDateObj = new Date(currentDate); + const dayOfWeek = currentDateObj.getDay(); + const weekStart = new Date(currentDateObj); + weekStart.setDate(currentDateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)); + const weekStartStr = weekStart.toISOString().split('T')[0]; + + // Hole beste Zeit des Spielers diese Woche + const playerResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE t.player_id = $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3 + `, [playerId, weekStartStr, currentDate]); + + // Hole beste Zeit der Woche + const weeklyResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 + `, [weekStartStr, currentDate]); + + const playerBest = playerResult.rows[0].best_time; + const weeklyBest = weeklyResult.rows[0].best_time; + + if (playerBest && weeklyBest && playerBest === weeklyBest) { + await this.awardAchievement(playerId, achievement, 1, newAchievements); + } + } + + /** + * Prüft Monatsmeister Achievement + */ + async checkMonthlyBest(playerId, currentDate, newAchievements) { + const achievement = Array.from(this.achievements.values()) + .find(a => a.category === 'best_time' && a.condition_type === 'monthly_best'); + + if (!achievement || this.isAchievementCompleted(playerId, achievement.id)) return; + + // Berechne Monatsstart + const currentDateObj = new Date(currentDate); + const monthStart = new Date(currentDateObj.getFullYear(), currentDateObj.getMonth(), 1); + const monthStartStr = monthStart.toISOString().split('T')[0]; + + // Hole beste Zeit des Spielers diesen Monat + const playerResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE t.player_id = $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $2 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $3 + `, [playerId, monthStartStr, currentDate]); + + // Hole beste Zeit des Monats + const monthlyResult = await pool.query(` + SELECT MIN(recorded_time) as best_time + FROM times t + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 + `, [monthStartStr, currentDate]); + + const playerBest = playerResult.rows[0].best_time; + const monthlyBest = monthlyResult.rows[0].best_time; + + if (playerBest && monthlyBest && playerBest === monthlyBest) { + await this.awardAchievement(playerId, achievement, 1, newAchievements); + } + } + + /** + * Vergibt ein Achievement an einen Spieler + * Erstellt immer einen neuen Eintrag (keine Updates mehr) + */ + async awardAchievement(playerId, achievement, progress, newAchievements) { + try { + await pool.query(` + INSERT INTO player_achievements (player_id, achievement_id, progress, is_completed, earned_at) + VALUES ($1, $2, $3, true, NOW()) + `, [playerId, achievement.id, progress]); + + newAchievements.push({ + id: achievement.id, + name: achievement.name, + description: achievement.description, + icon: achievement.icon, + points: achievement.points, + progress: progress + }); + + console.log(`🏆 Achievement vergeben: ${achievement.icon} ${achievement.name} (+${achievement.points} Punkte)`); + } catch (error) { + console.error(`❌ Fehler beim Vergeben des Achievements ${achievement.name}:`, error); + } + } + + /** + * Prüft ob ein Achievement bereits erreicht wurde + * Jetzt können Achievements mehrmals erreicht werden, daher immer false + */ + isAchievementCompleted(playerId, achievementId) { + // Achievements können jetzt mehrmals erreicht werden + // Daher prüfen wir nicht mehr, ob sie bereits erreicht wurden + return false; + } + + /** + * Hilfsfunktionen + */ + getSeason(month) { + if (month >= 3 && month <= 5) return 'spring'; + if (month >= 6 && month <= 8) return 'summer'; + if (month >= 9 && month <= 11) return 'autumn'; + return 'winter'; + } + + isLastDayOfMonth(date) { + const tomorrow = new Date(date); + tomorrow.setDate(date.getDate() + 1); + return tomorrow.getMonth() !== date.getMonth(); + } + + /** + * Berechnet die Gesamtpunkte eines Spielers (inklusive aller Completions) + */ + async getPlayerTotalPoints(playerId) { + try { + const result = await pool.query(` + SELECT + SUM(a.points) as total_points, + COUNT(pa.id) as total_completions + FROM player_achievements pa + INNER JOIN achievements a ON pa.achievement_id = a.id + WHERE pa.player_id = $1 AND pa.is_completed = true + `, [playerId]); + + return { + totalPoints: parseInt(result.rows[0].total_points) || 0, + totalCompletions: parseInt(result.rows[0].total_completions) || 0 + }; + } catch (error) { + console.error(`❌ Fehler beim Berechnen der Gesamtpunkte für ${playerId}:`, error); + return { totalPoints: 0, totalCompletions: 0 }; + } + } + + /** + * Führt tägliche Achievement-Prüfung für alle Spieler durch + */ + async runDailyAchievementCheck() { + console.log('🎯 Starte tägliche Achievement-Prüfung...'); + + // Lade Achievements + await this.loadAchievements(); + + // Hole alle Spieler, die heute gespielt haben + const playersResult = await pool.query(` + SELECT DISTINCT p.id, p.firstname, p.lastname + FROM players p + INNER JOIN times t ON p.id = t.player_id + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE + `); + + let totalNewAchievements = 0; + const allNewAchievements = []; + + for (const player of playersResult.rows) { + console.log(`🔍 Prüfe Achievements für ${player.firstname} ${player.lastname}...`); + + const newAchievements = await this.checkAllAchievements(player.id); + totalNewAchievements += newAchievements.length; + + if (newAchievements.length > 0) { + allNewAchievements.push({ + player: `${player.firstname} ${player.lastname}`, + achievements: newAchievements + }); + } + } + + console.log(`🎉 Tägliche Achievement-Prüfung abgeschlossen!`); + console.log(`📊 ${totalNewAchievements} neue Achievements vergeben`); + + return { + totalNewAchievements, + playerAchievements: allNewAchievements + }; + } +} + +module.exports = AchievementSystem; diff --git a/package.json b/package.json index ec0fcc4..334d6ae 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,11 @@ "start": "node server.js", "dev": "nodemon server.js", "init-db": "node scripts/init-db.js", - "create-user": "node scripts/create-user.js" + "create-user": "node scripts/create-user.js", + "test-achievements": "node scripts/test-achievements.js", + "test-immediate-achievements": "node scripts/test-immediate-achievements.js", + "simulate-new-time": "node scripts/simulate-new-time.js", + "test-multiple-achievements": "node scripts/test-multiple-achievements.js" }, "dependencies": { "@hisma/server-puppeteer": "^0.6.5", diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 446e290..463e539 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -1121,22 +1121,29 @@ function displayAchievements() { const isCompleted = achievement.is_completed; const progress = achievement.progress || 0; const earnedAt = achievement.earned_at; + const completionCount = achievement.completion_count || 0; // Translate achievement const translatedAchievement = translateAchievement(achievement); // Debug logging if (achievement.name === 'Tageskönig') { - console.log('Tageskönig Debug:', { isCompleted, progress, earnedAt }); + console.log('Tageskönig Debug:', { isCompleted, progress, earnedAt, completionCount }); } let progressText = ''; if (isCompleted) { const achievedText = currentLanguage === 'de' ? 'Erreicht am' : 'Achieved on'; const completedText = currentLanguage === 'de' ? 'Abgeschlossen' : 'Completed'; - progressText = earnedAt ? - `${achievedText} ${new Date(earnedAt).toLocaleDateString(currentLanguage === 'de' ? 'de-DE' : 'en-US')}` : - completedText; + const timesText = currentLanguage === 'de' ? 'x geschafft' : 'x completed'; + + if (completionCount > 1) { + progressText = `${completionCount}${timesText}`; + } else { + progressText = earnedAt ? + `${achievedText} ${new Date(earnedAt).toLocaleDateString(currentLanguage === 'de' ? 'de-DE' : 'en-US')}` : + completedText; + } } else if (progress > 0) { // Show progress for incomplete achievements const conditionValue = getAchievementConditionValue(achievement.name); @@ -1146,6 +1153,7 @@ function displayAchievements() { } const pointsText = currentLanguage === 'de' ? 'Punkte' : 'Points'; + const totalPoints = completionCount > 0 ? achievement.points * completionCount : achievement.points; return `
${translatedAchievement.description}