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:
2026-03-15 05:51:02 +01:00
commit da3237bf3f
29 changed files with 7249 additions and 0 deletions

18
.env.example Normal file
View 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
View 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
View 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
View 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. 14 | Stack validé, wireframes, systèmes formalisés |
| Sprint 1 — Auth + Backend | Sem. 56 | Auth Twitch, création personnage, API core |
| Sprint 2 — Combat + UI | Sem. 78 | Combat PvE, interface joueur |
| Sprint 3 — Artisanat | Sem. 910 | Forge, craft, recettes |
| Sprint 4 — Succès + HoF | Sem. 1112 | 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View 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
View 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 {}

View 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
View 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
View 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');
}
}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class SetSessionDto {
@IsString()
@IsNotEmpty()
jwt: string;
}

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

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

View 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 {}

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

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

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

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

View 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() };
}
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View 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
}
}