feat: Sprint 1 — backend fondations TetaRdPG
Auth SuperOAuth (JWT validation + httpOnly cookie), entités users/characters/level_thresholds, lazy calculation endurance, seed 100 niveaux, config prod-ready (trust proxy, helmet, CORS, rate limit). Validé : health 200, auth flow, character CRUD, endurance lazy, 401 sans cookie.
This commit is contained in:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
PORT=4000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
DATABASE_URL=postgresql://tetardpg:password@localhost:5432/tetardpg
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Frontend CORS (virgule-séparé pour multi-origin)
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# SuperOAuth — service externe d'authentification
|
||||||
|
SUPER_OAUTH_URL=http://localhost:3000
|
||||||
|
SUPER_OAUTH_JWT_SECRET=
|
||||||
|
|
||||||
|
# Cookie signing
|
||||||
|
COOKIE_SECRET=
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment — jamais versionné
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# TypeORM cache
|
||||||
|
.typeorm/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
pgdata/
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---
|
||||||
|
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
CMD ["node", "dist/main"]
|
||||||
524
GDD.md
Normal file
524
GDD.md
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
# TetaRdPG — Game Design Document
|
||||||
|
|
||||||
|
> Version : 1.0 — Mars 2026
|
||||||
|
> Statut : Conception — pré-production
|
||||||
|
> Type : Idle-RPG textuel communautaire | Extension Twitch + Site Web
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pitch
|
||||||
|
|
||||||
|
TetaRdPG est un RPG communautaire asynchrone intégré à Twitch.
|
||||||
|
Les viewers créent un personnage, le font progresser via des actions consommant de l'endurance,
|
||||||
|
et interagissent avec le stream et la communauté en temps réel.
|
||||||
|
|
||||||
|
**Ce qui rend ce jeu unique :**
|
||||||
|
- L'engagement Twitch (Bits, abonnements, points de chaîne) est une ressource de jeu
|
||||||
|
- La progression est persistante et stratégique, pas un simple clicker
|
||||||
|
- La communauté joue ensemble — succès collectifs, boss mondiaux, guildes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Piliers de design
|
||||||
|
|
||||||
|
| Pilier | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Stratégie** | Chaque action coûte de l'endurance — gérer sa ressource est central |
|
||||||
|
| **Persistance** | La progression survit aux streams, les choix ont des conséquences durables |
|
||||||
|
| **Communauté** | Les objectifs collectifs sont aussi importants que la progression individuelle |
|
||||||
|
| **Intégration Twitch** | L'activité sur le stream a une valeur en jeu directe |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Systèmes Core
|
||||||
|
|
||||||
|
### Endurance
|
||||||
|
|
||||||
|
L'endurance est la ressource principale. Elle régule toutes les actions du joueur.
|
||||||
|
|
||||||
|
| Paramètre | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Base | 100 pts |
|
||||||
|
| Maximum (avec équipement) | 150 pts |
|
||||||
|
| Recharge passive | 10 pts / heure (1 pt toutes les 6 min) |
|
||||||
|
|
||||||
|
**Recharge active :**
|
||||||
|
- 1 TetardCoin → +20 endurance
|
||||||
|
- Potions : +25 / +50 / +100 endurance
|
||||||
|
|
||||||
|
**Coût des actions :**
|
||||||
|
|
||||||
|
| Action | Coût endurance |
|
||||||
|
|--------|---------------|
|
||||||
|
| Combat | 10 |
|
||||||
|
| Entraînement (léger) | 20 |
|
||||||
|
| Entraînement (moyen) | 40 |
|
||||||
|
| Entraînement (intensif) | 60 |
|
||||||
|
| Quête (facile) | 15 |
|
||||||
|
| Quête (moyenne) | 30 |
|
||||||
|
| Quête (difficile) | 50 |
|
||||||
|
| Fuite en combat | 5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Combat PvE
|
||||||
|
|
||||||
|
**Format :** tour par tour — Attaque / Défense / Objet / Fuite
|
||||||
|
|
||||||
|
**Formules de dégâts :**
|
||||||
|
|
||||||
|
| Type | Formule |
|
||||||
|
|------|---------|
|
||||||
|
| Mêlée | Arme + (Force × 1.5) |
|
||||||
|
| Distance | Arme + (Agilité × 1.5) |
|
||||||
|
| Magique | Arme + (Intelligence × 1.5) |
|
||||||
|
|
||||||
|
**Mécanique défensive :**
|
||||||
|
- Réduction selon type d'armure + coefficient de défense
|
||||||
|
|
||||||
|
**Coups critiques et esquive :**
|
||||||
|
|
||||||
|
| Mécanique | Formule | Effet |
|
||||||
|
|-----------|---------|-------|
|
||||||
|
| Critique | 5% + (Chance × 0.2%) | Dégâts × 1.5 |
|
||||||
|
| Esquive | 5% + (Chance × 0.1%) | Annule les dégâts |
|
||||||
|
|
||||||
|
**Fin de combat :**
|
||||||
|
- Victoire → XP + Or + loot + récupération 10% PV
|
||||||
|
- Défaite → retour auberge, -50 endurance, perte d'or
|
||||||
|
- Récupération PV hors combat → 10% PV toutes les 15 min
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Progression & Niveaux
|
||||||
|
|
||||||
|
**Courbe XP :** `XP requise = 100 × N^1.5`
|
||||||
|
|
||||||
|
| Niveau | XP requise (cumulé) |
|
||||||
|
|--------|-------------------|
|
||||||
|
| 2 | 283 |
|
||||||
|
| 10 | 3 162 |
|
||||||
|
| 50 | 35 355 |
|
||||||
|
| 100 | 100 000 |
|
||||||
|
|
||||||
|
**Niveau maximum :** 100, puis **Niveau Beta** *(définition à préciser — prestige, cosmétique ?)*
|
||||||
|
|
||||||
|
**Statistiques :**
|
||||||
|
- 5 stats : Force, Agilité, Intelligence, Chance, Vitalité
|
||||||
|
- Base : 5 points à répartir à la création
|
||||||
|
- Gain : +5 points par niveau
|
||||||
|
- Plafond : 101 par stat (via entraînement et équipement)
|
||||||
|
- PV de base : 100 (Vitalité influence le max)
|
||||||
|
|
||||||
|
**Déblocages par niveau :**
|
||||||
|
|
||||||
|
| Niveau | Déblocage |
|
||||||
|
|--------|-----------|
|
||||||
|
| 5 | Quêtes moyennes |
|
||||||
|
| 10 | Forge |
|
||||||
|
| 15 | Boutique avancée |
|
||||||
|
| 20 | Quêtes difficiles + Guildes |
|
||||||
|
| 30 | Équipements épiques |
|
||||||
|
| 50 | Succès communautaires + Quêtes d'élite |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Artisanat & Forge
|
||||||
|
|
||||||
|
**Forge** (accès niveau 10+)
|
||||||
|
|
||||||
|
Améliore les équipements existants : +stat, effet spécial, bonus set anticipé.
|
||||||
|
|
||||||
|
| Niveau d'amélioration | Résultat | Risque |
|
||||||
|
|----------------------|----------|--------|
|
||||||
|
| Niv. 1 – 2 | Succès garanti | Aucun |
|
||||||
|
| Niv. 3 | Succès / Échec | 20% perte matériaux |
|
||||||
|
| Niv. 4 | Succès / Échec | 30% perte matériaux |
|
||||||
|
| Niv. 5 | Succès / Échec | 40% perte matériaux |
|
||||||
|
|
||||||
|
Coût max : 12 TetardCoin (succès garanti, tous niveaux)
|
||||||
|
|
||||||
|
**Artisanat**
|
||||||
|
|
||||||
|
Création d'équipements, consommables, améliorations à partir de recettes + matériaux.
|
||||||
|
|
||||||
|
| Paramètre | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Durée | 15 min à 2 h (temps réel) |
|
||||||
|
| Endurance | 5 + (Rareté × 3) |
|
||||||
|
| Accélération | TetardCoin |
|
||||||
|
|
||||||
|
Sources de matériaux : loot, échanges joueurs, événements.
|
||||||
|
|
||||||
|
**Bonus de sets d'équipement :**
|
||||||
|
|
||||||
|
| Pièces du même set | Bonus |
|
||||||
|
|--------------------|-------|
|
||||||
|
| 2 pièces | +3% stat associée |
|
||||||
|
| 3 pièces | +10% + effet spécial |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Économie
|
||||||
|
|
||||||
|
### Monnaies
|
||||||
|
|
||||||
|
| Monnaie | Obtention | Usage |
|
||||||
|
|---------|-----------|-------|
|
||||||
|
| **Or** | Combats, quêtes, succès | Forge, consommables, boutique de base |
|
||||||
|
| **TetardCoin** | Twitch (Bits, abonnements), achat direct | Recharge endurance, boutique Twitch, forge garantie |
|
||||||
|
| **Matériaux** | Loot, artisanat, événements | Forge, artisanat |
|
||||||
|
|
||||||
|
### Boutiques
|
||||||
|
|
||||||
|
| Boutique | Accès | Contenu |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| Base | Niveau 1 | Équipements communs/rares, potions simples |
|
||||||
|
| Avancée | Niveau 15 | Épiques, recettes, matériaux |
|
||||||
|
| Événementielle | Événements | Objets thématiques limités |
|
||||||
|
| Twitch | TetardCoin uniquement | Cosmétiques, boosts XP/loot |
|
||||||
|
|
||||||
|
### Marché communautaire *(futur — post-lancement)*
|
||||||
|
|
||||||
|
- Vente / achat d'objets et matériaux entre joueurs
|
||||||
|
- Taxe : 5% Or sur chaque transaction
|
||||||
|
- Limite d'accès : selon niveau joueur
|
||||||
|
- Phase alpha : interface simple, sans enchères
|
||||||
|
- Phase évoluée : filtres avancés, système d'enchères
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Communauté
|
||||||
|
|
||||||
|
### Succès
|
||||||
|
|
||||||
|
**Individuels** — catégories : Progression / Combat / Zones / Équipements / Économie
|
||||||
|
Récompenses : Or, objets rares, titres honorifiques
|
||||||
|
|
||||||
|
**Communautaires** — objectifs collectifs :
|
||||||
|
- Exemples : tuer 10 000 monstres, collecter 1M TetardCoin, vaincre un GIGABOSS
|
||||||
|
- Récompenses : boosts globaux XP/loot, boutique spéciale, titres exclusifs
|
||||||
|
|
||||||
|
### Événements mondiaux
|
||||||
|
|
||||||
|
**GIGABOSS mondial**
|
||||||
|
- Boss unique accessible à tous pendant 72h
|
||||||
|
- Chaque joueur inflige des dégâts cumulés
|
||||||
|
- Récompenses proportionnelles à la contribution (Or, loot, titres)
|
||||||
|
|
||||||
|
**Semaines thématiques**
|
||||||
|
- Focus sur une zone ou activité (XP doublée, loot spécial, quêtes exclusives)
|
||||||
|
|
||||||
|
**Chasses communautaires**
|
||||||
|
- Tuer X monstres spécifiques ensemble
|
||||||
|
- Suivi en temps réel (barres de progression)
|
||||||
|
|
||||||
|
### Guildes & Alliances
|
||||||
|
|
||||||
|
**Guildes** (accès niveau 20)
|
||||||
|
- Création : coût Or + TetardCoin, nom + blason + description personnalisés
|
||||||
|
- Chat de guilde (site + extension)
|
||||||
|
- Coffre de guilde (matériaux, or, objets partagés)
|
||||||
|
- Quêtes hebdomadaires collectives
|
||||||
|
|
||||||
|
| Niveau guilde | Bonus |
|
||||||
|
|---------------|-------|
|
||||||
|
| 1 | +5% XP |
|
||||||
|
| 2 | +10% loot |
|
||||||
|
| 3 | +5% endurance max |
|
||||||
|
|
||||||
|
**Alliances** (jusqu'à 3 guildes)
|
||||||
|
- Bonus événementiels partagés
|
||||||
|
- Classements inter-guildes (PvE, quêtes, contribution économique)
|
||||||
|
- Récompenses : titres collectifs, cosmétiques, accès boutique exclusive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compétitif
|
||||||
|
|
||||||
|
### PvP — Tag & Resolve
|
||||||
|
|
||||||
|
Inspiré du modèle LaBrute (site classique) : **les combats sont asynchrones, résolus automatiquement par le serveur.**
|
||||||
|
|
||||||
|
**Ressource PvP : tickets journaliers**
|
||||||
|
|
||||||
|
- 10 tickets par jour, remise à zéro quotidienne (heure fixe à définir)
|
||||||
|
- 1 combat = 1 ticket, qu'il s'agisse d'un humain ou d'un bot
|
||||||
|
- Pas de coût en endurance — le PvP est indépendant du PvE
|
||||||
|
- *(Bonus de tickets via abonnements Twitch ou TetardCoin : à définir lors de la session monétisation)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Flux d'un combat PvP :**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Le joueur s'inscrit en file PvP (tag)
|
||||||
|
2. Le système trouve un adversaire dans sa fenêtre Elo
|
||||||
|
3. Le combat se résout côté serveur (même moteur que le PvE)
|
||||||
|
4. Les deux joueurs consultent le log de combat + résultat
|
||||||
|
5. Les Elo se mettent à jour
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucune interaction en temps réel requise — le joueur peut voir le résultat à sa prochaine connexion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Système Elo
|
||||||
|
|
||||||
|
**Elo de départ :** 1000
|
||||||
|
|
||||||
|
**Formule :**
|
||||||
|
```
|
||||||
|
Score attendu = 1 / (1 + 10^((EloAdversaire - EloJoueur) / 400))
|
||||||
|
Nouvel Elo = Elo + K × (résultat - score attendu)
|
||||||
|
résultat = 1 (victoire) | 0 (défaite)
|
||||||
|
```
|
||||||
|
|
||||||
|
**K-factor :**
|
||||||
|
|
||||||
|
| Statut | Combats | K |
|
||||||
|
|--------|---------|---|
|
||||||
|
| Elo provisoire | < 20 combats PvP | 32 |
|
||||||
|
| Elo stable | ≥ 20 combats PvP | 16 |
|
||||||
|
| Combat contre bot | Tout statut | 8 |
|
||||||
|
|
||||||
|
**Fenêtre de matchmaking :**
|
||||||
|
- Départ : ±150 Elo
|
||||||
|
- Si aucun adversaire trouvé après 5 min → fenêtre élargie à ±300
|
||||||
|
- Si toujours rien → bot inséré
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bots de simulation
|
||||||
|
|
||||||
|
Permettent de jouer dès le lancement même avec peu de joueurs, et de tester l'équilibrage en continu.
|
||||||
|
|
||||||
|
Le bot est construit à partir d'un profil de stats correspondant à son tier Elo — il utilise le même moteur de combat que les joueurs.
|
||||||
|
|
||||||
|
| Tier | Elo | Profil |
|
||||||
|
|------|-----|--------|
|
||||||
|
| Têtard | < 800 | Stats faibles, build généraliste |
|
||||||
|
| Apprenti | 800 – 1 000 | Stats moyennes, pas d'équipement rare |
|
||||||
|
| Guerrier | 1 000 – 1 200 | Stats optimisées, set de base complet |
|
||||||
|
| Élite | 1 200 – 1 500 | Build spécialisé, équipements épiques |
|
||||||
|
| Légendaire | 1 500+ | Build maxé, équipement rare, set complet |
|
||||||
|
|
||||||
|
> Un combat contre un bot met l'Elo à jour (K=8). Le joueur sait qu'il a joué contre un bot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ligues saisonnières
|
||||||
|
|
||||||
|
- Saisons de durée fixe *(durée à définir)*
|
||||||
|
- Classement basé sur l'Elo PvP en fin de saison
|
||||||
|
- **Soft reset** en début de saison : Elo ramené vers 1000 (ex : `nouvel Elo = (Elo + 1000) / 2`)
|
||||||
|
- Récompenses de fin de saison selon rang (cosmétiques, titres, TetardCoin)
|
||||||
|
|
||||||
|
**Rangs de ligue *(noms illustratifs)* :**
|
||||||
|
|
||||||
|
| Rang | Elo |
|
||||||
|
|------|-----|
|
||||||
|
| Têtard | < 800 |
|
||||||
|
| Apprenti | 800 – 1 000 |
|
||||||
|
| Guerrier | 1 000 – 1 200 |
|
||||||
|
| Élite | 1 200 – 1 500 |
|
||||||
|
| Légendaire | 1 500+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Classements complémentaires
|
||||||
|
|
||||||
|
En plus du classement PvP Elo, plusieurs axes de classement coexistent :
|
||||||
|
|
||||||
|
| Classement | Basé sur |
|
||||||
|
|------------|----------|
|
||||||
|
| XP globale | Niveau + XP totale |
|
||||||
|
| Richesse | Or accumulé |
|
||||||
|
| Contribution communautaire | Dégâts GIGABOSS, chasses, événements |
|
||||||
|
| Hall of Fame mensuel | Activité + performance du mois |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Économie Twitch — Intégration enrichie *(Direction A)*
|
||||||
|
|
||||||
|
> Enrichissement de l'expérience pour UN streamer et sa communauté.
|
||||||
|
> Développable en continuité directe de la v1.0.
|
||||||
|
|
||||||
|
### Sources de TetardCoin via Twitch
|
||||||
|
|
||||||
|
| Source | Mécanisme | Montant |
|
||||||
|
|--------|-----------|---------|
|
||||||
|
| Bits | EventSub `channel.cheer` → conversion directe | *À définir* |
|
||||||
|
| Abonnement Prime | EventSub subscribe | Bonus mensuel *à définir* |
|
||||||
|
| Abonnement Lv.1 | EventSub subscribe | Bonus mensuel *à définir* |
|
||||||
|
| Abonnement Lv.2 | EventSub subscribe | Bonus mensuel *à définir* |
|
||||||
|
| Abonnement Lv.3 | EventSub subscribe | Bonus mensuel *à définir* |
|
||||||
|
| Activité chat | EventSub `channel.chat.message` | *À définir — ratio et anti-spam* |
|
||||||
|
| Présence stream | Token de présence côté serveur (pendant live) | *À définir — fréquence* |
|
||||||
|
|
||||||
|
### Présence stream (Watch Time)
|
||||||
|
|
||||||
|
Le viewer s'authentifie une fois sur le site.
|
||||||
|
Pendant que le stream est live (`stream.online`), le serveur lui crédite des TetardCoin passifs toutes les X minutes.
|
||||||
|
|
||||||
|
> Twitch n'expose pas le watch time via API — la détection de présence se fait côté serveur (token actif + stream live). Le viewer n'a pas besoin de rester sur le site.
|
||||||
|
> **Question ouverte :** doit-il être connecté sur le *site* ou juste sur *Twitch* ? *(impacte l'implémentation)*
|
||||||
|
|
||||||
|
### Activité chat
|
||||||
|
|
||||||
|
Récompenser la participation des viewers en direct.
|
||||||
|
|
||||||
|
- Commandes dédiées (`!combat`, `!quête`, `!statut`) → déclencher des actions en jeu depuis le chat
|
||||||
|
- Messages passifs → TetardCoin au message ou par tranche de X messages
|
||||||
|
- **Question ouverte :** récompenser tout message ou uniquement les commandes ? *(anti-spam à définir)*
|
||||||
|
|
||||||
|
### Événements Twitch → Événements en jeu
|
||||||
|
|
||||||
|
| Événement Twitch | Événement en jeu |
|
||||||
|
|-----------------|-----------------|
|
||||||
|
| Hype Train | Boost communautaire temporaire (XP/loot doublés, boss surprise) |
|
||||||
|
| Sub Gift massif | Événement spécial (drop de matériaux rares) |
|
||||||
|
| Raid entrant | Accueil des raiders avec bonus temporaire |
|
||||||
|
| *Autres à définir* | *Session brainstorm événements* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Portail multi-streamers *(Direction B)*
|
||||||
|
|
||||||
|
> TetaRdPG comme service — héberger le jeu pour plusieurs streamers.
|
||||||
|
> Architecture distincte de la v1.0 — à traiter comme un projet parallèle.
|
||||||
|
|
||||||
|
### Vision
|
||||||
|
|
||||||
|
Chaque streamer s'enregistre sur la plateforme et lie son compte Twitch.
|
||||||
|
La plateforme gère son token, ses viewers jouent dans son royaume TetaRdPG.
|
||||||
|
Le jeu grandit en réseau : plus de streamers = plus de joueurs = communauté étendue.
|
||||||
|
|
||||||
|
### Modèle d'univers — question ouverte
|
||||||
|
|
||||||
|
Deux options architecturales non tranchées :
|
||||||
|
|
||||||
|
| Modèle | Description | Avantage | Inconvénient |
|
||||||
|
|--------|-------------|----------|-------------|
|
||||||
|
| **Royaumes isolés** | Chaque streamer a son propre jeu indépendant | Simple, streamer = maître de son univers | Pas de communauté inter-streamers |
|
||||||
|
| **Univers partagé** | Un compte joueur global, plusieurs streams rejoignables | Réseau fort, croissance organique | Équilibrage complexe, gouvernance |
|
||||||
|
|
||||||
|
> **Question ouverte :** univers partagé ou royaumes isolés ?
|
||||||
|
|
||||||
|
### Modèle économique — question ouverte
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Freemium streamer | Gratuit de base, fonctionnalités premium payantes |
|
||||||
|
| Abonnement streamer | Mensuel fixe par streamer enregistré |
|
||||||
|
| % transactions | Commission sur les achats TetardCoin des joueurs |
|
||||||
|
| Hybride | Freemium + commission |
|
||||||
|
|
||||||
|
> **Question ouverte :** quel modèle économique pour les streamers ?
|
||||||
|
|
||||||
|
### Ce que la plateforme gère par streamer
|
||||||
|
|
||||||
|
- Token Twitch broadcaster (EventSub sur son channel)
|
||||||
|
- Base de joueurs liée à son channel
|
||||||
|
- Personnalisation : nom du royaume, lore local *(optionnel)*
|
||||||
|
- Classements et événements propres à sa communauté
|
||||||
|
|
||||||
|
### Roadmap Direction B
|
||||||
|
|
||||||
|
> À ne pas démarrer avant que la v1.0 (Direction A) soit stable en production.
|
||||||
|
|
||||||
|
```
|
||||||
|
v1.0 stable → extraire l'architecture multi-tenant → onboarding premiers streamers partenaires
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Intégration Twitch
|
||||||
|
|
||||||
|
**Authentification**
|
||||||
|
- Connexion via OAuth Twitch → lien compte Twitch ↔ compte site
|
||||||
|
|
||||||
|
**Données récupérées**
|
||||||
|
- Statut live du stream
|
||||||
|
- Points de chaîne
|
||||||
|
- Bits (conversion en TetardCoin)
|
||||||
|
- Abonnements (bonus TetardCoin mensuel)
|
||||||
|
|
||||||
|
**Extension Twitch (panel viewer)**
|
||||||
|
- Statut du personnage
|
||||||
|
- Actions rapides depuis le stream
|
||||||
|
- Affichage des succès et classements
|
||||||
|
|
||||||
|
**Actions spéciales via récompenses personnalisées Twitch**
|
||||||
|
- Exemples : boost XP, lancement de quête, défi communautaire
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monétisation *(valeurs à définir — session dédiée)*
|
||||||
|
|
||||||
|
| Source | Mécanisme | Valeur |
|
||||||
|
|--------|-----------|--------|
|
||||||
|
| Bits | 1 Bit → X TetardCoin | *À définir* |
|
||||||
|
| Abonnement Prime | Bonus mensuel TetardCoin | *À définir* |
|
||||||
|
| Abonnement Lv.1 | Bonus mensuel TetardCoin | *À définir* |
|
||||||
|
| Abonnement Lv.2 | Bonus mensuel TetardCoin | *À définir* |
|
||||||
|
| Abonnement Lv.3 | Bonus mensuel TetardCoin | *À définir* |
|
||||||
|
| Achat direct TetardCoin | Recharge endurance / objets | *À définir* |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lore & Univers *(illustratif — à redéfinir)*
|
||||||
|
|
||||||
|
> Ce qui suit est une base d'inspiration, pas un canon définitif.
|
||||||
|
> Tout peut être réinventé lors d'une session lore dédiée.
|
||||||
|
|
||||||
|
Monde aquatique régi par la mythologie du **Têtarastafarisme** et du *Têtard Prophétique*.
|
||||||
|
|
||||||
|
Exemples de zones : marais, forêt brumeuse, grottes, ruines, temple.
|
||||||
|
Exemples de PNJ : sage, alchimiste, forgeron, guide mystérieux.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap v1.0
|
||||||
|
|
||||||
|
| Phase | Durée | Livrables clés |
|
||||||
|
|-------|-------|----------------|
|
||||||
|
| Pré-production | Sem. 1–4 | Stack validé, wireframes, systèmes formalisés |
|
||||||
|
| Sprint 1 — Auth + Backend | Sem. 5–6 | Auth Twitch, création personnage, API core |
|
||||||
|
| Sprint 2 — Combat + UI | Sem. 7–8 | Combat PvE, interface joueur |
|
||||||
|
| Sprint 3 — Artisanat | Sem. 9–10 | Forge, craft, recettes |
|
||||||
|
| Sprint 4 — Succès + HoF | Sem. 11–12 | Succès, classements, Hall of Fame |
|
||||||
|
| Bêta fermée | Mois 4 | Tests communauté, feedback, équilibrage |
|
||||||
|
| Lancement v1.0 | Mois 5 | Extension Twitch + Site Web en prod |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-lancement (v1.1+)
|
||||||
|
|
||||||
|
| Trimestre | Contenu |
|
||||||
|
|-----------|---------|
|
||||||
|
| T1 | Guildes v1.1, premier GIGABOSS rotatif, boutique de succès |
|
||||||
|
| T2 | PvP v1.2, personnalisation joueur, marché communautaire étendu |
|
||||||
|
| T3 | Nouvelles zones & lore avancé v1.3, événements saisonniers |
|
||||||
|
| T4 | Alliances de guildes v1.4, GIGABOSS légendaires, personnalisation Twitch avancée |
|
||||||
|
|
||||||
|
**Fréquence de mises à jour :**
|
||||||
|
- Patchs : toutes les 2 semaines (bugfix, équilibrage)
|
||||||
|
- Contenu : mensuel (zones, quêtes, objets, événements)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Points ouverts
|
||||||
|
|
||||||
|
| Point | État |
|
||||||
|
|-------|------|
|
||||||
|
| Stack technique | **TypeScript partout** — NestJS (backend) + React (frontend) + PostgreSQL + Redis |
|
||||||
|
| Monétisation — valeurs exactes (Bits, abos) | *Session dédiée* |
|
||||||
|
| Présence stream — détection côté site ou Twitch ? | *À trancher — Direction A* |
|
||||||
|
| Chat rewards — tout message ou commandes seulement ? | *À trancher — Direction A* |
|
||||||
|
| Direction B — univers partagé ou royaumes isolés ? | *À trancher — Direction B* |
|
||||||
|
| Direction B — modèle économique streamers | *À trancher — Direction B* |
|
||||||
|
| Durée des saisons PvP | *À définir* |
|
||||||
|
| Niveau Beta (post-100) | *À préciser* |
|
||||||
|
| Lore & univers | *Redéfinition libre lors d'une session dédiée* |
|
||||||
|
| Zones et contenu v1.0 | *À définir selon le scope MVP* |
|
||||||
318
SPRINT1.md
Normal file
318
SPRINT1.md
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# TetaRdPG — Brief Sprint 1
|
||||||
|
|
||||||
|
> Statut : ⬜ À démarrer
|
||||||
|
> Objectif : Backend jouable en local — Auth + Personnage + Endurance
|
||||||
|
> Stack : TypeScript · NestJS · PostgreSQL · Redis · Docker Compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Le GDD est complet sur les systèmes core (voir `GDD.md`).
|
||||||
|
Ce sprint pose les fondations backend : auth déléguée à SuperOAuth, personnage joueur, gestion d'endurance.
|
||||||
|
**Pas de Twitch. Pas de combat.** Ce sera Sprint 2.
|
||||||
|
|
||||||
|
Architecture locale d'abord — mais production-ready dès le départ (VPS probable).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope — contrainte d'autonomie agents
|
||||||
|
|
||||||
|
```
|
||||||
|
Répertoire de travail : /home/tetardtek/Dev/Gitea/TetaRdPG/
|
||||||
|
Interdit d'écriture : brain/, originsdigital/, super-oauth/, tout autre projet
|
||||||
|
SuperOAuth : service externe consommé via env vars — jamais modifié
|
||||||
|
Brain : lecture seule si besoin de patterns — zéro écriture
|
||||||
|
```
|
||||||
|
|
||||||
|
> Toute écriture hors de ce répertoire = violation de scope → STOP immédiat, signaler à l'humain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Périmètre Sprint 1
|
||||||
|
|
||||||
|
### ✅ In scope
|
||||||
|
|
||||||
|
- Projet NestJS + TypeScript initialisé
|
||||||
|
- Docker Compose local : PostgreSQL + Redis + backend
|
||||||
|
- Auth via SuperOAuth (consommer le service existant — ne pas réimplémenter)
|
||||||
|
- Entités DB : `users`, `characters`, `level_thresholds` (seed)
|
||||||
|
- API : création personnage, lecture personnage, état endurance
|
||||||
|
- Endurance : lazy calculation (pas de timer actif)
|
||||||
|
- Config production-ready dès le départ (trust proxy, CORS env, rate limiting, httpOnly cookies)
|
||||||
|
- Health endpoint `/api/health`
|
||||||
|
|
||||||
|
### ❌ Out of scope
|
||||||
|
|
||||||
|
- Twitch OAuth / EventSub
|
||||||
|
- Combat PvE
|
||||||
|
- Forge / Artisanat
|
||||||
|
- Frontend
|
||||||
|
- Déploiement VPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth — Intégration SuperOAuth
|
||||||
|
|
||||||
|
SuperOAuth est le service d'auth mutualisé de Tetardtek.
|
||||||
|
Le client (TetaRdPG) ne gère jamais les credentials OAuth — il délègue et valide le JWT.
|
||||||
|
|
||||||
|
**Variables d'environnement requises :**
|
||||||
|
```env
|
||||||
|
SUPER_OAUTH_URL=http://localhost:3000 # local — https://superoauth.tetardtek.com en prod
|
||||||
|
SUPER_OAUTH_JWT_SECRET=<secret partagé> # même secret que SuperOAuth
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow :**
|
||||||
|
```
|
||||||
|
Joueur clique "Se connecter"
|
||||||
|
→ Frontend redirige vers SuperOAuth (/auth/twitch ou /auth/discord)
|
||||||
|
→ SuperOAuth gère l'OAuth provider
|
||||||
|
→ SuperOAuth émet un JWT signé avec SUPER_OAUTH_JWT_SECRET
|
||||||
|
→ TetaRdPG backend reçoit le JWT → valide la signature → stocke en httpOnly cookie
|
||||||
|
→ Toutes les routes protégées : AuthGuard vérifie le cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endpoints auth à implémenter :**
|
||||||
|
```
|
||||||
|
POST /api/auth/session → reçoit JWT de SuperOAuth → valide → set cookie httpOnly
|
||||||
|
GET /api/auth/me → lit cookie → retourne profil user
|
||||||
|
POST /api/auth/logout → clear cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
**AuthGuard NestJS :**
|
||||||
|
- Lit le cookie `session`
|
||||||
|
- Vérifie la signature JWT avec `SUPER_OAUTH_JWT_SECRET`
|
||||||
|
- Injecte le user dans le request context
|
||||||
|
- Retourne 401 si invalide ou absent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schéma DB
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
||||||
|
oauth_id VARCHAR(255) UNIQUE NOT NULL -- ID chez le provider (Twitch ID, Discord ID)
|
||||||
|
provider VARCHAR(50) NOT NULL -- 'twitch' | 'discord' | 'google' | 'github'
|
||||||
|
username VARCHAR(255) NOT NULL
|
||||||
|
avatar_url VARCHAR(500)
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `characters`
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id)
|
||||||
|
name VARCHAR(100) NOT NULL
|
||||||
|
level INTEGER DEFAULT 1
|
||||||
|
xp INTEGER DEFAULT 0
|
||||||
|
gold INTEGER DEFAULT 0
|
||||||
|
|
||||||
|
-- Stats (cap : 101)
|
||||||
|
force INTEGER DEFAULT 1
|
||||||
|
agilite INTEGER DEFAULT 1
|
||||||
|
intelligence INTEGER DEFAULT 1
|
||||||
|
chance INTEGER DEFAULT 1
|
||||||
|
vitalite INTEGER DEFAULT 1
|
||||||
|
hp_current INTEGER DEFAULT 100
|
||||||
|
hp_max INTEGER DEFAULT 100
|
||||||
|
|
||||||
|
-- Endurance (lazy calculation)
|
||||||
|
endurance_saved INTEGER DEFAULT 100
|
||||||
|
last_endurance_ts TIMESTAMP DEFAULT NOW()
|
||||||
|
endurance_max INTEGER DEFAULT 100 -- 150 avec équipement (v2)
|
||||||
|
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `level_thresholds` (seed — immuable)
|
||||||
|
```sql
|
||||||
|
level INTEGER PRIMARY KEY -- 1 à 100
|
||||||
|
xp_required INTEGER -- 100 × level^1.5
|
||||||
|
```
|
||||||
|
> Précalculé au seed — jamais recalculé à la requête.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endurance — Pattern lazy calculation
|
||||||
|
|
||||||
|
**Règle absolue : pas de timer par joueur.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// À chaque lecture de l'endurance :
|
||||||
|
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||||
|
const recharge = Math.floor(elapsedMinutes / 6); // 10 pts/heure = 1 pt / 6 min
|
||||||
|
const enduranceCurrent = Math.min(
|
||||||
|
character.enduranceSaved + recharge,
|
||||||
|
character.enduranceMax
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lors d'une action (ex: combat) :
|
||||||
|
// 1. Calculer l'endurance actuelle (ci-dessus)
|
||||||
|
// 2. Vérifier que enduranceCurrent >= coût de l'action
|
||||||
|
// 3. Stocker : endurance_saved = enduranceCurrent - coût, last_endurance_ts = NOW()
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ce pattern est fondamental. L'endurance n'existe en DB que comme deux colonnes.
|
||||||
|
> Tout le reste est calculé. Zéro cron job. Zéro timer. Scalable à N joueurs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API — Endpoints Sprint 1
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health → { status: 'ok', timestamp }
|
||||||
|
|
||||||
|
POST /api/auth/session → valide JWT SuperOAuth → set cookie
|
||||||
|
GET /api/auth/me → profil user connecté
|
||||||
|
POST /api/auth/logout → clear cookie
|
||||||
|
|
||||||
|
POST /api/characters → crée un personnage (5 pts stats à répartir)
|
||||||
|
GET /api/characters/me → personnage du user connecté + endurance calculée
|
||||||
|
GET /api/characters/me/endurance → endurance actuelle calculée lazy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Config production-ready (pattern OriginsDigital)
|
||||||
|
|
||||||
|
Ces éléments sont **obligatoires dès le Sprint 1** — pas des ajouts post-lancement.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// main.ts
|
||||||
|
app.set('trust proxy', 1); // VPS derrière Apache / reverse proxy
|
||||||
|
|
||||||
|
// CORS — depuis l'env, multi-origin supporté
|
||||||
|
const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:5173')
|
||||||
|
.split(',')
|
||||||
|
.map(o => o.trim());
|
||||||
|
|
||||||
|
// Cookies httpOnly pour le JWT
|
||||||
|
// Rate limiting sur /api/auth/*
|
||||||
|
// Logging structuré (Pino ou Winston)
|
||||||
|
// Helmet pour les headers de sécurité
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variables d'environnement — `.env.example` :**
|
||||||
|
```env
|
||||||
|
PORT=4000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://tetardpg:password@localhost:5432/tetardpg
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
SUPER_OAUTH_URL=http://localhost:3000
|
||||||
|
SUPER_OAUTH_JWT_SECRET=
|
||||||
|
|
||||||
|
COOKIE_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Compose local
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: tetardpg
|
||||||
|
POSTGRES_USER: tetardpg
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
> Le backend tourne hors Docker en local (hot reload). Seuls PostgreSQL et Redis sont conteneurisés.
|
||||||
|
> En prod VPS : tout dans Docker (backend inclus) — Dockerfile à préparer dès Sprint 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure NestJS cible
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.ts → bootstrap, trust proxy, CORS, helmet
|
||||||
|
├── app.module.ts
|
||||||
|
├── auth/
|
||||||
|
│ ├── auth.module.ts
|
||||||
|
│ ├── auth.controller.ts → /api/auth/*
|
||||||
|
│ ├── auth.service.ts → valide JWT SuperOAuth, gère session
|
||||||
|
│ └── guards/
|
||||||
|
│ └── auth.guard.ts → vérifie cookie sur routes protégées
|
||||||
|
├── character/
|
||||||
|
│ ├── character.module.ts
|
||||||
|
│ ├── character.controller.ts → /api/characters/*
|
||||||
|
│ ├── character.service.ts
|
||||||
|
│ └── entities/
|
||||||
|
│ ├── character.entity.ts
|
||||||
|
│ └── level-threshold.entity.ts
|
||||||
|
├── user/
|
||||||
|
│ ├── user.module.ts
|
||||||
|
│ └── user.entity.ts
|
||||||
|
└── common/
|
||||||
|
├── health.controller.ts → /api/health
|
||||||
|
└── logger/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chaîne d'agents — Sprint 1
|
||||||
|
|
||||||
|
```
|
||||||
|
tech-lead → gate d'entrée (valide l'approche, contention map)
|
||||||
|
↓
|
||||||
|
migration → schema DB + seed level_thresholds (AVANT tout build)
|
||||||
|
↓
|
||||||
|
build ×3 → [auth module] [character module] [docker-compose + config]
|
||||||
|
↓
|
||||||
|
security → validation JWT handling, httpOnly, CORS, rate limiting
|
||||||
|
↓
|
||||||
|
integrator → critères de validation (voir ci-dessous)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critères de validation integrator :**
|
||||||
|
- [ ] `docker-compose up` → PostgreSQL + Redis up
|
||||||
|
- [ ] `npm run start:dev` → backend démarre sans erreur
|
||||||
|
- [ ] `GET /api/health` → 200 `{ status: 'ok' }`
|
||||||
|
- [ ] Auth flow SuperOAuth → cookie httpOnly posé
|
||||||
|
- [ ] `GET /api/auth/me` → profil user retourné
|
||||||
|
- [ ] `POST /api/characters` → personnage créé en DB
|
||||||
|
- [ ] `GET /api/characters/me` → endurance calculée correctement
|
||||||
|
- [ ] Requête sans cookie → 401
|
||||||
|
- [ ] `.env.example` complet
|
||||||
|
- [ ] `Dockerfile` présent (non testé en prod — validé au Sprint 2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Coach — lecture obligatoire avant de démarrer
|
||||||
|
|
||||||
|
Ce sprint est le premier code réel de TetaRdPG. C'est aussi le premier test de la chaîne sur du vrai code.
|
||||||
|
|
||||||
|
**Ce qui va bien se passer :** la structure est claire, les patterns sont connus (SuperOAuth + OriginsDigital), les entités sont définies.
|
||||||
|
|
||||||
|
**Ce qui va être l'enjeu réel :**
|
||||||
|
- Le pattern lazy endurance — ne pas le simplifier en timer "parce que c'est plus simple". C'est le coeur du système idle.
|
||||||
|
- La séparation auth user / character — un user peut ne pas avoir de personnage. Gérer ce cas dès le départ.
|
||||||
|
- `trust proxy: 1` — si oublié, les rate limiters et les IP logs seront faux dès le VPS.
|
||||||
|
|
||||||
|
**Signal de graduation à surveiller :**
|
||||||
|
Si le pattern lazy calculation est implémenté correctement sans intervention du coach → le concept de "calcul à la demande vs état persisté en continu" est acquis. C'est une compétence backend avancée.
|
||||||
|
|
||||||
|
**Objectif pédagogique du sprint :**
|
||||||
|
Produire un backend NestJS de qualité professionnelle — structure de modules, séparation des responsabilités, config 12-factor (env vars), sécurité dès le départ. Pas juste "ça marche en local".
|
||||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: tetardpg
|
||||||
|
POSTGRES_USER: tetardpg
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U tetardpg"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
5561
package-lock.json
generated
Normal file
5561
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "tetardpg-backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TetaRdPG backend — Auth + Characters + Endurance",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
|
||||||
|
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/config": "^3.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/jwt": "^10.0.0",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/throttler": "^5.0.0",
|
||||||
|
"@nestjs/typeorm": "^10.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"helmet": "^7.0.0",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"reflect-metadata": "^0.2.0",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"typeorm": "^0.3.20"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/schematics": "^10.0.0",
|
||||||
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.6",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app.module.ts
Normal file
37
src/app.module.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { CharacterModule } from './character/character.module';
|
||||||
|
import { HealthController } from './common/health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
url: config.get<string>('DATABASE_URL'),
|
||||||
|
autoLoadEntities: true,
|
||||||
|
synchronize: config.get('NODE_ENV') !== 'production',
|
||||||
|
logging: config.get('NODE_ENV') === 'development',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60_000,
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
|
||||||
|
AuthModule,
|
||||||
|
CharacterModule,
|
||||||
|
],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
44
src/auth/auth.controller.ts
Normal file
44
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import { Response, Request } from 'express';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthGuard } from './guards/auth.guard';
|
||||||
|
import { SetSessionDto } from './dto/set-session.dto';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('session')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 10 } })
|
||||||
|
async setSession(
|
||||||
|
@Body() dto: SetSessionDto,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
return this.authService.setSession(dto, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
async getMe(@Req() req: Request & { user: User }) {
|
||||||
|
return this.authService.getMe(req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
logout(@Res({ passthrough: true }) res: Response) {
|
||||||
|
this.authService.logout(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/auth/auth.module.ts
Normal file
25
src/auth/auth.module.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { AuthGuard } from './guards/auth.guard';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([User]),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get<string>('SUPER_OAUTH_JWT_SECRET'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, AuthGuard],
|
||||||
|
exports: [AuthGuard, TypeOrmModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
89
src/auth/auth.service.ts
Normal file
89
src/auth/auth.service.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
import { SetSessionDto } from './dto/set-session.dto';
|
||||||
|
|
||||||
|
// Payload émis par SuperOAuth
|
||||||
|
interface SuperOAuthPayload {
|
||||||
|
sub: string; // ID provider (Twitch ID, Discord ID…)
|
||||||
|
provider: string; // 'twitch' | 'discord' | 'google' | 'github'
|
||||||
|
username: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'oauthId'>> {
|
||||||
|
let payload: SuperOAuthPayload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = await this.jwtService.verifyAsync<SuperOAuthPayload>(dto.jwt, {
|
||||||
|
secret: this.configService.get<string>('SUPER_OAUTH_JWT_SECRET'),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('JWT SuperOAuth invalide ou expiré');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.sub || !payload.provider || !payload.username) {
|
||||||
|
throw new UnauthorizedException('Payload JWT incomplet');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert user
|
||||||
|
let user = await this.userRepository.findOne({
|
||||||
|
where: { oauthId: payload.sub, provider: payload.provider },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = this.userRepository.create({
|
||||||
|
oauthId: payload.sub,
|
||||||
|
provider: payload.provider,
|
||||||
|
username: payload.username,
|
||||||
|
avatarUrl: payload.avatar_url ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
user.username = payload.username;
|
||||||
|
user.avatarUrl = payload.avatar_url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
// Cookie httpOnly signé — valeur = UUID interne
|
||||||
|
const isProduction = this.configService.get('NODE_ENV') === 'production';
|
||||||
|
res.cookie('session', user.id, {
|
||||||
|
httpOnly: true,
|
||||||
|
signed: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
|
||||||
|
});
|
||||||
|
|
||||||
|
const { oauthId: _, ...safeUser } = user;
|
||||||
|
return safeUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe(user: User): Promise<Omit<User, 'oauthId'>> {
|
||||||
|
const { oauthId: _, ...safeUser } = user;
|
||||||
|
return safeUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(res: Response): void {
|
||||||
|
res.clearCookie('session');
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/auth/dto/set-session.dto.ts
Normal file
7
src/auth/dto/set-session.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetSessionDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
jwt: string;
|
||||||
|
}
|
||||||
41
src/auth/guards/auth.guard.ts
Normal file
41
src/auth/guards/auth.guard.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { User } from '../../user/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const userId: string | false = request.signedCookies?.session;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException('Session manquante ou invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation UUID basique avant la requête DB
|
||||||
|
const uuidRegex =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
if (!uuidRegex.test(userId)) {
|
||||||
|
throw new UnauthorizedException('Session invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('Utilisateur introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
request.user = user;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/character/character.controller.ts
Normal file
40
src/character/character.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { CharacterService } from './character.service';
|
||||||
|
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||||
|
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
@Controller('characters')
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class CharacterController {
|
||||||
|
constructor(private readonly characterService: CharacterService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateCharacterDto,
|
||||||
|
@Req() req: Request & { user: User },
|
||||||
|
) {
|
||||||
|
return this.characterService.create(dto, req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
findMe(@Req() req: Request & { user: User }) {
|
||||||
|
return this.characterService.findByUser(req.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me/endurance')
|
||||||
|
getEndurance(@Req() req: Request & { user: User }) {
|
||||||
|
return this.characterService.getEndurance(req.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/character/character.module.ts
Normal file
17
src/character/character.module.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Character } from './entities/character.entity';
|
||||||
|
import { LevelThreshold } from './entities/level-threshold.entity';
|
||||||
|
import { CharacterController } from './character.controller';
|
||||||
|
import { CharacterService } from './character.service';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Character, LevelThreshold]),
|
||||||
|
AuthModule, // pour AuthGuard + User repository
|
||||||
|
],
|
||||||
|
controllers: [CharacterController],
|
||||||
|
providers: [CharacterService],
|
||||||
|
})
|
||||||
|
export class CharacterModule {}
|
||||||
97
src/character/character.service.ts
Normal file
97
src/character/character.service.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Character } from './entities/character.entity';
|
||||||
|
import { LevelThreshold } from './entities/level-threshold.entity';
|
||||||
|
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
|
||||||
|
const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer
|
||||||
|
const ENDURANCE_REGEN_MINUTES = 6; // 1 pt d'endurance toutes les 6 min = 10 pts/heure
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CharacterService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Character)
|
||||||
|
private readonly characterRepository: Repository<Character>,
|
||||||
|
@InjectRepository(LevelThreshold)
|
||||||
|
private readonly levelThresholdRepository: Repository<LevelThreshold>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Pattern lazy calculation — pas de timer actif
|
||||||
|
private calculateEndurance(character: Character): number {
|
||||||
|
const elapsedMinutes =
|
||||||
|
(Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
|
||||||
|
const recharge = Math.floor(elapsedMinutes / ENDURANCE_REGEN_MINUTES);
|
||||||
|
return Math.min(character.enduranceSaved + recharge, character.enduranceMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCharacterDto, user: User): Promise<Character & { enduranceCurrent: number }> {
|
||||||
|
const totalStats =
|
||||||
|
dto.force + dto.agilite + dto.intelligence + dto.chance + dto.vitalite;
|
||||||
|
|
||||||
|
if (totalStats !== STAT_POOL) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`La somme des stats doit être égale à ${STAT_POOL} (reçu : ${totalStats})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.characterRepository.findOne({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('Ce joueur possède déjà un personnage');
|
||||||
|
}
|
||||||
|
|
||||||
|
const character = this.characterRepository.create({
|
||||||
|
userId: user.id,
|
||||||
|
name: dto.name,
|
||||||
|
force: dto.force,
|
||||||
|
agilite: dto.agilite,
|
||||||
|
intelligence: dto.intelligence,
|
||||||
|
chance: dto.chance,
|
||||||
|
vitalite: dto.vitalite,
|
||||||
|
enduranceSaved: 100,
|
||||||
|
lastEnduranceTs: new Date(),
|
||||||
|
enduranceMax: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.characterRepository.save(character);
|
||||||
|
return { ...saved, enduranceCurrent: this.calculateEndurance(saved) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUser(user: User): Promise<Character & { enduranceCurrent: number }> {
|
||||||
|
const character = await this.characterRepository.findOne({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!character) {
|
||||||
|
throw new NotFoundException('Aucun personnage trouvé pour ce joueur');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...character, enduranceCurrent: this.calculateEndurance(character) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEndurance(
|
||||||
|
user: User,
|
||||||
|
): Promise<{ enduranceCurrent: number; enduranceMax: number; rechargeRatePerHour: number }> {
|
||||||
|
const character = await this.characterRepository.findOne({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!character) {
|
||||||
|
throw new NotFoundException('Aucun personnage trouvé pour ce joueur');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enduranceCurrent: this.calculateEndurance(character),
|
||||||
|
enduranceMax: character.enduranceMax,
|
||||||
|
rechargeRatePerHour: 60 / ENDURANCE_REGEN_MINUTES,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/character/dto/create-character.dto.ts
Normal file
35
src/character/dto/create-character.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { IsInt, IsString, Length, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
|
// 5 points de stats à répartir — chaque stat démarre à 1
|
||||||
|
// Contrainte : force + agilite + intelligence + chance + vitalite = 10 (5 base + 5 extra)
|
||||||
|
// Validé dans le service
|
||||||
|
export class CreateCharacterDto {
|
||||||
|
@IsString()
|
||||||
|
@Length(2, 100)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(6)
|
||||||
|
force: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(6)
|
||||||
|
agilite: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(6)
|
||||||
|
intelligence: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(6)
|
||||||
|
chance: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(6)
|
||||||
|
vitalite: number;
|
||||||
|
}
|
||||||
75
src/character/entities/character.entity.ts
Normal file
75
src/character/entities/character.entity.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from '../../user/user.entity';
|
||||||
|
|
||||||
|
@Entity('characters')
|
||||||
|
@Unique(['userId'])
|
||||||
|
export class Character {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ default: 1 })
|
||||||
|
level: number;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
xp: number;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
gold: number;
|
||||||
|
|
||||||
|
// Stats (cap : 101)
|
||||||
|
@Column({ default: 1 })
|
||||||
|
force: number;
|
||||||
|
|
||||||
|
@Column({ default: 1 })
|
||||||
|
agilite: number;
|
||||||
|
|
||||||
|
@Column({ default: 1 })
|
||||||
|
intelligence: number;
|
||||||
|
|
||||||
|
@Column({ default: 1 })
|
||||||
|
chance: number;
|
||||||
|
|
||||||
|
@Column({ default: 1 })
|
||||||
|
vitalite: number;
|
||||||
|
|
||||||
|
@Column({ name: 'hp_current', default: 100 })
|
||||||
|
hpCurrent: number;
|
||||||
|
|
||||||
|
@Column({ name: 'hp_max', default: 100 })
|
||||||
|
hpMax: number;
|
||||||
|
|
||||||
|
// Endurance — lazy calculation (pas de timer actif)
|
||||||
|
@Column({ name: 'endurance_saved', default: 100 })
|
||||||
|
enduranceSaved: number;
|
||||||
|
|
||||||
|
@Column({ name: 'last_endurance_ts', type: 'timestamp', default: () => 'NOW()' })
|
||||||
|
lastEnduranceTs: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'endurance_max', default: 100 })
|
||||||
|
enduranceMax: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
10
src/character/entities/level-threshold.entity.ts
Normal file
10
src/character/entities/level-threshold.entity.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Entity, PrimaryColumn, Column } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('level_thresholds')
|
||||||
|
export class LevelThreshold {
|
||||||
|
@PrimaryColumn()
|
||||||
|
level: number;
|
||||||
|
|
||||||
|
@Column({ name: 'xp_required' })
|
||||||
|
xpRequired: number;
|
||||||
|
}
|
||||||
9
src/common/health.controller.ts
Normal file
9
src/common/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
check() {
|
||||||
|
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/database/data-source.ts
Normal file
14
src/database/data-source.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
|
import { Character } from '../character/entities/character.entity';
|
||||||
|
import { LevelThreshold } from '../character/entities/level-threshold.entity';
|
||||||
|
|
||||||
|
// DataSource pour le CLI TypeORM (migrations manuelles)
|
||||||
|
export const AppDataSource = new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg',
|
||||||
|
entities: [User, Character, LevelThreshold],
|
||||||
|
migrations: ['src/database/migrations/*.ts'],
|
||||||
|
synchronize: false,
|
||||||
|
});
|
||||||
46
src/database/seed.ts
Normal file
46
src/database/seed.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { LevelThreshold } from '../character/entities/level-threshold.entity';
|
||||||
|
|
||||||
|
const dataSource = new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg',
|
||||||
|
entities: [LevelThreshold],
|
||||||
|
synchronize: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
await dataSource.initialize();
|
||||||
|
console.log('DB connectée');
|
||||||
|
|
||||||
|
const repo = dataSource.getRepository(LevelThreshold);
|
||||||
|
const existing = await repo.count();
|
||||||
|
|
||||||
|
if (existing >= 100) {
|
||||||
|
console.log('Level thresholds déjà seedés — skip');
|
||||||
|
await dataSource.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// XP requis = 100 × level^1.5
|
||||||
|
// Level 1 : 100 XP
|
||||||
|
// Level 10 : 3162 XP
|
||||||
|
// Level 100: 1 000 000 XP
|
||||||
|
const thresholds: LevelThreshold[] = Array.from({ length: 100 }, (_, i) => {
|
||||||
|
const level = i + 1;
|
||||||
|
const threshold = new LevelThreshold();
|
||||||
|
threshold.level = level;
|
||||||
|
threshold.xpRequired = Math.round(100 * Math.pow(level, 1.5));
|
||||||
|
return threshold;
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(thresholds);
|
||||||
|
console.log('✅ 100 level_thresholds seedés');
|
||||||
|
|
||||||
|
await dataSource.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch((err) => {
|
||||||
|
console.error('Seed échoué :', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
49
src/main.ts
Normal file
49
src/main.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import * as cookieParser from 'cookie-parser';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
|
// VPS derrière Apache / reverse proxy — obligatoire pour rate limiter + IP logs corrects
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// Cookie parser avec signature
|
||||||
|
const cookieSecret = process.env.COOKIE_SECRET;
|
||||||
|
if (!cookieSecret) throw new Error('COOKIE_SECRET manquant');
|
||||||
|
app.use(cookieParser(cookieSecret));
|
||||||
|
|
||||||
|
// CORS — multi-origin depuis l'env
|
||||||
|
const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:5173')
|
||||||
|
.split(',')
|
||||||
|
.map((o) => o.trim());
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: allowedOrigins,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation globale
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prefix global
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
|
const port = process.env.PORT ?? 4000;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`TetaRdPG backend démarré sur le port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
33
src/user/user.entity.ts
Normal file
33
src/user/user.entity.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Unique,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
@Unique(['oauthId', 'provider'])
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'oauth_id', length: 255 })
|
||||||
|
oauthId: string;
|
||||||
|
|
||||||
|
@Column({ length: 50 })
|
||||||
|
provider: string;
|
||||||
|
|
||||||
|
@Column({ length: 255 })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column({ name: 'avatar_url', type: 'varchar', length: 500, nullable: true })
|
||||||
|
avatarUrl: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
9
src/user/user.module.ts
Normal file
9
src/user/user.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { User } from './user.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
|
exports: [TypeOrmModule],
|
||||||
|
})
|
||||||
|
export class UserModule {}
|
||||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user