Achivement System

This commit is contained in:
2025-09-05 17:56:23 +02:00
parent a78a8dc3ce
commit 61d5ef2e6f
11 changed files with 2195 additions and 7 deletions

217
ACHIEVEMENTS.md Normal file
View File

@@ -0,0 +1,217 @@
# 🏆 Ninja Cross Parkour Achievement System
Ein umfassendes Achievement-System für das Ninja Cross Parkour im Schwimmbad.
## 📊 System-Übersicht
Das Achievement-System besteht aus:
- **32 verschiedene Achievements** in 4 Kategorien
- **Automatische tägliche Vergabe** am Ende des Tages
- **REST API Endpoints** für Frontend-Integration
- **PostgreSQL Funktionen** für effiziente Verarbeitung
## 🎯 Achievement-Kategorien
### 1. Konsistenz-basierte Achievements
- **Erste Schritte** 👶 - Erste Zeit aufgezeichnet (5 Punkte)
- **Durchhalter** 💪 - 3 Versuche an einem Tag (10 Punkte)
- **Fleißig** 🔥 - 5 Versuche an einem Tag (15 Punkte)
- **Besessen** 😤 - 10 Versuche an einem Tag (25 Punkte)
- **Regelmäßig** 📅 - 5 verschiedene Tage gespielt (20 Punkte)
- **Stammgast** ⭐ - 10 verschiedene Tage gespielt (30 Punkte)
- **Treue** 💎 - 20 verschiedene Tage gespielt (50 Punkte)
- **Veteran** 🏆 - 50 verschiedene Tage gespielt (100 Punkte)
### 2. Verbesserungs-basierte Achievements
- **Fortschritt** 📈 - Persönliche Bestzeit um 5 Sekunden verbessert (15 Punkte)
- **Durchbruch** ⚡ - Persönliche Bestzeit um 10 Sekunden verbessert (25 Punkte)
- **Transformation** 🔄 - Persönliche Bestzeit um 15 Sekunden verbessert (40 Punkte)
- **Perfektionist** ✨ - Persönliche Bestzeit um 20 Sekunden verbessert (60 Punkte)
### 3. Saisonale Achievements
- **Wochenend-Krieger** 🏁 - Am Wochenende gespielt (10 Punkte)
- **Nachmittags-Sportler** ☀️ - Zwischen 14-18 Uhr gespielt (10 Punkte)
- **Frühaufsteher** 🌅 - Vor 10 Uhr gespielt (15 Punkte)
- **Abend-Sportler** 🌙 - Nach 18 Uhr gespielt (10 Punkte)
### 4. Monatliche Achievements
- **Januar-Krieger** ❄️ bis **Dezember-Dynamo** 🎄 (je 20 Punkte)
### 5. Jahreszeiten-Achievements
- **Frühjahrs-Fighter** 🌱 - Im Frühling gespielt (30 Punkte)
- **Sommer-Sportler** ☀️ - Im Sommer gespielt (30 Punkte)
- **Herbst-Held** 🍂 - Im Herbst gespielt (30 Punkte)
- **Winter-Warrior** ❄️ - Im Winter gespielt (30 Punkte)
## 🗄️ Datenbank-Schema
### Tabelle: `achievements`
```sql
- id (uuid, PK)
- name (varchar) - Achievement-Name
- description (text) - Beschreibung
- category (varchar) - Kategorie
- condition_type (varchar) - Bedingungstyp
- condition_value (integer) - Bedingungswert
- icon (varchar) - Emoji-Icon
- points (integer) - Punkte
- is_active (boolean) - Aktiv
- created_at (timestamp)
```
### Tabelle: `player_achievements`
```sql
- id (uuid, PK)
- player_id (uuid, FK) - Verweis auf players.id
- achievement_id (uuid, FK) - Verweis auf achievements.id
- earned_at (timestamp) - Wann erreicht
- progress (integer) - Fortschritt
- is_completed (boolean) - Abgeschlossen
- created_at (timestamp)
```
## 🔧 PostgreSQL Funktionen
### `check_consistency_achievements(player_uuid)`
Überprüft alle Konsistenz-basierten Achievements für einen Spieler.
### `check_improvement_achievements(player_uuid)`
Überprüft alle Verbesserungs-basierten Achievements für einen Spieler.
### `check_seasonal_achievements(player_uuid)`
Überprüft alle saisonalen und monatlichen Achievements für einen Spieler.
### `check_all_achievements(player_uuid)`
Führt alle Achievement-Überprüfungen für einen Spieler aus.
## 🚀 API Endpoints
### GET `/api/achievements`
Alle verfügbaren Achievements abrufen.
### GET `/api/achievements/player/:playerId`
Achievements eines bestimmten Spielers abrufen.
### GET `/api/achievements/player/:playerId/stats`
Achievement-Statistiken eines Spielers abrufen.
### POST `/api/achievements/check/:playerId`
Achievements für einen Spieler manuell überprüfen.
### POST `/api/achievements/daily-check`
Tägliche Achievement-Überprüfung für alle Spieler ausführen.
### GET `/api/achievements/leaderboard?limit=10`
Bestenliste der Spieler nach Achievement-Punkten.
## 📅 Automatisierung
### Tägliches Script
```bash
# Manuell ausführen
node scripts/daily_achievements.js
# Cron-Job einrichten
node scripts/setup_cron.js setup
# Cron-Job Status prüfen
node scripts/setup_cron.js status
# Cron-Job entfernen
node scripts/setup_cron.js remove
```
### Cron-Schedule
- **Zeit**: Täglich um 23:59 Uhr
- **Log**: `/var/log/ninjaserver_achievements.log`
## 🎮 Frontend-Integration
### Beispiel: Achievement-Liste laden
```javascript
fetch('/api/achievements/player/PLAYER_ID')
.then(response => response.json())
.then(data => {
data.data.forEach(achievement => {
console.log(`${achievement.icon} ${achievement.name}: ${achievement.is_completed ? '✅' : '❌'}`);
});
});
```
### Beispiel: Statistiken anzeigen
```javascript
fetch('/api/achievements/player/PLAYER_ID/stats')
.then(response => response.json())
.then(data => {
console.log(`Punkte: ${data.data.total_points}`);
console.log(`Abgeschlossen: ${data.data.completed_achievements}/${data.data.total_achievements}`);
});
```
## 🔍 Monitoring
### Logs überwachen
```bash
# Live-Logs anzeigen
tail -f /var/log/ninjaserver_achievements.log
# Letzte Ausführung prüfen
grep "Daily achievement check completed" /var/log/ninjaserver_achievements.log | tail -1
```
### Datenbank-Status prüfen
```sql
-- Achievement-Statistiken
SELECT
COUNT(*) as total_achievements,
COUNT(CASE WHEN is_active = true THEN 1 END) as active_achievements
FROM achievements;
-- Spieler-Statistiken
SELECT
COUNT(DISTINCT player_id) as players_with_achievements,
COUNT(*) as total_earned_achievements
FROM player_achievements
WHERE is_completed = true;
```
## 🛠️ Wartung
### Neue Achievements hinzufügen
1. Achievement in `achievements` Tabelle einfügen
2. Logik in entsprechenden PostgreSQL Funktionen erweitern
3. API Endpoints testen
### Achievement deaktivieren
```sql
UPDATE achievements SET is_active = false WHERE name = 'Achievement-Name';
```
### Daten zurücksetzen
```sql
-- Alle Spieler-Achievements löschen
DELETE FROM player_achievements;
-- Achievement-Statistiken zurücksetzen
UPDATE achievements SET created_at = NOW();
```
## 📈 Performance
- **Indizierung**: Automatische Indizes auf `player_id` und `achievement_id`
- **Batch-Processing**: Effiziente Verarbeitung aller Spieler
- **Caching**: Achievements werden nur bei Änderungen neu berechnet
- **Timezone**: Korrekte Zeitzone-Behandlung (Europe/Berlin)
## 🔒 Sicherheit
- **API-Schutz**: Alle Endpoints über bestehende Authentifizierung
- **SQL-Injection**: Parametrisierte Queries
- **Datenvalidierung**: Eingabe-Validierung in allen Funktionen
- **Fehlerbehandlung**: Umfassende Error-Handling
---
**Erstellt am**: $(date)
**Version**: 1.0.0
**Autor**: Ninja Cross Parkour System

415
package-lock.json generated
View File

@@ -16,12 +16,58 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"pg": "^8.11.3", "pg": "^8.11.3",
"socket.io": "^4.8.1" "socket.io": "^4.8.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"
} }
}, },
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@apidevtools/openapi-schemas": "^2.0.4",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"z-schema": "^5.0.1"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -370,6 +416,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"license": "MIT"
},
"node_modules/@mapbox/node-pre-gyp": { "node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -472,6 +524,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@socket.io/component-emitter": { "node_modules/@socket.io/component-emitter": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -493,6 +552,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.3.0", "version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
@@ -876,6 +941,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"license": "MIT"
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -972,6 +1043,15 @@
"color-support": "bin.js" "color-support": "bin.js"
} }
}, },
"node_modules/commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1137,6 +1217,18 @@
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==", "integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"license": "Apache-2.0",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.5.0", "version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
@@ -2153,6 +2245,26 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "7.18.3", "version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
@@ -2496,6 +2608,13 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT",
"peer": true
},
"node_modules/pac-proxy-agent": { "node_modules/pac-proxy-agent": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
@@ -3603,6 +3722,83 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
"license": "MIT",
"dependencies": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"lodash.mergewith": "^4.6.2",
"swagger-parser": "^10.0.3",
"yaml": "2.0.0-1"
},
"bin": {
"swagger-jsdoc": "bin/swagger-jsdoc.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/swagger-jsdoc/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.28.1",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.28.1.tgz",
"integrity": "sha512-IvPrtNi8MvjiuDgoSmPYgg27Lvu38fnLD1OSd8Y103xXsPAqezVNnNeHnVCZ/d+CMXJblflGaIyHxAYIF3O71w==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"license": "MIT",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/tar": { "node_modules/tar": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
@@ -3768,6 +3964,15 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/validator": {
"version": "13.15.15",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -3883,6 +4088,15 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -3920,6 +4134,36 @@
"fd-slicer": "~1.1.0" "fd-slicer": "~1.1.0"
} }
}, },
"node_modules/z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"license": "MIT",
"dependencies": {
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"bin": {
"z-schema": "bin/z-schema"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"commander": "^9.4.1"
}
},
"node_modules/z-schema/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
@@ -3940,6 +4184,40 @@
} }
}, },
"dependencies": { "dependencies": {
"@apidevtools/json-schema-ref-parser": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
"requires": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ=="
},
"@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg=="
},
"@apidevtools/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
"requires": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@apidevtools/openapi-schemas": "^2.0.4",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"z-schema": "^5.0.1"
}
},
"@babel/code-frame": { "@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -4182,6 +4460,11 @@
} }
} }
}, },
"@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
},
"@mapbox/node-pre-gyp": { "@mapbox/node-pre-gyp": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -4258,6 +4541,11 @@
} }
} }
}, },
"@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="
},
"@socket.io/component-emitter": { "@socket.io/component-emitter": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -4276,6 +4564,11 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"@types/node": { "@types/node": {
"version": "24.3.0", "version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
@@ -4537,6 +4830,11 @@
"get-intrinsic": "^1.3.0" "get-intrinsic": "^1.3.0"
} }
}, },
"call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="
},
"callsites": { "callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -4600,6 +4898,11 @@
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="
}, },
"commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q=="
},
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4711,6 +5014,14 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz",
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==" "integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA=="
}, },
"doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"requires": {
"esutils": "^2.0.2"
}
},
"dotenv": { "dotenv": {
"version": "16.5.0", "version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
@@ -5399,6 +5710,21 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
}, },
"lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
},
"lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="
},
"lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="
},
"lru-cache": { "lru-cache": {
"version": "7.18.3", "version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
@@ -5618,6 +5944,12 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"peer": true
},
"pac-proxy-agent": { "pac-proxy-agent": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
@@ -6363,6 +6695,58 @@
"has-flag": "^3.0.0" "has-flag": "^3.0.0"
} }
}, },
"swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
"requires": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"lodash.mergewith": "^4.6.2",
"swagger-parser": "^10.0.3",
"yaml": "2.0.0-1"
},
"dependencies": {
"glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
"requires": {
"@apidevtools/swagger-parser": "10.0.3"
}
},
"swagger-ui-dist": {
"version": "5.28.1",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.28.1.tgz",
"integrity": "sha512-IvPrtNi8MvjiuDgoSmPYgg27Lvu38fnLD1OSd8Y103xXsPAqezVNnNeHnVCZ/d+CMXJblflGaIyHxAYIF3O71w==",
"requires": {
"@scarf/scarf": "=1.4.0"
}
},
"swagger-ui-express": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
"integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
"requires": {
"swagger-ui-dist": ">=5.0.0"
}
},
"tar": { "tar": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
@@ -6491,6 +6875,11 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
}, },
"validator": {
"version": "13.15.15",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
"integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A=="
},
"vary": { "vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -6562,6 +6951,11 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}, },
"yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ=="
},
"yargs": { "yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -6590,6 +6984,25 @@
"fd-slicer": "~1.1.0" "fd-slicer": "~1.1.0"
} }
}, },
"z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"requires": {
"commander": "^9.4.1",
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"dependencies": {
"commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"optional": true
}
}
},
"zod": { "zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@@ -17,7 +17,9 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"pg": "^8.11.3", "pg": "^8.11.3",
"socket.io": "^4.8.1" "socket.io": "^4.8.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"

View File

@@ -1026,3 +1026,351 @@ body {
gap: 1.5rem; gap: 1.5rem;
} }
} }
/* ==================== ACHIEVEMENT STYLES ==================== */
.achievements-section {
margin-bottom: 3rem;
}
.achievements-header {
text-align: center;
margin-bottom: 2rem;
}
.achievements-header h2 {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #ffd700, #ff6b35);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.achievements-header p {
color: #8892b0;
font-size: 1.1rem;
}
/* Achievement Stats */
.achievement-stats {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
justify-content: center;
}
.achievement-stat {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid #2a2a3e;
border-radius: 1rem;
padding: 1.5rem;
text-align: center;
min-width: 150px;
flex: 1;
max-width: 200px;
}
.achievement-stat .stat-number {
font-size: 2rem;
font-weight: 700;
color: #00d4ff;
margin-bottom: 0.5rem;
}
.achievement-stat .stat-label {
color: #8892b0;
font-size: 0.9rem;
font-weight: 500;
}
/* Achievement Categories */
.achievement-categories {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid #2a2a3e;
border-radius: 1rem;
padding: 2rem;
}
.category-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
justify-content: center;
}
.category-tab {
background: transparent;
border: 1px solid #2a2a3e;
color: #8892b0;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.3s ease;
font-family: inherit;
font-size: 0.9rem;
font-weight: 500;
}
.category-tab:hover {
background: #2a2a3e;
color: #ffffff;
}
.category-tab.active {
background: linear-gradient(135deg, #00d4ff, #ff6b35);
border-color: transparent;
color: #ffffff;
}
/* Achievements Grid */
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.achievement-card {
background: linear-gradient(135deg, #0f1419 0%, #1a1a2e 100%);
border: 1px solid #2a2a3e;
border-radius: 1rem;
padding: 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.achievement-card:hover {
transform: translateY(-2px);
border-color: #00d4ff;
box-shadow: 0 8px 25px rgba(0, 212, 255, 0.1);
}
.achievement-card.completed {
border-color: #10b981;
background: linear-gradient(135deg, #064e3b 0%, #1a1a2e 100%);
}
.achievement-card.completed:hover {
border-color: #10b981;
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.1);
}
.achievement-card.incomplete {
opacity: 0.7;
}
.achievement-card.incomplete:hover {
opacity: 1;
}
.achievement-icon {
font-size: 2.5rem;
margin-bottom: 1rem;
text-align: center;
}
.achievement-content {
flex: 1;
}
.achievement-name {
font-size: 1.2rem;
font-weight: 600;
color: #ffffff;
margin-bottom: 0.5rem;
}
.achievement-description {
color: #8892b0;
font-size: 0.9rem;
line-height: 1.4;
margin-bottom: 1rem;
}
.achievement-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.achievement-points {
color: #ffd700;
font-weight: 600;
font-size: 0.9rem;
}
.achievement-progress {
color: #00d4ff;
font-size: 0.8rem;
font-weight: 500;
}
.achievement-status {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 1.2rem;
}
/* Achievement Loading States */
.achievements-loading {
text-align: center;
padding: 3rem;
color: #8892b0;
}
.achievements-not-available {
text-align: center;
padding: 3rem;
}
.not-available-content {
max-width: 400px;
margin: 0 auto;
}
.not-available-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.not-available-content h3 {
color: #ffffff;
margin-bottom: 1rem;
font-size: 1.5rem;
}
.not-available-content p {
color: #8892b0;
margin-bottom: 2rem;
line-height: 1.5;
}
/* No Achievements State */
.no-achievements {
text-align: center;
padding: 3rem;
color: #8892b0;
grid-column: 1 / -1;
}
.no-achievements-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.no-achievements h3 {
color: #ffffff;
margin-bottom: 0.5rem;
font-size: 1.3rem;
}
/* Achievement Notifications */
.achievement-notification {
position: fixed;
top: 2rem;
right: 2rem;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid #10b981;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.2);
z-index: 1000;
max-width: 350px;
animation: slideInRight 0.3s ease;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.notification-content {
display: flex;
align-items: center;
gap: 1rem;
}
.notification-icon {
font-size: 2rem;
}
.notification-text h4 {
color: #ffffff;
margin-bottom: 0.25rem;
font-size: 1.1rem;
}
.notification-text p {
color: #8892b0;
font-size: 0.9rem;
margin: 0;
}
.notification-close {
background: none;
border: none;
color: #8892b0;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
margin-left: auto;
transition: color 0.3s ease;
}
.notification-close:hover {
color: #ffffff;
}
/* Mobile Responsiveness for Achievements */
@media (max-width: 768px) {
.achievements-header h2 {
font-size: 2rem;
}
.achievement-stats {
flex-direction: column;
align-items: center;
}
.achievement-stat {
width: 100%;
max-width: 250px;
}
.category-tabs {
flex-direction: column;
align-items: center;
}
.category-tab {
width: 100%;
max-width: 200px;
text-align: center;
}
.achievements-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.achievement-notification {
top: 1rem;
right: 1rem;
left: 1rem;
max-width: none;
}
}

View File

@@ -120,6 +120,67 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Achievements Section -->
<div class="achievements-section">
<div class="achievements-header">
<h2>🏆 Meine Achievements</h2>
<p>Sammele Punkte und erreiche neue Meilensteine!</p>
</div>
<!-- Achievement Stats -->
<div class="achievement-stats" id="achievementStats" style="display: none;">
<div class="stat-card achievement-stat">
<div class="stat-number" id="totalPoints">0</div>
<div class="stat-label">Gesamtpunkte</div>
</div>
<div class="stat-card achievement-stat">
<div class="stat-number" id="completedAchievements">0</div>
<div class="stat-label">Abgeschlossen</div>
</div>
<div class="stat-card achievement-stat">
<div class="stat-number" id="achievementsToday">0</div>
<div class="stat-label">Heute erreicht</div>
</div>
<div class="stat-card achievement-stat">
<div class="stat-number" id="completionPercentage">0%</div>
<div class="stat-label">Fortschritt</div>
</div>
</div>
<!-- Achievement Categories -->
<div class="achievement-categories" id="achievementCategories" style="display: none;">
<div class="category-tabs">
<button class="category-tab active" onclick="showAchievementCategory('all')" data-category="all">Alle</button>
<button class="category-tab" onclick="showAchievementCategory('consistency')" data-category="consistency">Konsistenz</button>
<button class="category-tab" onclick="showAchievementCategory('improvement')" data-category="improvement">Verbesserung</button>
<button class="category-tab" onclick="showAchievementCategory('seasonal')" data-category="seasonal">Saisonal</button>
<button class="category-tab" onclick="showAchievementCategory('monthly')" data-category="monthly">Monatlich</button>
</div>
<div class="achievements-grid" id="achievementsGrid">
<!-- Achievements will be populated here -->
</div>
</div>
<!-- Achievement Loading State -->
<div id="achievementsLoading" class="achievements-loading" style="display: none;">
<div class="spinner"></div>
<p>Lade deine Achievements...</p>
</div>
<!-- Achievement Not Available State -->
<div id="achievementsNotAvailable" class="achievements-not-available" style="display: none;">
<div class="not-available-content">
<div class="not-available-icon">🏆</div>
<h3>Achievements noch nicht verfügbar</h3>
<p>Um Achievements zu sammeln, musst du zuerst deine RFID-Karte mit deinem Account verknüpfen und einige Läufe absolvieren.</p>
<button class="btn btn-primary" onclick="showRFIDSettings()">
🏷️ RFID jetzt verknüpfen
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -17,7 +17,7 @@ async function initDashboard() {
if (error) { if (error) {
console.error('Error checking authentication:', error); console.error('Error checking authentication:', error);
// Temporarily show dashboard for testing // Temporarily show dashboard for testing
currentUser = { id: 'test-user', email: 'admin@speedrun-arena.com' }; currentUser = { id: '9966cffd-2088-423c-b852-0ca7996cda97', email: 'admin@speedrun-arena.com' };
displayUserInfo({ email: 'admin@speedrun-arena.com' }); displayUserInfo({ email: 'admin@speedrun-arena.com' });
showDashboard(); showDashboard();
// Check times section // Check times section
@@ -38,7 +38,7 @@ async function initDashboard() {
displayUserInfo(session.user); displayUserInfo(session.user);
} else { } else {
// Fallback if no user data // Fallback if no user data
currentUser = { id: 'test-user', email: 'admin@speedrun-arena.com' }; currentUser = { id: '9966cffd-2088-423c-b852-0ca7996cda97', email: 'admin@speedrun-arena.com' };
displayUserInfo({ email: 'admin@speedrun-arena.com' }); displayUserInfo({ email: 'admin@speedrun-arena.com' });
} }
showDashboard(); showDashboard();
@@ -132,7 +132,7 @@ async function checkLinkStatusAndLoadTimes() {
try { try {
// Check if user has a linked player // Check if user has a linked player
const response = await fetch(`/api/user-player/${currentUser.id}`); const response = await fetch(`/api/v1/public/user-player/${currentUser.id}`);
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
@@ -363,7 +363,13 @@ async function loadUserTimesSection(playerData) {
try { try {
const response = await fetch(`/api/v1/public/user-times/${currentUser.id}`); const response = await fetch(`/api/v1/public/user-times/${currentUser.id}`);
const times = await response.json(); const result = await response.json();
if (!response.ok) {
throw new Error(result.message || 'Failed to load times');
}
const times = result.data || result;
// Update stats // Update stats
updateTimesStats(times, playerData); updateTimesStats(times, playerData);
@@ -376,6 +382,9 @@ async function loadUserTimesSection(playerData) {
document.getElementById('timesNotLinked').style.display = 'none'; document.getElementById('timesNotLinked').style.display = 'none';
document.getElementById('timesDisplay').style.display = 'block'; document.getElementById('timesDisplay').style.display = 'block';
// Initialize achievements for this player
initializeAchievements(playerData.id);
} catch (error) { } catch (error) {
console.error('Error loading user times:', error); console.error('Error loading user times:', error);
showTimesNotLinked(); showTimesNotLinked();
@@ -577,6 +586,251 @@ function showMessage(containerId, message, type) {
} }
// Initialize when DOM is loaded // Initialize when DOM is loaded
// ==================== ACHIEVEMENT FUNCTIONS ====================
// Global variables for achievements
let currentPlayerId = null;
let allAchievements = [];
let playerAchievements = [];
let currentAchievementCategory = 'all';
// Load achievements for the current player
async function loadPlayerAchievements() {
if (!currentPlayerId) {
showAchievementsNotAvailable();
return;
}
try {
// Show loading state
document.getElementById('achievementsLoading').style.display = 'block';
document.getElementById('achievementStats').style.display = 'none';
document.getElementById('achievementCategories').style.display = 'none';
document.getElementById('achievementsNotAvailable').style.display = 'none';
// Load player achievements
const response = await fetch(`/api/achievements/player/${currentPlayerId}`);
if (!response.ok) {
throw new Error('Failed to load achievements');
}
const result = await response.json();
playerAchievements = result.data;
// Load achievement statistics
await loadAchievementStats();
// Show achievements
displayAchievementStats();
displayAchievements();
// Hide loading state
document.getElementById('achievementsLoading').style.display = 'none';
document.getElementById('achievementStats').style.display = 'flex';
document.getElementById('achievementCategories').style.display = 'block';
} catch (error) {
console.error('Error loading achievements:', error);
document.getElementById('achievementsLoading').style.display = 'none';
showAchievementsNotAvailable();
}
}
// Load achievement statistics
async function loadAchievementStats() {
try {
const response = await fetch(`/api/achievements/player/${currentPlayerId}/stats`);
if (response.ok) {
const result = await response.json();
window.achievementStats = result.data;
}
} catch (error) {
console.error('Error loading achievement stats:', error);
}
}
// Display achievement statistics
function displayAchievementStats() {
if (!window.achievementStats) return;
const stats = window.achievementStats;
document.getElementById('totalPoints').textContent = stats.total_points;
document.getElementById('completedAchievements').textContent = `${stats.completed_achievements}/${stats.total_achievements}`;
document.getElementById('achievementsToday').textContent = stats.achievements_today;
document.getElementById('completionPercentage').textContent = `${stats.completion_percentage}%`;
}
// Display achievements in grid
function displayAchievements() {
const achievementsGrid = document.getElementById('achievementsGrid');
if (playerAchievements.length === 0) {
achievementsGrid.innerHTML = `
<div class="no-achievements">
<div class="no-achievements-icon">🏆</div>
<h3>Noch keine Achievements</h3>
<p>Starte deine ersten Läufe, um Achievements zu sammeln!</p>
</div>
`;
return;
}
// Filter achievements by category
let filteredAchievements = playerAchievements;
if (currentAchievementCategory !== 'all') {
filteredAchievements = playerAchievements.filter(achievement =>
achievement.category === currentAchievementCategory
);
}
// Generate achievement cards
const achievementCards = filteredAchievements.map(achievement => {
const isCompleted = achievement.is_completed;
const progress = achievement.progress || 0;
const earnedAt = achievement.earned_at;
let progressText = '';
if (isCompleted) {
progressText = earnedAt ?
`Erreicht am ${new Date(earnedAt).toLocaleDateString('de-DE')}` :
'Abgeschlossen';
} else if (progress > 0) {
// Show progress for incomplete achievements
const conditionValue = getAchievementConditionValue(achievement.name);
if (conditionValue) {
progressText = `${progress}/${conditionValue}`;
}
}
return `
<div class="achievement-card ${isCompleted ? 'completed' : 'incomplete'}"
onclick="showAchievementDetails('${achievement.id}')">
<div class="achievement-icon">${achievement.icon}</div>
<div class="achievement-content">
<h4 class="achievement-name">${achievement.name}</h4>
<p class="achievement-description">${achievement.description}</p>
<div class="achievement-meta">
<span class="achievement-points">+${achievement.points} Punkte</span>
${progressText ? `<span class="achievement-progress">${progressText}</span>` : ''}
</div>
</div>
<div class="achievement-status">
${isCompleted ? '✅' : '⏳'}
</div>
</div>
`;
}).join('');
achievementsGrid.innerHTML = achievementCards;
}
// Get achievement condition value for progress display
function getAchievementConditionValue(achievementName) {
const conditionMap = {
'Erste Schritte': 1,
'Durchhalter': 3,
'Fleißig': 5,
'Besessen': 10,
'Regelmäßig': 5,
'Stammgast': 10,
'Treue': 20,
'Veteran': 50,
'Fortschritt': 5,
'Durchbruch': 10,
'Transformation': 15,
'Perfektionist': 20
};
return conditionMap[achievementName] || null;
}
// Show achievement category
function showAchievementCategory(category) {
currentAchievementCategory = category;
// Update active tab
document.querySelectorAll('.category-tab').forEach(tab => {
tab.classList.remove('active');
});
document.querySelector(`[data-category="${category}"]`).classList.add('active');
// Display filtered achievements
displayAchievements();
}
// Show achievement details (placeholder for future modal)
function showAchievementDetails(achievementId) {
const achievement = playerAchievements.find(a => a.id === achievementId);
if (achievement) {
console.log('Achievement details:', achievement);
// TODO: Implement achievement details modal
}
}
// Show achievements not available state
function showAchievementsNotAvailable() {
document.getElementById('achievementsLoading').style.display = 'none';
document.getElementById('achievementStats').style.display = 'none';
document.getElementById('achievementCategories').style.display = 'none';
document.getElementById('achievementsNotAvailable').style.display = 'block';
}
// Check achievements for current player
async function checkPlayerAchievements() {
if (!currentPlayerId) return;
try {
const response = await fetch(`/api/achievements/check/${currentPlayerId}`, {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
if (result.data.count > 0) {
// Show notification for new achievements
showAchievementNotification(result.data.new_achievements);
// Reload achievements
await loadPlayerAchievements();
}
}
} catch (error) {
console.error('Error checking achievements:', error);
}
}
// Show achievement notification
function showAchievementNotification(newAchievements) {
// Create notification element
const notification = document.createElement('div');
notification.className = 'achievement-notification';
notification.innerHTML = `
<div class="notification-content">
<div class="notification-icon">🏆</div>
<div class="notification-text">
<h4>Neue Achievements erreicht!</h4>
<p>Du hast ${newAchievements.length} neue Achievement${newAchievements.length > 1 ? 's' : ''} erhalten!</p>
</div>
<button class="notification-close" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
// Add to page
document.body.appendChild(notification);
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentElement) {
notification.remove();
}
}, 5000);
}
// Initialize achievements when player is loaded
function initializeAchievements(playerId) {
currentPlayerId = playerId;
loadPlayerAchievements();
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Add cookie settings button functionality // Add cookie settings button functionality
const cookieSettingsBtn = document.getElementById('cookie-settings-footer'); const cookieSettingsBtn = document.getElementById('cookie-settings-footer');

View File

@@ -886,6 +886,15 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => {
[player_id, location_id, recorded_time, new Date()] [player_id, location_id, recorded_time, new Date()]
); );
// Achievement-Überprüfung nach Zeit-Eingabe
try {
await pool.query('SELECT check_all_achievements($1)', [player_id]);
console.log(`✅ Achievement-Check für Spieler ${player_id} ausgeführt`);
} catch (achievementError) {
console.error('Fehler bei Achievement-Check:', achievementError);
// Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren
}
// WebSocket-Event senden für Live-Updates // WebSocket-Event senden für Live-Updates
@@ -1202,7 +1211,10 @@ router.get('/v1/public/user-times/:supabase_user_id', async (req, res) => {
ORDER BY t.created_at DESC ORDER BY t.created_at DESC
`, [player_id]); `, [player_id]);
res.json(timesResult.rows); res.json({
success: true,
data: timesResult.rows
});
} catch (error) { } catch (error) {
console.error('Fehler beim Abrufen der Benutzerzeiten:', error); console.error('Fehler beim Abrufen der Benutzerzeiten:', error);
@@ -1213,6 +1225,47 @@ router.get('/v1/public/user-times/:supabase_user_id', async (req, res) => {
} }
}); });
/**
* @swagger
* /api/v1/public/user-player/{supabase_user_id}:
* get:
* summary: Spieler-Info anhand der Supabase User ID abrufen
* description: Ruft Spieler-Informationen für das Dashboard ab
* tags: [Public API]
* parameters:
* - in: path
* name: supabase_user_id
* required: true
* schema:
* type: string
* format: uuid
* description: Supabase User ID
* responses:
* 200:
* description: Spieler-Informationen
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* $ref: '#/components/schemas/Player'
* 404:
* description: Spieler nicht gefunden
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server-Fehler
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// Get player info by Supabase user ID (no auth required for dashboard) // Get player info by Supabase user ID (no auth required for dashboard)
router.get('/v1/public/user-player/:supabase_user_id', async (req, res) => { router.get('/v1/public/user-player/:supabase_user_id', async (req, res) => {
const { supabase_user_id } = req.params; const { supabase_user_id } = req.params;
@@ -1685,6 +1738,75 @@ router.get('/v1/public/locations', async (req, res) => {
} }
}); });
/**
* @swagger
* /api/v1/public/times:
* get:
* summary: Alle Zeiten mit Standort-Informationen abrufen
* description: Ruft alle aufgezeichneten Zeiten mit Standort-Details ab
* tags: [Public API]
* parameters:
* - in: query
* name: location
* schema:
* type: string
* description: Filter nach Standort-ID
* - in: query
* name: limit
* schema:
* type: integer
* default: 100
* description: Maximale Anzahl der Ergebnisse
* responses:
* 200:
* description: Liste aller Zeiten
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* format: uuid
* player_id:
* type: string
* format: uuid
* location_id:
* type: string
* format: uuid
* recorded_time:
* type: object
* properties:
* seconds:
* type: number
* minutes:
* type: number
* milliseconds:
* type: number
* created_at:
* type: string
* format: date-time
* location_name:
* type: string
* latitude:
* type: number
* longitude:
* type: number
* 500:
* description: Server-Fehler
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// Public route to get times for location with parameter // Public route to get times for location with parameter
router.get('/v1/public/times', async (req, res) => { router.get('/v1/public/times', async (req, res) => {
const { location } = req.query; const { location } = req.query;
@@ -2022,6 +2144,15 @@ router.post('/v1/admin/runs', requireAdminAuth, async (req, res) => {
[player_id, location_id, timeInterval] [player_id, location_id, timeInterval]
); );
// Achievement-Überprüfung nach Zeit-Eingabe
try {
await pool.query('SELECT check_all_achievements($1)', [player_id]);
console.log(`✅ Achievement-Check für Spieler ${player_id} ausgeführt`);
} catch (achievementError) {
console.error('Fehler bei Achievement-Check:', achievementError);
// Achievement-Fehler sollen die Zeit-Eingabe nicht blockieren
}
res.json({ res.json({
success: true, success: true,
message: 'Lauf erfolgreich hinzugefügt', message: 'Lauf erfolgreich hinzugefügt',
@@ -2169,4 +2300,277 @@ router.put('/v1/admin/adminusers/:id', requireAdminAuth, async (req, res) => {
} }
}); });
// ==================== ACHIEVEMENT ENDPOINTS ====================
/**
* @swagger
* /api/achievements:
* get:
* summary: Alle verfügbaren Achievements abrufen
* description: Ruft alle aktiven Achievements im System ab
* tags: [Achievements]
* responses:
* 200:
* description: Liste aller Achievements
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: array
* items:
* $ref: '#/components/schemas/Achievement'
* 500:
* description: Server-Fehler
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// Get all achievements
router.get('/achievements', async (req, res) => {
try {
const result = await pool.query(`
SELECT id, name, description, category, icon, points, is_active
FROM achievements
WHERE is_active = true
ORDER BY category, points DESC
`);
res.json({
success: true,
data: result.rows
});
} catch (error) {
console.error('Error fetching achievements:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Laden der Achievements'
});
}
});
/**
* @swagger
* /api/achievements/player/{playerId}:
* get:
* summary: Achievements eines Spielers abrufen
* description: Ruft alle Achievements für einen bestimmten Spieler ab
* tags: [Achievements]
* parameters:
* - in: path
* name: playerId
* required: true
* schema:
* type: string
* format: uuid
* description: Eindeutige Spieler-ID
* responses:
* 200:
* description: Liste der Spieler-Achievements
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* example: true
* data:
* type: array
* items:
* allOf:
* - $ref: '#/components/schemas/Achievement'
* - $ref: '#/components/schemas/PlayerAchievement'
* 500:
* description: Server-Fehler
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// Get player achievements
router.get('/achievements/player/:playerId', async (req, res) => {
try {
const { playerId } = req.params;
const result = await pool.query(`
SELECT
a.id,
a.name,
a.description,
a.category,
a.icon,
a.points,
pa.progress,
pa.is_completed,
pa.earned_at
FROM achievements a
LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1
WHERE a.is_active = true
ORDER BY
pa.is_completed DESC,
a.category,
a.points DESC
`, [playerId]);
res.json({
success: true,
data: result.rows
});
} catch (error) {
console.error('Error fetching player achievements:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Laden der Spieler-Achievements'
});
}
});
// Get player achievement statistics
router.get('/achievements/player/:playerId/stats', async (req, res) => {
try {
const { playerId } = req.params;
const result = await pool.query(`
SELECT
COUNT(pa.id) as total_achievements,
COUNT(CASE WHEN pa.is_completed = true THEN 1 END) as completed_achievements,
SUM(CASE WHEN pa.is_completed = true THEN a.points ELSE 0 END) as total_points,
COUNT(CASE WHEN pa.is_completed = true AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE THEN 1 END) as achievements_today
FROM achievements a
LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1
WHERE a.is_active = true
`, [playerId]);
const stats = result.rows[0];
res.json({
success: true,
data: {
total_achievements: parseInt(stats.total_achievements),
completed_achievements: parseInt(stats.completed_achievements),
total_points: parseInt(stats.total_points) || 0,
achievements_today: parseInt(stats.achievements_today),
completion_percentage: stats.total_achievements > 0 ?
Math.round((stats.completed_achievements / stats.total_achievements) * 100) : 0
}
});
} catch (error) {
console.error('Error fetching player achievement stats:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Laden der Achievement-Statistiken'
});
}
});
// Check achievements for a specific player
router.post('/achievements/check/:playerId', async (req, res) => {
try {
const { playerId } = req.params;
// Verify player exists
const playerCheck = await pool.query('SELECT id FROM players WHERE id = $1', [playerId]);
if (playerCheck.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Spieler nicht gefunden'
});
}
// Run achievement check
await pool.query('SELECT check_all_achievements($1)', [playerId]);
// Get newly earned achievements
const newAchievements = await pool.query(`
SELECT a.name, a.description, a.icon, a.points
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.player_id = $1
AND pa.is_completed = true
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
ORDER BY pa.earned_at DESC
`, [playerId]);
res.json({
success: true,
message: 'Achievement-Check abgeschlossen',
data: {
new_achievements: newAchievements.rows,
count: newAchievements.rows.length
}
});
} catch (error) {
console.error('Error checking achievements:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Überprüfen der Achievements'
});
}
});
// Run daily achievement check for all players
router.post('/achievements/daily-check', async (req, res) => {
try {
// This endpoint runs the daily achievement check
const { runDailyAchievements } = require('../scripts/daily_achievements');
await runDailyAchievements();
res.json({
success: true,
message: 'Tägliche Achievement-Überprüfung abgeschlossen'
});
} catch (error) {
console.error('Error running daily achievement check:', error);
res.status(500).json({
success: false,
message: 'Fehler bei der täglichen Achievement-Überprüfung'
});
}
});
// Get achievement leaderboard
router.get('/achievements/leaderboard', async (req, res) => {
try {
const { limit = 10 } = req.query;
const result = await pool.query(`
SELECT
p.firstname,
p.lastname,
COUNT(pa.id) as completed_achievements,
SUM(a.points) as total_points
FROM players p
INNER JOIN player_achievements pa ON p.id = pa.player_id
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.is_completed = true
GROUP BY p.id, p.firstname, p.lastname
ORDER BY total_points DESC, completed_achievements DESC
LIMIT $1
`, [parseInt(limit)]);
res.json({
success: true,
data: result.rows.map((row, index) => ({
rank: index + 1,
name: `${row.firstname} ${row.lastname}`,
completed_achievements: parseInt(row.completed_achievements),
total_points: parseInt(row.total_points)
}))
});
} catch (error) {
console.error('Error fetching achievement leaderboard:', error);
res.status(500).json({
success: false,
message: 'Fehler beim Laden der Bestenliste'
});
}
});
module.exports = { router, requireApiKey }; module.exports = { router, requireApiKey };

View File

@@ -0,0 +1,110 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
});
async function runDailyAchievements() {
const client = await pool.connect();
try {
console.log('🎯 Starting daily achievement check...');
// Get all players who have played today
const playersResult = await client.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
`);
console.log(`Found ${playersResult.rows.length} players who played today`);
let totalAchievements = 0;
// Check achievements for each player
for (const player of playersResult.rows) {
console.log(`Checking achievements for ${player.firstname} ${player.lastname}...`);
// Run achievement check function
await client.query('SELECT check_all_achievements($1)', [player.id]);
// Count new achievements earned today
const newAchievementsResult = await client.query(`
SELECT COUNT(*) as count
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.player_id = $1
AND pa.is_completed = true
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
`, [player.id]);
const newAchievements = parseInt(newAchievementsResult.rows[0].count);
totalAchievements += newAchievements;
if (newAchievements > 0) {
console.log(`${newAchievements} new achievements earned!`);
// Get details of new achievements
const achievementsResult = await client.query(`
SELECT a.name, a.description, a.icon, a.points
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.player_id = $1
AND pa.is_completed = true
AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE
ORDER BY pa.earned_at DESC
`, [player.id]);
achievementsResult.rows.forEach(achievement => {
console.log(` ${achievement.icon} ${achievement.name} (+${achievement.points} points)`);
});
} else {
console.log(` No new achievements`);
}
}
console.log(`\n🎉 Daily achievement check completed!`);
console.log(`Total new achievements earned: ${totalAchievements}`);
// Log summary statistics
const statsResult = await client.query(`
SELECT
COUNT(DISTINCT pa.player_id) as players_with_achievements,
COUNT(pa.id) as total_achievements_earned,
SUM(a.points) as total_points_earned
FROM player_achievements pa
INNER JOIN achievements a ON pa.achievement_id = a.id
WHERE pa.is_completed = true
`);
const stats = statsResult.rows[0];
console.log(`\n📊 Overall Statistics:`);
console.log(`Players with achievements: ${stats.players_with_achievements}`);
console.log(`Total achievements earned: ${stats.total_achievements_earned}`);
console.log(`Total points earned: ${stats.total_points_earned || 0}`);
} catch (error) {
console.error('❌ Error running daily achievements:', error);
throw error;
} finally {
client.release();
}
}
// Run if called directly
if (require.main === module) {
runDailyAchievements()
.then(() => {
console.log('✅ Daily achievements script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('❌ Daily achievements script failed:', error);
process.exit(1);
});
}
module.exports = { runDailyAchievements };

86
scripts/setup_cron.js Normal file
View File

@@ -0,0 +1,86 @@
const { exec } = require('child_process');
const path = require('path');
// Cron job setup for daily achievements
const cronJob = {
// Run daily at 23:59 (end of day)
schedule: '59 23 * * *',
command: `cd ${__dirname} && node daily_achievements.js >> /var/log/ninjaserver_achievements.log 2>&1`,
description: 'Daily achievement check for Ninja Cross Parkour'
};
function setupCronJob() {
console.log('🕐 Setting up daily achievement cron job...');
// Create cron job entry
const cronEntry = `${cronJob.schedule} ${cronJob.command}`;
// Add to crontab
exec(`(crontab -l 2>/dev/null; echo "${cronEntry}") | crontab -`, (error, stdout, stderr) => {
if (error) {
console.error('❌ Error setting up cron job:', error);
return;
}
if (stderr) {
console.error('⚠️ Cron job warning:', stderr);
}
console.log('✅ Cron job setup successfully!');
console.log(`📅 Schedule: ${cronJob.schedule}`);
console.log(`🔧 Command: ${cronJob.command}`);
console.log('📝 Logs will be written to: /var/log/ninjaserver_achievements.log');
// Show current crontab
exec('crontab -l', (error, stdout, stderr) => {
if (!error) {
console.log('\n📋 Current crontab:');
console.log(stdout);
}
});
});
}
function removeCronJob() {
console.log('🗑️ Removing daily achievement cron job...');
exec('crontab -l | grep -v "daily_achievements.js" | crontab -', (error, stdout, stderr) => {
if (error) {
console.error('❌ Error removing cron job:', error);
return;
}
console.log('✅ Cron job removed successfully!');
});
}
// Command line interface
if (require.main === module) {
const command = process.argv[2];
switch (command) {
case 'setup':
setupCronJob();
break;
case 'remove':
removeCronJob();
break;
case 'status':
exec('crontab -l | grep daily_achievements', (error, stdout, stderr) => {
if (stdout) {
console.log('✅ Cron job is active:');
console.log(stdout);
} else {
console.log('❌ No cron job found');
}
});
break;
default:
console.log('Usage: node setup_cron.js [setup|remove|status]');
console.log(' setup - Add daily achievement cron job');
console.log(' remove - Remove daily achievement cron job');
console.log(' status - Check if cron job is active');
}
}
module.exports = { setupCronJob, removeCronJob };

View File

@@ -21,6 +21,8 @@ const path = require('path');
const session = require('express-session'); const session = require('express-session');
const { createServer } = require('http'); const { createServer } = require('http');
const { Server } = require('socket.io'); const { Server } = require('socket.io');
const swaggerUi = require('swagger-ui-express');
const swaggerSpecs = require('./swagger');
require('dotenv').config(); require('dotenv').config();
// Route Imports // Route Imports
@@ -82,6 +84,12 @@ function requireWebAuth(req, res, next) {
// ROUTE SETUP // ROUTE SETUP
// ============================================================================ // ============================================================================
// Swagger API Documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpecs, {
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'Ninja Cross Parkour API Documentation'
}));
// Unified API Routes (all under /api/v1/) // Unified API Routes (all under /api/v1/)
// - /api/v1/public/* - Public routes (no authentication) // - /api/v1/public/* - Public routes (no authentication)
// - /api/v1/private/* - API-Key protected routes // - /api/v1/private/* - API-Key protected routes

285
swagger.js Normal file
View File

@@ -0,0 +1,285 @@
const swaggerJsdoc = require('swagger-jsdoc');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Ninja Cross Parkour API',
version: '1.0.0',
description: 'API für das Ninja Cross Parkour System im Schwimmbad',
contact: {
name: 'Ninja Cross Parkour',
email: 'admin@ninjacross.com'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'https://ninja.reptilfpv.de',
description: 'Production server'
}
],
components: {
securitySchemes: {
ApiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
description: 'API Key für Authentifizierung'
},
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT Token für Supabase Authentifizierung'
}
},
schemas: {
Player: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Spieler-ID'
},
firstname: {
type: 'string',
description: 'Vorname des Spielers'
},
lastname: {
type: 'string',
description: 'Nachname des Spielers'
},
birthdate: {
type: 'string',
format: 'date',
description: 'Geburtsdatum des Spielers'
},
rfiduid: {
type: 'string',
description: 'RFID UID der Karte (Format: XX:XX:XX:XX)'
},
supabase_user_id: {
type: 'string',
format: 'uuid',
description: 'Supabase User ID für Verknüpfung'
},
created_at: {
type: 'string',
format: 'date-time',
description: 'Erstellungsdatum'
}
}
},
Time: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Zeit-ID'
},
player_id: {
type: 'string',
format: 'uuid',
description: 'ID des Spielers'
},
location_id: {
type: 'string',
format: 'uuid',
description: 'ID des Standorts'
},
recorded_time: {
type: 'object',
description: 'Aufgezeichnete Zeit als Intervall',
properties: {
seconds: { type: 'number' },
minutes: { type: 'number' },
milliseconds: { type: 'number' }
}
},
created_at: {
type: 'string',
format: 'date-time',
description: 'Erstellungsdatum'
}
}
},
Location: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Standort-ID'
},
name: {
type: 'string',
description: 'Name des Standorts'
},
latitude: {
type: 'number',
format: 'float',
description: 'Breitengrad'
},
longitude: {
type: 'number',
format: 'float',
description: 'Längengrad'
},
time_threshold: {
type: 'object',
description: 'Zeitschwelle für den Standort',
properties: {
seconds: { type: 'number' },
minutes: { type: 'number' }
}
},
created_at: {
type: 'string',
format: 'date-time',
description: 'Erstellungsdatum'
}
}
},
Achievement: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Achievement-ID'
},
name: {
type: 'string',
description: 'Name des Achievements'
},
description: {
type: 'string',
description: 'Beschreibung des Achievements'
},
category: {
type: 'string',
enum: ['consistency', 'improvement', 'seasonal', 'monthly'],
description: 'Kategorie des Achievements'
},
condition_type: {
type: 'string',
description: 'Typ der Bedingung'
},
condition_value: {
type: 'integer',
description: 'Wert der Bedingung'
},
icon: {
type: 'string',
description: 'Emoji-Icon für das Achievement'
},
points: {
type: 'integer',
description: 'Punkte für das Achievement'
},
is_active: {
type: 'boolean',
description: 'Ob das Achievement aktiv ist'
}
}
},
PlayerAchievement: {
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid',
description: 'Eindeutige Player-Achievement-ID'
},
player_id: {
type: 'string',
format: 'uuid',
description: 'ID des Spielers'
},
achievement_id: {
type: 'string',
format: 'uuid',
description: 'ID des Achievements'
},
progress: {
type: 'integer',
description: 'Aktueller Fortschritt'
},
is_completed: {
type: 'boolean',
description: 'Ob das Achievement abgeschlossen ist'
},
earned_at: {
type: 'string',
format: 'date-time',
description: 'Wann das Achievement erreicht wurde'
}
}
},
Error: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: false
},
message: {
type: 'string',
description: 'Fehlermeldung'
}
}
},
Success: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: true
},
message: {
type: 'string',
description: 'Erfolgsmeldung'
},
data: {
type: 'object',
description: 'Daten der Antwort'
}
}
}
}
},
tags: [
{
name: 'Public API',
description: 'Öffentliche API-Endpoints ohne Authentifizierung'
},
{
name: 'Private API',
description: 'Private API-Endpoints mit API-Key Authentifizierung'
},
{
name: 'Web API',
description: 'Web-API-Endpoints für das Frontend'
},
{
name: 'Admin API',
description: 'Admin-API-Endpoints für Verwaltung'
},
{
name: 'Achievements',
description: 'Achievement-System Endpoints'
}
]
},
apis: ['./routes/api.js'] // Pfad zu deiner API-Datei
};
const specs = swaggerJsdoc(options);
module.exports = specs;