Compare commits

...

3 Commits

46 changed files with 5673 additions and 3 deletions

View File

@@ -16,3 +16,7 @@ SUPER_OAUTH_JWT_SECRET=
# Cookie signing
COOKIE_SECRET=
# Twitch EventSub webhook
TWITCH_WEBHOOK_SECRET=<secret EventSub>
TWITCH_CLIENT_ID=<app client id>

244
SPRINT3.md Normal file
View File

@@ -0,0 +1,244 @@
# TetaRdPG — Brief Sprint 3
> Statut : 🔄 En cours
> Objectif : Items + Inventaire + Artisanat (Craft) + Forge
> Stack : NestJS · PostgreSQL · TypeORM (synchronize dev)
> Prérequis : Sprint 2 livré ✅
---
## Scope Sprint 3
### ✅ In scope
- Entité `items` (armes + armures) avec bonus stats
- Inventaire joueur (`character_items`) — possession + équipement actif
- Intégration combat : `player.attack` et `player.defense` depuis l'équipement équipé
- Entité `materials` + inventaire joueur (`character_materials`) — loot post-combat
- Entité `recipes` + ingrédients en jsonb
- Artisanat (`craft_jobs`) — lazy calc timer (même pattern que endurance)
- Forge — amélioration item niveau 15 avec risque croissant
- Seeds : 5 items de base, 5 matériaux, 3 recettes
- API : voir section dédiée
### ❌ Out of scope
- Boutique (achat/vente) — Sprint 4
- Bonus de sets d'équipement — Sprint 4
- TetardCoin (accélération craft, forge garantie) — sprint monétisation
- Twitch, PvP, guildes
- Frontend React
---
## Décisions de design (game-designer)
| Décision | Valeur | Justification |
|----------|--------|---------------|
| Slots équipement | weapon + armor | Simplifié Sprint 3 — casque/bottes Sprint 4 |
| Player.attack en combat | `char.weapon?.attackBonus ?? 0` | Remplace attack=0 Sprint 2 |
| Player.defense en combat | `char.armor?.defenseBonus ?? 0` | Remplace defense=0 Sprint 2 |
| Loot drop | 40% de chance après victoire | 1 matériau aléatoire parmi ceux du monstre |
| Craft timer | lazy calc : `startedAt + durationMs` | Même pattern endurance — zéro job schedulé |
| Forge risque | Niv.12 : 0% | 3 : 20% | 4 : 30% | 5 : 40% | GDD exact |
| Forge succès garanti | 12 TetardCoin (non implémenté Sprint 3) | Placeholder, champ `forcedSuccess` |
| Forge échec | item inchangé, pas de coût matériaux Sprint 3 | coût matériaux forge = Sprint 4 |
| Durée craft | court: 15s (dev) / long: 60s (dev) | Valeurs réelles en prod : 15min2h |
---
## Schéma DB
### `items`
```
id uuid PK
name varchar(100)
description text
type varchar(20) -- 'weapon' | 'armor'
rarity varchar(20) -- 'common' | 'rare' | 'epic' | 'legendary'
attack_bonus int default 0
defense_bonus int default 0
force_bonus int default 0
agilite_bonus int default 0
intelligence_bonus int default 0
chance_bonus int default 0
vitalite_bonus int default 0
```
### `character_items`
```
id uuid PK
character_id uuid FK characters
item_id uuid FK items
forge_level int default 0 -- 0 = non forgé
equipped boolean default false
acquired_at timestamp
```
### `materials`
```
id uuid PK
name varchar(100)
description text
rarity varchar(20)
```
### `character_materials`
```
id uuid PK
character_id uuid FK characters
material_id uuid FK materials
quantity int default 0
```
### `recipes`
```
id uuid PK
name varchar(100)
result_item_id uuid FK items
craft_duration_seconds int
endurance_cost int
ingredients jsonb -- [{ materialId, quantity }]
```
### `craft_jobs`
```
id uuid PK
character_id uuid FK characters
recipe_id uuid FK recipes
started_at timestamp
completed_at timestamp -- lazy : startedAt + duration
collected boolean default false
```
---
## Seeds
### Items (5)
| Nom | Type | Rareté | Attack | Defense | Notes |
|-----|------|--------|--------|---------|-------|
| Bâton de Roseau | weapon | common | 3 | 0 | Arme de départ |
| Dague Rouillée | weapon | common | 5 | 0 | |
| Épée Courte | weapon | rare | 9 | 0 | |
| Gilet de Cuir | armor | common | 0 | 3 | |
| Cotte de Mailles | armor | rare | 0 | 7 | |
### Matériaux (5)
| Nom | Rareté | Sources |
|-----|--------|---------|
| Bave de Têtard | common | Têtard Vase |
| Écailles de Grenouille | common | Grenouille Boueuse |
| Venin de Serpent | rare | Serpent des Marais |
| Spores Vénéneuses | rare | Champi Vénéneux |
| Fragment de Boue | common | Golem de Boue |
### Recettes (3)
| Nom | Résultat | Durée (dev) | Endurance | Ingrédients |
|-----|----------|-------------|-----------|-------------|
| Forge Bâton Renforcé | Bâton de Roseau+? | 15s | 5 | 2× Bave de Têtard |
| Craft Dague | Dague Rouillée | 15s | 8 | 3× Bave + 1× Écaille |
| Craft Gilet de Cuir | Gilet de Cuir | 30s | 10 | 3× Écaille + 2× Fragment |
---
## API Sprint 3
```
# Items
GET /api/items → liste tous les items (catalogue)
GET /api/items/inventory → inventaire du personnage connecté
POST /api/items/equip/:itemId → équiper un item (character_items.id)
POST /api/items/unequip/:slot → déséquiper un slot (weapon|armor)
# Materials
GET /api/materials → catalogue matériaux
GET /api/materials/inventory → matériaux du personnage connecté
# Craft
GET /api/craft/recipes → liste recettes
POST /api/craft/start → { recipeId } → lance le craft
GET /api/craft/active → craft en cours (lazy status)
POST /api/craft/collect/:jobId → collecter si terminé
# Forge
POST /api/forge/upgrade → { characterItemId } → tente amélioration
```
---
## Architecture modules
```
src/
├── item/
│ ├── item.entity.ts
│ ├── character-item.entity.ts
│ ├── item.module.ts
│ ├── item.service.ts
│ └── item.controller.ts
├── material/
│ ├── material.entity.ts
│ ├── character-material.entity.ts
│ ├── material.module.ts
│ ├── material.service.ts
│ └── material.controller.ts
├── craft/
│ ├── recipe.entity.ts
│ ├── craft-job.entity.ts
│ ├── craft.module.ts
│ ├── craft.service.ts
│ └── craft.controller.ts
├── forge/
│ ├── forge.module.ts
│ ├── forge.service.ts
│ └── forge.controller.ts
└── database/
└── items-seed.ts
```
---
## Intégration combat (CombatEngine)
```typescript
// Dans CombatService.startCombat() — charger l'équipement
const weaponBonus = char.equippedWeapon?.item.attackBonus ?? 0;
const armorBonus = char.equippedArmor?.item.defenseBonus ?? 0;
const playerStats: CombatantStats = {
...
attack: weaponBonus, // était 0 Sprint 2
defense: armorBonus, // était 0 Sprint 2
};
```
Loot post-victoire :
```typescript
if (result.winner === 'player' && Math.random() < 0.4) {
// créditer 1 matériau aléatoire dans character_materials
}
```
---
## Critères de validation integrator
- [ ] `GET /api/items` → 5 items seedés
- [ ] `GET /api/materials` → 5 matériaux seedés
- [ ] `POST /api/items/equip/:id` → item équipé, character mis à jour
- [ ] Combat avec arme équipée → player.attack = weaponBonus
- [ ] Victoire combat → chance de loot matériau (40%)
- [ ] `GET /api/materials/inventory` → quantité augmentée après loot
- [ ] `GET /api/craft/recipes` → 3 recettes disponibles
- [ ] `POST /api/craft/start` → job créé, endurance déduite
- [ ] `GET /api/craft/active` → status `pending` | `ready` | `none`
- [ ] `POST /api/craft/collect/:jobId` → item ajouté à l'inventaire
- [ ] Collect avant fin → 400 "not ready yet"
- [ ] `POST /api/forge/upgrade` → niveau 1 : succès garanti
- [ ] Forge niveau 3 → 20% chance échec (matériaux déduits, forgeLvl inchangé)
- [ ] Sans cookie → 401 sur toutes les routes protégées
- [ ] Endurance insuffisante pour craft → 400

View File

@@ -0,0 +1,55 @@
## **Feuille de Route Post-Lancement (v1.1+)**
### **Objectifs Globaux**
* Étendre le contenu et les fonctionnalités du jeu pour maintenir l'engagement.
* Renforcer la communauté avec des mécaniques sociales (guildes, alliances).
* Introduire des défis avancés (zones spéciales, PvP, quêtes élite).
* Assurer un rythme régulier d'événements et de mises à jour.
### **Calendrier de Déploiement par Trimestre**
#### **T1 (Mois 1 à 3 Post-Lancement)**
* Lancement Système de Guildes (v1.1)
* Création, gestion membres, coffre partagé, bonus XP/loot.
* Premiers objectifs hebdomadaires de guilde.
* Premier GIGABOSS Rotatif
* Nouvelle mécanique, classement global, récompenses exclusives.
* Boutique de Succès (v1.1.1)
* Objets rares à acheter via titres/succès débloqués.
* Patchs bi-hebdo : correction bugs, ajustement XP, loot.
#### **T2 (Mois 4 à 6 Post-Lancement)**
* Introduction Mode PvP (v1.2)
* Duels asynchrones, système Elo, tournois mensuels.
* Personnalisation Joueur (v1.2.1)
* Skins, titres, badges visibles sur profil/site/Twitch.
* Extension Marché Communautaire
* Filtres avancés, système denchères.
* Semaine thématique Zone \+ Quêtes Exclusives.
#### **T3 (Mois 7 à 9 Post-Lancement)**
* Nouvelles Zones & Lore Avancé (v1.3)
* Temple du Prophète, Lac des Reflets.
* Quêtes mystiques, objets uniques, accès restreints.
* Amélioration Performance & Sécurité (v1.3.1)
* Optimisation serveur/API, surveillance accrue.
* Événements saisonniers (Halloween, Hiver).
#### **T4 (Mois 10 à 12 Post-Lancement)**
* Extension Alliances de Guildes (v1.4)
* Coopération inter-guildes, quêtes communes, bonus partagés.
* Nouveaux GIGABOSS Légendaires (v1.4.1)
* Mécaniques spéciales, loot rare, accès limité.
* Personnalisation Twitch Avancée
* Animations live personnalisées, interactions boosts.
### **Fréquence Mises à Jour**
* Patchs : toutes les 2 semaines (bugfix, équilibrage).
* Contenu : chaque mois (zones, quêtes, objets, événements).
* Rapport mensuel : activité joueur, feedback, ajustements.

View File

@@ -0,0 +1,230 @@
# **Game Design Document (GDD) \- TetaRdPG**
## **1\. Vue d'ensemble**
### **Titre du projet : TetaRdPG**
### **Type de jeu : RPG textuel interactif intégré à une extension Twitch, de type "idle-RPG" avec progression persistante**
### **Plateformes : Site web \+ Extension Twitch (Navigateur)**
### **Vision**
Proposer une expérience communautaire ludique où les viewers influencent le jeu via Twitch, en créant et faisant évoluer leur personnage incarné. Le jeu repose sur la stratégie, la gestion de ressources (endurance, TetardCoin) et l'engagement à long terme.
## **2\. Objectifs principaux**
* Permettre aux viewers de Twitch d'interagir avec le stream via un jeu RPG.
* Intégration des points de chaîne (TetardCoin) comme monnaie en jeu.
* Liaison des comptes Twitch et site pour un suivi persistant.
* Monétisation possible via :
* Dons de Bits (Conversion : 1 Bit \= 1 TetardCoin)
* Abonnements Prime / Lv.1-3 / Offres : bonus mensuel en TetardCoin (quantités à définir)
* Achat de TetardCoin pour recharger l'endurance ou acheter des objets spéciaux
* Fonctionnalités Twitch :
* Affichage du statut "Live" dans l'extension et sur le site
* Classements viewers (XP, Or, Succès)
* Actions spéciales via récompenses personnalisées (boost, quête, duel)
## **3\. Système de progression**
### **3.1 Création de personnage (Incarné)**
* **Endurance** : Base 100 (recharge 10/h). Max : 150 via équipement.
* **Activités** :
* Combat : 10 endurance
* Entraînement : 20/40/60 endurance
* Quête : Coût/durée variables selon la difficulté
* **Statistiques de base** (5 points à répartir) :
* Force / Agilité / Intelligence / Chance / Vitalité (100 PV de base)
* Récupération hors combat : 10% PV toutes les 15 min
* Progression : Niveaux, entraînement, consommables (cap 101/stats)
### **3.2 Niveaux**
* XP via combats/quêtes : courbe exponentielle modérée
* Niveau max : 100, puis "Niveau Beta" (progression symbolique)
* Gain : \+5 points de stats / niveau
* Déblocages :
* Niv. 5 : Quêtes Moyennes
* Niv. 10 : Forge
* Niv. 15 : Boutique avancée
* Niv. 20 : Quêtes Difficiles
* Niv. 30 : Équipements épiques
* Niv. 50 : Succès communautaires / quêtes d'élite
## **4\. Système de combat (Optimisé)**
Objectif : Créer des combats dynamiques même en tour par tour, valorisant la spécialisation et la prise de risque.
### **Système de sets d'équipement**
* Équipements communs à légendaires, liés à des sets thématiques.
* 3+ pièces d'un même set \= bonus spécial cumulatif.
**Exemples** :
* Set des Brumes (Forêt Brumeuse) : \+10% esquive, \+5% résistance poison
* Set Cristal Pur (Grottes Cristallines) : \+10% résistance magique, \+5% Intelligence
* Set Gardien Ancestral (Ruines) : \+10% PV, \+5% Force
* Set Prophétique (GIGABOSS) : \+5% toutes stats, régénération passive 1% PV/tour
### **Titres liés aux zones**
* Obtenus après X victoires/quêtes dans une zone.
* Bonus actif uniquement dans la zone.
**Exemples** :
* Maître du Marais : \+10% dégâts (Marais du Têtard)
* Chasseur de Brume : \+5% esquive (Forêt Brumeuse)
* Défenseur des Ruines : \+10% PV max (Ruines)
* Héros Prophétique : \+10% dégâts GIGABOSS, \+5% toutes stats
## **5\. Système de Succès**
### **Succès individuels**
* Catégories :
* Progression / Combat / Zones / Équipements / Économie
* Récompenses : Or, objets rares, titres honorifiques (profil)
### **Succès communautaires**
* Objectifs collectifs :
* Ex : Tuer 10 000 monstres / Collecter 1M TetardCoin / Vaincre GIGABOSS communautaire
* Récompenses : Boosts globaux (XP/loot), boutique spéciale, titres exclusifs
## **6\. Artisanat et Forge**
### **Forge d'équipement**
* Accès : Niv. 10+
* Améliorations :
* \+Stat (+1 à \+5)
* Bonus set anticipé (2 pièces)
* Effet spécial (critique, vol de vie)
* Coût : Or \+ matériaux / TetardCoin (succès garanti)
* Risque : Succès garanti (niv. 1-2), échec possible \+3 (perte)
### **Artisanat**
* Création : consommables, équipements spécifiques, améliorations
* Requiert : recettes \+ matériaux \+ temps (temps réel/endurance)
* Sources matériaux : loot, échanges joueurs, événements
Objectif : Renforcer l'autonomie, la collecte ciblée, la diversité de progression.
## **7\. Système Économique**
### **Monnaies**
* **Or** : combats/quêtes/succès ; achats standards (forge, consommables)
* **TetardCoin** : via Twitch ; recharge endurance, objets exclusifs, forge/artisanat
* **Matériaux** : loot ou artisanat ; pour forge/amélioration
### **Boutiques**
* Boutique de base (niv. 1\) : équipements communs/rares, potions simples
* Boutique avancée (niv. 15+) : équipements épiques, recettes, matériaux
* Boutique événementielle : objets thématiques, consommation limitée
* Boutique Twitch : TetardCoin uniquement, objets esthétiques, boosts XP/loot
### **Échange entre joueurs (futur)**
* Marché communautaire : vente/achat objets/matériaux
* Taxe : 5% Or
* Limites : selon niveau joueur
### **Événements économiques**
* Promotions temporaires (ex : \-20% boutique avancée)
* Objets exclusifs à durée limitée
Objectif : Créer une économie dynamique, valoriser l'engagement Twitch, stimuler l'interaction joueur.
## **8\. Événements communautaires et spéciaux**
### **Événements mondiaux**
* **Boss mondial** : GIGABOSS unique accessible à tous pendant 72h.
* Système de participation : chacun inflige des dégâts cumulés.
* Récompenses : selon contribution (or, loot, titres)
* **Semaine à thème** : focus sur une zone ou une activité.
* Bonus : XP doublée, loot spécial, quêtes exclusives
* **Chasses communautaires** : tuer X monstres spécifiques ensemble.
* Suivi en temps réel via l'extension/site.
### **Mécaniques de participation**
* Tous les joueurs peuvent participer via leurs activités normales.
* Progression visible (barres, classements).
### **Récompenses globales**
* Boost temporaire (ex : \+10% XP pour tous pendant 3 jours)
* Accès à boutique exclusive post-événement
* Objets ou titres commémoratifs (visibles sur profil)
Objectif : Renforcer la cohésion communautaire, dynamiser le jeu et valoriser les efforts collectifs.
## **9\. Système Social (Guildes et Alliances)**
### **Guildes de joueurs**
* **Création de guilde** :
* Accessible à partir du niveau 20
* Coût initial en or et TetardCoin
* Nom personnalisé, blason, description
* **Fonctionnalités** :
* Chat de guilde (site/extension)
* Coffre de guilde (partage de matériaux, or, objets)
* **Bonus passifs** :
* Niv. 1 : \+5% XP guilde
* Niv. 2 : \+10% loot guilde
* Niv. 3 : \+5% endurance max
* **Quêtes de guilde** :
* Objectifs collectifs hebdomadaires
### **Alliances entre guildes**
* Jusquà 3 guildes peuvent sallier
* Bonus événementiels partagés
* Classements inter-guildes (PvE, quêtes, contribution économique)
### **Classement social**
* Top guildes : XP cumulée, quêtes, participation aux événements
* Récompenses : titres collectifs, objets cosmétiques de guilde, accès boutique exclusive
Objectif : Favoriser la coopération, structurer la communauté et créer un sentiment dappartenance durable.
## **10\. Lore et PNJ**
### **Univers de TetaRdPG**
Un monde aquatique ancien, dominé par les cycles de vie des Têtards et régi par les mystères du Têtarastafarisme, une religion prophétique centrée autour du "Têtard Prophétique". Les aventuriers incarnés explorent des marais, forêts, ruines et cavernes à la recherche de gloire, de pouvoir, et des vérités perdues.
### **Origines :**
* Le "Marais Originel" est le point de départ de toute vie têtardienne.
* Les "GIGABOSS" sont des entités antiques, fragments d'une ancienne conscience unique divisée par le chaos.
### **Lieux majeurs :**
* **Village de LimoKroak** : point central, lieu de repos et déchange.
* **Temple du Prophète** : accessible niveau 50+, quêtes mystiques, lore avancé.
* **Lac des Reflets** : lieu dépreuves spirituelles, accès rare, objets uniques.
### **PNJ importants :**
* **Gorn, le Sage** : a vu des générations de Têtards, guide le joueur, donne quêtes clefs.
* **Sarna, lAlchimiste** : offre artisanat, recettes rares, potions avancées.
* **Brugg, le Forgeron** : améliore les armes, gère la forge, défis spéciaux.
* **Ombre-Têtard** : mystérieux guide vers les zones interdites, accès GIGABOSS.
Objectif : Offrir un monde riche et cohérent qui renforce limmersion, stimule la curiosité, et donne du sens aux quêtes et combats.

View File

@@ -0,0 +1,23 @@
### **Jalons Principaux**
| Jalons | Délais Prévus | Livrables Clés |
| ----- | ----- | ----- |
| **Pré-production** | Semaine 1 à 4 | Mécanismes validés, wireframes, choix techniques |
| **Sprint 1** (Twitch/Auth) | Semaine 5 à 6 | Auth Twitch, création perso, API de base |
| **Sprint 2** (Combat/Base UI) | Semaine 7 à 8 | Combat basique, interface utilisateur |
| **Sprint 3** (Artisanat & Forge) | Semaine 9 à 10 | Forge, craft, recettes |
| **Sprint 4** (Succès/HoF) | Semaine 11 à 12 | Succès joueurs, classements |
| **Tests Bêta Fermée** | Mois 4 | Bêta jouable, feedback intégrés |
| **Lancement v1.0** | Mois 5 | Site et extension live |
### **Rôles & Responsabilités**
| Rôle | Responsable | Tâches Principales |
| :---- | :---- | :---- |
| **Chef de Projet** | \[Nom à définir\] | Planification, suivi, validation milestones |
| **Développeur Backend** | \[Nom à définir\] | API, BDD, logique combat/endurance |
| **Développeur Frontend** | \[Nom à définir\] | UI site/extension, récup Twitch, affichages |
| **UX/UI Designer** | \[Nom à définir\] | Wireframes, UX extension/site |
| **Spécialiste Twitch** | \[Nom à définir\] | Auth, récupération data Twitch, webhooks |
| **Testeur QA** | \[Nom à définir\] | Tests fonctionnels, équilibrage, feedback utilisateur |

View File

@@ -0,0 +1,71 @@
## **Roadmap de Développement (Planning Global)**
### **Phase 1 : Pré-production (1 mois)**
* **Finalisation des mécanismes clés** : Documentation et validation des systèmes Endurance, Combat (formules), Progression (XP, niveaux), Artisanat/Forge.
* **Spécifications techniques** :
* Choix du stack : Frontend (React, Vue ?), Backend (Node.js, Django ?), BDD (MongoDB, PostgreSQL ?).
* Architecture de l'application (API REST ? WebSocket pour events Twitch ?).
* **Design de linterface utilisateur** :
* Wireframes pour extension Twitch (compacte, intuitive).
* Wireframes site web : tableau de bord joueur, classements, marché.
* **Monétisation** :
* Conversion précise Bits → TetardCoin.
* Valeurs bonus abonnements Prime/Lv1-Lv3 (ex: bonus mensuel TetardCoin).
* Intégration des options d'achat d'objets avec TetardCoin.
### **Phase 2 : Développement Initial (2-3 mois)**
* **Intégration Twitch** :
* Authentification OAuth Twitch.
* Récupération des données : points de chaîne, status live, abonnements, bits.
* **Système de création de personnage** :
* Interface de création avec répartition stats initiales.
* Gestion de lendurance : récupération passive, dépense activités.
* **Combats PvE de base** :
* Tour par tour : Attaque, Défense, Objet, Fuite.
* Calcul de dégâts, critiques, régénération.
* Interface combat avec animation simple (texte/logs).
* **Interface utilisateur** :
* Tableau de bord : stats, inventaire, endurance.
* Extension Twitch : interactions live, affichage succès.
### **Phase 3 : Contenus & Fonctionnalités avancées (2 mois)**
* **Artisanat & Forge** :
* Interface de forge, amélioration équipements.
* Création objets : recettes, matériaux, temps de craft.
* **Systèmes de Succès** :
* Suivi individuel (profil joueur).
* Suivi communautaire (barres de progression, objectifs).
* **Hall of Fame** :
* Classements mensuels, badges, récompenses.
* Affichage sur Twitch/site.
* **Marché communautaire (alpha)** :
* Vente/achat d'objets, interface simple.
* Taxation 5% Or, limitations par niveau.
* **Événements communautaires** :
* GIGABOSS mondial (72h), suivi contributions.
* Chasses collectives, semaines thématiques.
### **Phase 4 : Test et équilibrage (1 mois)**
* **Test technique** :
* Debug API, test load Twitch/site.
* Compatibilité navigateur.
* **Test communautaire (bêta fermée)** :
* Groupes pilotes (Twitch viewers).
* Feedback gameplay, bugs.
* **Équilibrage** :
* Ajustement XP, Or, TetardCoin, difficulté combats.
* Test économie de jeu (prix, récompenses).
### **Phase 5 : Lancement Public**
* **Lancement v1.0** : Extension Twitch et Site Web.
* **Suivi communautaire** :
* Patchs correctifs, ajouts contenu.
* Événements spéciaux (lancement, premières chasses).
* **Feuille de route post-lancement** :
* Modules PVP, nouvelles zones (Lore \++), fonctionnalités sociales (guildes, alliances).

View File

@@ -0,0 +1,102 @@
# **Synthèse du Projet TetaRdPG 18/03/2025**
## **1\. Vision et Objectifs**
**TetaRdPG** est un RPG textuel interactif, intégré à une extension Twitch. Le jeu propose une expérience communautaire immersive où les viewers influencent directement le gameplay via la plateforme Twitch.
* **Plateformes** : Extension Twitch \+ Site Web
* **Type de Jeu** : Idle-RPG avec progression persistante
* **Objectifs principaux** :
* Intégration complète de Twitch (Statut live, classements viewers, interactions).
* Utilisation des **TetardCoin** comme monnaie premium (obtenue via Twitch : Bits, abonnements).
* Monétisation par dons, abonnements, achats de TetardCoin.
* Expérience de progression longue, stratégie, gestion de ressources.
## **2\. Systèmes de Jeu**
### **2.1 Endurance**
* **Base** : 100 points, **max** : 150 (via équipements).
* **Recharge passive** : 10 pts/heure (1 pt/6 min).
* **Recharge active** :
* 1 TetardCoin \= \+20 endurance.
* Potions : \+25 / \+50 / \+100 endurance.
* **Coûts** :
* Combat : 10
* Entraînement : 20/40/60
* Quêtes : 15/30/50
* Fuite : 5
### **2.2 Combat (PvE)**
* **Tour par tour dynamique** : Attaque / Défense / Objet / Fuite.
* **Formules de dégâts** :
* Mêlée : Arme \+ (Force x1.5)
* Distance : Arme \+ (Agilité x1.5)
* Magique : Arme \+ (Intelligence x1.5)
* **Défense** : Réduction selon type d'armure \+ coefficient.
* **Critique** : 5% \+ (Chance x0.2%), Dégâts x1.5
* **Esquive** : 5% \+ (Chance x0.1%)
* **Fin de combat** : XP, Or, objets, \+10% PV
* **Défaite** : Retour auberge, \-50 endurance, perte d'or
### **2.3 Progression & XP**
* **XP nécessaire** : XP \= 100 x N^1.5
* **Niveau max** : 100 \+ Niveau Beta (symbolique)
* **Stats** : \+5 points par niveau à répartir (Force, Agilité, Intelligence, Chance, Vitalité)
* **Déblocages** :
* Niv 5 : Quêtes Moyennes
* Niv 10 : Forge
* Niv 15 : Boutique avancée
* Niv 20 : Quêtes Difficiles
* Niv 30 : Equipements épiques
* Niv 50 : Succès communautaires
### **2.4 Artisanat & Forge**
* **Forge** :
* Niv 1-2 : Succès garanti
* Niv 3-5 : Risque (20%-40%), perte de matériaux
* Coût max : 12 TetardCoin
* **Artisanat** :
* Temps : 15 min à 2h (accélérable via TetardCoin)
* Endurance : 5 \+ (Rareté x3)
* **Bonus de set** :
* 2 pièces : \+3% stat associée
* 3 pièces : \+10% \+ effets spéciaux
## **3\. Système Économique**
* **Monnaies** :
* **Or** : gains classiques
* **TetardCoin** : via Twitch (Bits, abonnements), achats, objets exclusifs
* **Boutiques** :
* Base (niv 1), Avancée (niv 15), Événementielle, Twitch-only
* **Marché communautaire** (futur) : taxé à 5% Or
## **4\. Succès & Hall of Fame**
* **Succès individuels** : progression, combat, zones, équipement, économie
* **Succès communautaires** : chasse, collecte TetardCoin, GIGABOSS
* **Hall of Fame Twitch** : classements mensuels, badges, titres, objets rares
* **Récompenses** : boosts, objets exclusifs, accès boutiques
## **5\. Univers & Lore**
* **Monde** : Aquatique, Têtarastafarisme, Têtard Prophétique.
* **Zones** : Marais Originel, Village Limo'Kroak, Temple du Prophète, Lac des Reflets.
* **PNJ** : Gorn (sage), Sarna (alchimiste), Brugg (forgeron), Ombre-Têtard.
* **GIGABOSS** : boss communautaires, entités anciennes.
## **6\. Points à Finaliser**
* **Monétisation** : Valeurs exactes Bits/TetardCoin, abonnements Prime et Lv.1-3.
* **PVP** : Aucune info \- clarifier si prévu ou non.
* **Stack Technique** : Technologies à préciser.
* **Lore dynamique** : Quêtes liées à la religion et aux zones.
## **7\. Synthèse Finale**
Le projet est **cohérent, complet et bien articulé**. Les systèmes sont solidement définis. Quelques ajustements et précisions permettront d'amorcer efficacement la **roadmap de développement**.

80
docs/economy-design.md Normal file
View File

@@ -0,0 +1,80 @@
# TetaRdPG — Economy Design : TetardCoin
> Sprint 3 — Step 1 output
> Date : 2026-03-17
---
## Taux de conversion
**10 Bits = 1 TetardCoin (TC)**
Justification : 100 Bits (cheer le plus courant sur Twitch) → 10 TC = 1 recharge endurance complète. Ni trop abondant (1:1 dévaluerait le TC immédiatement), ni trop rare (100:1 pénaliserait les petits cheers). Valeur implicite ~0,10 USD par TC, ancrée sur le cours Bits Twitch.
---
## Rewards Abonnés
| Tier | TC / mois |
|------|-----------|
| Prime | 30 TC |
| T1 | 50 TC |
| T2 | 120 TC |
| T3 | 350 TC |
---
## Rewards Bits — Seuils de Cheers
Base : 10 Bits = 1 TC + prime de seuil
| Seuil | TC crédité | Prime | Note |
|-------|-----------|-------|------|
| 100 Bits | 10 TC | 0 | Pas de prime — évite le split-cheering |
| 500 Bits | 55 TC | +5 TC | ~10% prime |
| 1 000 Bits | 115 TC | +15 TC | ~15% prime |
| 5 000 Bits | 575 TC | +75 TC | ~15% prime |
---
## Utilisations TC
| Usage | Coût TC | Description |
|-------|---------|-------------|
| Recharge endurance | 1 TC = +20 pts | Prime volume : 5 TC et 10 TC |
| Cosmétiques Twitch | 20 150 TC | Titres, cadres, skins limités — rotation mensuelle |
| Forge garantie | Max 12 TC | Supprime risque perte matériaux (20-40%) |
| Tickets PvP | 5 TC = +3 tickets | Plafond +10 tickets/jour |
| Artisanat accéléré | 1 TC = skip 15 min | Max 8 TC pour un craft de 2h |
---
## Sink Anti-Inflation
**Oui — sinks actifs**
Sinks primaires :
- Endurance (consommation quotidienne si actif)
- Forge garantie (usage situationnel fort)
- Cosmétiques (rotation crée FOMO)
- Artisanat accéléré (usage passif régulier)
Sink secondaire proposé :
- Création de guilde : 50 TC
- Upgrade guilde (3 niveaux) : 30 / 60 / 100 TC
---
## Différenciateur vs StreamElements / Streamlabs Points
StreamElements/Streamlabs = présence passive → points sans friction → aucune décision.
TetardCoin = engagement actif → arbitrages réels (forge vs endurance vs guilde) → économie avec tension.
**C'est la différence entre un programme de fidélité et un jeu.**
---
## Prochaines étapes → Step 2
Implémenter : entité TetardCoin (balance + historique), service de conversion Bits→TC, migrations DB, API endpoints (balance, earn, spend, history), tests invariants économiques.

3193
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,11 @@
"start:prod": "node dist/main",
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
"seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts",
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts"
"seed:items": "ts-node -r tsconfig-paths/register src/database/items-seed.ts",
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
"test": "jest",
"test:watch": "jest --watch",
"migration:generate": "typeorm-ts-node-commonjs -d src/database/data-source.ts migration:generate"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
@@ -34,9 +38,29 @@
"@nestjs/testing": "^10.0.0",
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/node": "^20.0.0",
"jest": "^29.7.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -6,6 +6,12 @@ import { AuthModule } from './auth/auth.module';
import { CharacterModule } from './character/character.module';
import { MonsterModule } from './monster/monster.module';
import { CombatModule } from './combat/combat.module';
import { ItemModule } from './item/item.module';
import { MaterialModule } from './material/material.module';
import { CraftModule } from './craft/craft.module';
import { ForgeModule } from './forge/forge.module';
import { EconomyModule } from './economy/economy.module';
import { TwitchModule } from './twitch/twitch.module';
import { HealthController } from './common/health.controller';
@Module({
@@ -35,6 +41,12 @@ import { HealthController } from './common/health.controller';
CharacterModule,
MonsterModule,
CombatModule,
ItemModule,
MaterialModule,
CraftModule,
ForgeModule,
EconomyModule,
TwitchModule,
],
controllers: [HealthController],
})

View File

@@ -6,12 +6,16 @@ import { CombatService } from './combat.service';
import { CombatController } from './combat.controller';
import { MonsterModule } from '../monster/monster.module';
import { AuthModule } from '../auth/auth.module';
import { ItemModule } from '../item/item.module';
import { MaterialModule } from '../material/material.module';
@Module({
imports: [
TypeOrmModule.forFeature([Character, CombatLog]),
MonsterModule,
AuthModule,
ItemModule,
MaterialModule,
],
controllers: [CombatController],
providers: [CombatService],

View File

@@ -7,6 +7,8 @@ import { MonsterService } from '../monster/monster.service';
import { CombatLog } from './combat-log.entity';
import { StartCombatDto } from './dto/start-combat.dto';
import { User } from '../user/user.entity';
import { ItemService } from '../item/item.service';
import { MaterialService } from '../material/material.service';
import {
resolveCombat,
applyXpGain,
@@ -27,6 +29,8 @@ export class CombatService {
@InjectRepository(CombatLog)
private readonly combatLogRepository: Repository<CombatLog>,
private readonly monsterService: MonsterService,
private readonly itemService: ItemService,
private readonly materialService: MaterialService,
) {}
async startCombat(dto: StartCombatDto, user: User) {
@@ -54,6 +58,16 @@ export class CombatService {
throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV');
}
// Charger l'équipement actif du personnage
const equipped = await this.itemService.getEquippedItems(character.id);
const FORGE_BONUS_PER_LEVEL = 2;
const weaponAttack = equipped.weapon
? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FORGE_BONUS_PER_LEVEL
: 0;
const armorDefense = equipped.armor
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
: 0;
// Construire les stats des combattants
const playerStats: CombatantStats = {
name: character.name,
@@ -63,8 +77,8 @@ export class CombatService {
agilite: character.agilite,
intelligence: character.intelligence,
chance: character.chance,
attack: 0, // pas d'arme Sprint 2
defense: 0, // pas d'armure Sprint 2
attack: weaponAttack,
defense: armorDefense,
attackType: dto.attackType,
};
@@ -118,6 +132,13 @@ export class CombatService {
character.lastEnduranceTs = new Date();
await this.characterRepository.save(character);
// Loot matériaux — 40% de chance après victoire si le monstre a un drop_material_id
let lootMaterial: { name: string; quantity: number } | null = null;
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
lootMaterial = { name: 'matériau', quantity: 1 };
}
// Persister le log
const combatLog = this.combatLogRepository.create({
characterId: character.id,
@@ -144,6 +165,10 @@ export class CombatService {
if (goldLost > 0) summaryParts.push(`${goldLost} Or perdu.`);
}
if (lootMaterial) {
summaryParts.push(`Loot : 1 matériau obtenu !`);
}
return {
winner: result.winner,
rounds: result.rounds,
@@ -155,6 +180,7 @@ export class CombatService {
levelUp: levelUpData.levelsGained > 0,
newLevel: levelUpData.newLevel,
statPointsGained: levelUpData.statPointsGained,
loot: lootMaterial,
},
character: {
level: character.level,

View File

@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Character } from '../character/entities/character.entity';
import { Recipe } from './recipe.entity';
@Entity('craft_jobs')
export class CraftJob {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'character_id' })
characterId: string;
@ManyToOne(() => Character)
@JoinColumn({ name: 'character_id' })
character: Character;
@Column({ name: 'recipe_id' })
recipeId: string;
@ManyToOne(() => Recipe, { eager: true })
@JoinColumn({ name: 'recipe_id' })
recipe: Recipe;
@CreateDateColumn({ name: 'started_at' })
startedAt: Date;
@Column({ name: 'completed_at', type: 'timestamp' })
completedAt: Date;
@Column({ default: false })
collected: boolean;
}

View File

@@ -0,0 +1,39 @@
import { Controller, Get, Post, Param, Body, UseGuards, Req } from '@nestjs/common';
import { Request } from 'express';
import { IsUUID } from 'class-validator';
import { CraftService } from './craft.service';
import { AuthGuard } from '../auth/guards/auth.guard';
import { User } from '../user/user.entity';
class StartCraftDto {
@IsUUID()
recipeId: string;
}
@Controller('craft')
export class CraftController {
constructor(private readonly craftService: CraftService) {}
@Get('recipes')
findRecipes() {
return this.craftService.findAllRecipes();
}
@Post('start')
@UseGuards(AuthGuard)
start(@Body() dto: StartCraftDto, @Req() req: Request & { user: User }) {
return this.craftService.startCraft(dto.recipeId, req.user);
}
@Get('active')
@UseGuards(AuthGuard)
getActive(@Req() req: Request & { user: User }) {
return this.craftService.getActiveJob(req.user);
}
@Post('collect/:jobId')
@UseGuards(AuthGuard)
collect(@Param('jobId') jobId: string, @Req() req: Request & { user: User }) {
return this.craftService.collectCraft(jobId, req.user);
}
}

22
src/craft/craft.module.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Recipe } from './recipe.entity';
import { CraftJob } from './craft-job.entity';
import { CraftService } from './craft.service';
import { CraftController } from './craft.controller';
import { AuthModule } from '../auth/auth.module';
import { ItemModule } from '../item/item.module';
import { MaterialModule } from '../material/material.module';
import { Character } from '../character/entities/character.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Recipe, CraftJob, Character]),
AuthModule,
ItemModule,
MaterialModule,
],
controllers: [CraftController],
providers: [CraftService],
})
export class CraftModule {}

128
src/craft/craft.service.ts Normal file
View File

@@ -0,0 +1,128 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Recipe } from './recipe.entity';
import { CraftJob } from './craft-job.entity';
import { Character } from '../character/entities/character.entity';
import { ItemService } from '../item/item.service';
import { MaterialService } from '../material/material.service';
import { User } from '../user/user.entity';
@Injectable()
export class CraftService {
constructor(
@InjectRepository(Recipe)
private readonly recipeRepository: Repository<Recipe>,
@InjectRepository(CraftJob)
private readonly craftJobRepository: Repository<CraftJob>,
@InjectRepository(Character)
private readonly characterRepository: Repository<Character>,
private readonly itemService: ItemService,
private readonly materialService: MaterialService,
) {}
findAllRecipes() {
return this.recipeRepository.find({ order: { name: 'ASC' } });
}
async startCraft(recipeId: string, user: User) {
const char = await this.getCharacter(user);
const recipe = await this.recipeRepository.findOne({ where: { id: recipeId } });
if (!recipe) throw new NotFoundException('Recette introuvable');
// Vérifier qu'aucun craft actif
const activeCraft = await this.craftJobRepository.findOne({
where: { characterId: char.id, collected: false },
});
if (activeCraft) throw new BadRequestException('Un craft est déjà en cours');
// Calculer endurance actuelle (lazy pattern)
const elapsedMinutes = (Date.now() - char.lastEnduranceTs.getTime()) / 60_000;
const recharge = Math.floor(elapsedMinutes / 6);
const enduranceCurrent = Math.min(char.enduranceSaved + recharge, char.enduranceMax);
if (enduranceCurrent < recipe.enduranceCost) {
throw new BadRequestException(
`Endurance insuffisante (${enduranceCurrent}/${recipe.enduranceCost} requis)`,
);
}
// Consommer les matériaux (vérifie la dispo et déduit)
await this.materialService.consumeMaterials(char.id, recipe.ingredients);
// Déduire l'endurance
char.enduranceSaved = enduranceCurrent - recipe.enduranceCost;
char.lastEnduranceTs = new Date();
await this.characterRepository.save(char);
// Créer le job (lazy timer)
const startedAt = new Date();
const completedAt = new Date(startedAt.getTime() + recipe.craftDurationSeconds * 1000);
const job = this.craftJobRepository.create({
characterId: char.id,
recipeId: recipe.id,
startedAt,
completedAt,
collected: false,
});
await this.craftJobRepository.save(job);
return {
jobId: job.id,
recipe: recipe.name,
startedAt,
completedAt,
remainingSeconds: recipe.craftDurationSeconds,
status: 'pending',
};
}
async getActiveJob(user: User) {
const char = await this.getCharacter(user);
const job = await this.craftJobRepository.findOne({
where: { characterId: char.id, collected: false },
});
if (!job) return { status: 'none' };
const now = new Date();
const remaining = Math.max(0, Math.floor((job.completedAt.getTime() - now.getTime()) / 1000));
return {
status: now >= job.completedAt ? 'ready' : 'pending',
jobId: job.id,
recipe: job.recipe.name,
completedAt: job.completedAt,
remainingSeconds: remaining,
};
}
async collectCraft(jobId: string, user: User) {
const char = await this.getCharacter(user);
const job = await this.craftJobRepository.findOne({
where: { id: jobId, characterId: char.id, collected: false },
});
if (!job) throw new NotFoundException('Craft introuvable');
const remaining = Math.ceil((job.completedAt.getTime() - Date.now()) / 1000);
if (remaining > 0) {
throw new BadRequestException(`Craft non terminé — encore ${remaining}s`);
}
// Ajouter l'item crafté à l'inventaire
const charItem = await this.itemService.addItemToInventory(char.id, job.recipe.resultItemId);
job.collected = true;
await this.craftJobRepository.save(job);
return {
collected: true,
item: charItem.item,
message: `${job.recipe.resultItem.name} ajouté à l'inventaire !`,
};
}
private async getCharacter(user: User): Promise<Character> {
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
if (!char) throw new BadRequestException('Aucun personnage trouvé');
return char;
}
}

View File

@@ -0,0 +1,32 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { Item } from '../item/item.entity';
export interface RecipeIngredient {
materialId: string;
quantity: number;
}
@Entity('recipes')
export class Recipe {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100 })
name: string;
@Column({ name: 'result_item_id' })
resultItemId: string;
@ManyToOne(() => Item, { eager: true })
@JoinColumn({ name: 'result_item_id' })
resultItem: Item;
@Column({ name: 'craft_duration_seconds' })
craftDurationSeconds: number;
@Column({ name: 'endurance_cost' })
enduranceCost: number;
@Column({ type: 'jsonb' })
ingredients: RecipeIngredient[];
}

144
src/database/items-seed.ts Normal file
View File

@@ -0,0 +1,144 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { Item } from '../item/item.entity';
import { Material } from '../material/material.entity';
import { Recipe } from '../craft/recipe.entity';
import { Monster } from '../monster/monster.entity';
const dataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg',
entities: [Item, Material, Recipe, Monster],
synchronize: false,
});
const ITEMS = [
{ name: 'Bâton de Roseau', description: 'Un bâton taillé dans un roseau des marais.', type: 'weapon' as const, rarity: 'common' as const, attackBonus: 3 },
{ name: 'Dague Rouillée', description: 'Une dague usée mais encore tranchante.', type: 'weapon' as const, rarity: 'common' as const, attackBonus: 5 },
{ name: 'Épée Courte', description: 'Une épée courte bien équilibrée.', type: 'weapon' as const, rarity: 'rare' as const, attackBonus: 9 },
{ name: 'Gilet de Cuir', description: 'Un gilet de cuir tanné offrant une protection basique.', type: 'armor' as const, rarity: 'common' as const, defenseBonus: 3 },
{ name: 'Cotte de Mailles', description: 'Une cotte de mailles robuste.', type: 'armor' as const, rarity: 'rare' as const, defenseBonus: 7 },
];
const MATERIALS = [
{ name: 'Bave de Têtard', description: 'Substance visqueuse sécrétée par les têtards vases.', rarity: 'common' as const },
{ name: 'Écailles de Grenouille', description: 'Écailles dures et brillantes de grenouilles boueuses.', rarity: 'common' as const },
{ name: 'Venin de Serpent', description: 'Venin concentré extrait d\'un serpent des marais.', rarity: 'rare' as const },
{ name: 'Spores Vénéneuses', description: 'Spores toxiques récoltées sur les champi vénéneux.', rarity: 'rare' as const },
{ name: 'Fragment de Boue', description: 'Éclat de boue cristallisée prélevé sur un golem.', rarity: 'common' as const },
];
// Loot mapping : monster name → material name
const MONSTER_LOOT: Record<string, string> = {
'Têtard Vase': 'Bave de Têtard',
'Grenouille Boueuse': 'Écailles de Grenouille',
'Serpent des Marais': 'Venin de Serpent',
'Champi Vénéneux': 'Spores Vénéneuses',
'Golem de Boue': 'Fragment de Boue',
};
async function seed() {
await dataSource.initialize();
console.log('DB connectée');
const itemRepo = dataSource.getRepository(Item);
const materialRepo = dataSource.getRepository(Material);
const recipeRepo = dataSource.getRepository(Recipe);
const monsterRepo = dataSource.getRepository(Monster);
// Seed matériaux
const materialMap: Record<string, string> = {};
for (const data of MATERIALS) {
let mat = await materialRepo.findOne({ where: { name: data.name } });
if (!mat) {
mat = await materialRepo.save(materialRepo.create(data));
console.log(`✅ Matériau "${data.name}" seedé`);
} else {
console.log(`⏭ Matériau "${data.name}" déjà présent`);
}
materialMap[data.name] = mat.id;
}
// Seed items
const itemMap: Record<string, string> = {};
for (const data of ITEMS) {
let item = await itemRepo.findOne({ where: { name: data.name } });
if (!item) {
item = await itemRepo.save(itemRepo.create(data));
console.log(`✅ Item "${data.name}" seedé`);
} else {
console.log(`⏭ Item "${data.name}" déjà présent`);
}
itemMap[data.name] = item.id;
}
// Seed recettes
const RECIPES = [
{
name: 'Craft Dague Rouillée',
resultItemName: 'Dague Rouillée',
craftDurationSeconds: 15,
enduranceCost: 8,
ingredients: [
{ materialId: materialMap['Bave de Têtard'], quantity: 3 },
{ materialId: materialMap['Écailles de Grenouille'], quantity: 1 },
],
},
{
name: 'Craft Gilet de Cuir',
resultItemName: 'Gilet de Cuir',
craftDurationSeconds: 30,
enduranceCost: 10,
ingredients: [
{ materialId: materialMap['Écailles de Grenouille'], quantity: 3 },
{ materialId: materialMap['Fragment de Boue'], quantity: 2 },
],
},
{
name: 'Craft Épée Courte',
resultItemName: 'Épée Courte',
craftDurationSeconds: 60,
enduranceCost: 15,
ingredients: [
{ materialId: materialMap['Venin de Serpent'], quantity: 2 },
{ materialId: materialMap['Fragment de Boue'], quantity: 3 },
{ materialId: materialMap['Écailles de Grenouille'], quantity: 2 },
],
},
];
for (const data of RECIPES) {
const exists = await recipeRepo.findOne({ where: { name: data.name } });
if (!exists) {
await recipeRepo.save(recipeRepo.create({
name: data.name,
resultItemId: itemMap[data.resultItemName],
craftDurationSeconds: data.craftDurationSeconds,
enduranceCost: data.enduranceCost,
ingredients: data.ingredients,
}));
console.log(`✅ Recette "${data.name}" seedée`);
} else {
console.log(`⏭ Recette "${data.name}" déjà présente`);
}
}
// Mise à jour monsters avec leur drop_material_id
for (const [monsterName, materialName] of Object.entries(MONSTER_LOOT)) {
const monster = await monsterRepo.findOne({ where: { name: monsterName } });
const materialId = materialMap[materialName];
if (monster && materialId && monster.dropMaterialId !== materialId) {
monster.dropMaterialId = materialId;
await monsterRepo.save(monster);
console.log(`✅ Monstre "${monsterName}" → drop "${materialName}" mis à jour`);
}
}
console.log('✅ Seed Sprint 3 terminé');
await dataSource.destroy();
}
seed().catch((err) => {
console.error('Seed Sprint 3 échoué :', err);
process.exit(1);
});

View File

@@ -0,0 +1,13 @@
import { ConversionService } from './conversion.service';
describe('ConversionService', () => {
const service = new ConversionService();
it('10 Bits = 1 TC', () => expect(service.bitsToTC(10)).toBe(1));
it('15 Bits = 1 TC (floor)', () => expect(service.bitsToTC(15)).toBe(1));
it('0 Bits = 0 TC', () => expect(service.bitsToTC(0)).toBe(0));
it('9 Bits = 0 TC', () => expect(service.bitsToTC(9)).toBe(0));
it('100 Bits = 10 TC', () => expect(service.bitsToTC(100)).toBe(10));
it('1000 Bits = 100 TC', () => expect(service.bitsToTC(1000)).toBe(100));
it('throws on negative bits', () => expect(() => service.bitsToTC(-1)).toThrow());
});

View File

@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
const BITS_PER_TC = 10;
@Injectable()
export class ConversionService {
bitsToTC(bits: number): number {
if (bits < 0) throw new Error('bits cannot be negative');
return Math.floor(bits / BITS_PER_TC);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TetardCoin } from './entities/tetard-coin.entity';
import { Transaction } from './entities/transaction.entity';
import { TetardCoinService } from './tetard-coin.service';
import { ConversionService } from './conversion.service';
@Module({
imports: [TypeOrmModule.forFeature([TetardCoin, Transaction])],
providers: [TetardCoinService, ConversionService],
exports: [TetardCoinService, ConversionService],
})
export class EconomyModule {}

View File

@@ -0,0 +1,17 @@
import { Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn, Index } from 'typeorm';
@Entity('tetard_coins')
export class TetardCoin {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'varchar', length: 255, unique: true })
userId: string;
@Column({ type: 'int', default: 0 })
balance: number;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,20 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
@Entity('tc_transactions')
export class Transaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'varchar', length: 255 })
userId: string;
@Column({ type: 'int' })
amount: number;
@Column({ type: 'varchar', length: 100 })
reason: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@@ -0,0 +1,99 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { BadRequestException } from '@nestjs/common';
import { TetardCoinService } from './tetard-coin.service';
import { TetardCoin } from './entities/tetard-coin.entity';
import { Transaction } from './entities/transaction.entity';
const makeTcRepo = () => ({
findOne: jest.fn(),
create: jest.fn((dto) => ({ ...dto })),
save: jest.fn(async (entity) => entity),
});
const makeTxRepo = () => ({
create: jest.fn((dto) => ({ ...dto })),
save: jest.fn(async (entity) => entity),
});
describe('TetardCoinService', () => {
let service: TetardCoinService;
let tcRepo: ReturnType<typeof makeTcRepo>;
let txRepo: ReturnType<typeof makeTxRepo>;
beforeEach(async () => {
tcRepo = makeTcRepo();
txRepo = makeTxRepo();
const module: TestingModule = await Test.createTestingModule({
providers: [
TetardCoinService,
{ provide: getRepositoryToken(TetardCoin), useValue: tcRepo },
{ provide: getRepositoryToken(Transaction), useValue: txRepo },
],
}).compile();
service = module.get<TetardCoinService>(TetardCoinService);
});
describe('earn()', () => {
it('augmente la balance (nouvel utilisateur)', async () => {
tcRepo.findOne.mockResolvedValue(null);
expect(await service.earn('u1', 10)).toBe(10);
});
it('augmente la balance (utilisateur existant)', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 50 });
expect(await service.earn('u1', 30)).toBe(80);
});
it('crée une entrée transaction audit', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 0 });
await service.earn('u1', 20, 'subscription-t1');
expect(txRepo.save).toHaveBeenCalledTimes(1);
expect(txRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'u1', amount: 20, reason: 'subscription-t1' }),
);
});
it('throw si amount <= 0', async () => {
await expect(service.earn('u1', 0)).rejects.toThrow(BadRequestException);
await expect(service.earn('u1', -5)).rejects.toThrow(BadRequestException);
});
});
describe('spend()', () => {
it('diminue la balance du montant correct', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 100 });
expect(await service.spend('u1', 30, 'endurance')).toBe(70);
});
it('throw si balance insuffisante (invariant ≥ 0)', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 10 });
await expect(service.spend('u1', 15, 'guild')).rejects.toThrow(BadRequestException);
});
it('throw si balance = 0', async () => {
tcRepo.findOne.mockResolvedValue(null);
await expect(service.spend('u1', 1, 'cosmetic')).rejects.toThrow(BadRequestException);
});
it('crée une transaction négative (audit)', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 50 });
await service.spend('u1', 20, 'forge');
expect(txRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ amount: -20, reason: 'forge' }),
);
});
});
describe('getBalance()', () => {
it('retourne 0 si aucun record', async () => {
tcRepo.findOne.mockResolvedValue(null);
expect(await service.getBalance('u1')).toBe(0);
});
it('retourne la balance correcte', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 42 });
expect(await service.getBalance('u1')).toBe(42);
});
});
});

View File

@@ -0,0 +1,52 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TetardCoin } from './entities/tetard-coin.entity';
import { Transaction } from './entities/transaction.entity';
@Injectable()
export class TetardCoinService {
constructor(
@InjectRepository(TetardCoin)
private readonly tcRepo: Repository<TetardCoin>,
@InjectRepository(Transaction)
private readonly txRepo: Repository<Transaction>,
) {}
async getBalance(userId: string): Promise<number> {
const record = await this.tcRepo.findOne({ where: { userId } });
return record?.balance ?? 0;
}
async earn(userId: string, amount: number, reason = 'earn'): Promise<number> {
if (amount <= 0) throw new BadRequestException('earn amount must be > 0');
let record = await this.tcRepo.findOne({ where: { userId } });
if (!record) {
record = this.tcRepo.create({ userId, balance: 0 });
}
record.balance += amount;
await this.tcRepo.save(record);
await this.txRepo.save(this.txRepo.create({ userId, amount, reason }));
return record.balance;
}
async spend(userId: string, amount: number, reason: string): Promise<number> {
if (amount <= 0) throw new BadRequestException('spend amount must be > 0');
const record = await this.tcRepo.findOne({ where: { userId } });
const current = record?.balance ?? 0;
// Invariant absolu : balance ne peut jamais être négative
if (current < amount) {
throw new BadRequestException(
`Insufficient balance: has ${current} TC, needs ${amount} TC`,
);
}
record!.balance -= amount;
await this.tcRepo.save(record!);
await this.txRepo.save(this.txRepo.create({ userId, amount: -amount, reason }));
return record!.balance;
}
}

View File

@@ -0,0 +1,16 @@
import { Controller, Post, Param, UseGuards, Req } from '@nestjs/common';
import { Request } from 'express';
import { ForgeService } from './forge.service';
import { AuthGuard } from '../auth/guards/auth.guard';
import { User } from '../user/user.entity';
@Controller('forge')
@UseGuards(AuthGuard)
export class ForgeController {
constructor(private readonly forgeService: ForgeService) {}
@Post('upgrade/:charItemId')
upgrade(@Param('charItemId') charItemId: string, @Req() req: Request & { user: User }) {
return this.forgeService.upgradeItem(charItemId, req.user);
}
}

14
src/forge/forge.module.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ForgeService } from './forge.service';
import { ForgeController } from './forge.controller';
import { AuthModule } from '../auth/auth.module';
import { ItemModule } from '../item/item.module';
import { Character } from '../character/entities/character.entity';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [TypeOrmModule.forFeature([Character]), AuthModule, ItemModule],
controllers: [ForgeController],
providers: [ForgeService],
})
export class ForgeModule {}

View File

@@ -0,0 +1,68 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CharacterItem } from '../item/character-item.entity';
import { Character } from '../character/entities/character.entity';
import { User } from '../user/user.entity';
const MAX_FORGE_LEVEL = 5;
const FORGE_BONUS_PER_LEVEL = 2; // +2 attack (weapon) ou +2 defense (armor) par niveau affiché
// Risque d'échec par niveau cible (GDD exact)
const FORGE_FAIL_CHANCE: Record<number, number> = {
1: 0,
2: 0,
3: 0.20,
4: 0.30,
5: 0.40,
};
@Injectable()
export class ForgeService {
constructor(
@InjectRepository(CharacterItem)
private readonly charItemRepository: Repository<CharacterItem>,
@InjectRepository(Character)
private readonly characterRepository: Repository<Character>,
) {}
async upgradeItem(charItemId: string, user: User) {
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
if (!char) throw new BadRequestException('Aucun personnage trouvé');
const charItem = await this.charItemRepository.findOne({
where: { id: charItemId, characterId: char.id },
});
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
if (charItem.forgeLevel >= MAX_FORGE_LEVEL) {
throw new BadRequestException(`Niveau de forge maximum atteint (${MAX_FORGE_LEVEL})`);
}
const targetLevel = charItem.forgeLevel + 1;
const failChance = FORGE_FAIL_CHANCE[targetLevel] ?? 0;
const success = Math.random() >= failChance;
if (success) {
charItem.forgeLevel = targetLevel;
await this.charItemRepository.save(charItem);
const statLabel = charItem.item.type === 'weapon'
? `+${FORGE_BONUS_PER_LEVEL} ATK`
: `+${FORGE_BONUS_PER_LEVEL} DEF`;
return {
success: true,
forgeLevel: charItem.forgeLevel,
item: charItem.item.name,
message: `Forge réussie ! ${charItem.item.name} [+${charItem.forgeLevel}] (${statLabel}).`,
};
}
return {
success: false,
forgeLevel: charItem.forgeLevel,
item: charItem.item.name,
message: `Échec de forge ! ${charItem.item.name} reste au niveau [+${charItem.forgeLevel}].`,
};
}
}

View File

@@ -0,0 +1,39 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Character } from '../character/entities/character.entity';
import { Item } from './item.entity';
@Entity('character_items')
export class CharacterItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'character_id' })
characterId: string;
@ManyToOne(() => Character)
@JoinColumn({ name: 'character_id' })
character: Character;
@Column({ name: 'item_id' })
itemId: string;
@ManyToOne(() => Item, { eager: true })
@JoinColumn({ name: 'item_id' })
item: Item;
@Column({ name: 'forge_level', default: 0 })
forgeLevel: number;
@Column({ default: false })
equipped: boolean;
@CreateDateColumn({ name: 'acquired_at' })
acquiredAt: Date;
}

View File

@@ -0,0 +1,33 @@
import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common';
import { Request } from 'express';
import { ItemService } from './item.service';
import { AuthGuard } from '../auth/guards/auth.guard';
import { User } from '../user/user.entity';
@Controller('items')
export class ItemController {
constructor(private readonly itemService: ItemService) {}
@Get()
findAll() {
return this.itemService.findAll();
}
@Get('inventory')
@UseGuards(AuthGuard)
getInventory(@Req() req: Request & { user: User }) {
return this.itemService.getInventory(req.user);
}
@Post('equip/:charItemId')
@UseGuards(AuthGuard)
equip(@Param('charItemId') charItemId: string, @Req() req: Request & { user: User }) {
return this.itemService.equip(charItemId, req.user);
}
@Post('unequip/:slot')
@UseGuards(AuthGuard)
unequip(@Param('slot') slot: 'weapon' | 'armor', @Req() req: Request & { user: User }) {
return this.itemService.unequip(slot, req.user);
}
}

43
src/item/item.entity.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
export type ItemType = 'weapon' | 'armor';
export type ItemRarity = 'common' | 'rare' | 'epic' | 'legendary';
@Entity('items')
export class Item {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'varchar', length: 20 })
type: ItemType;
@Column({ type: 'varchar', length: 20 })
rarity: ItemRarity;
@Column({ name: 'attack_bonus', default: 0 })
attackBonus: number;
@Column({ name: 'defense_bonus', default: 0 })
defenseBonus: number;
@Column({ name: 'force_bonus', default: 0 })
forceBonus: number;
@Column({ name: 'agilite_bonus', default: 0 })
agiliteBonus: number;
@Column({ name: 'intelligence_bonus', default: 0 })
intelligenceBonus: number;
@Column({ name: 'chance_bonus', default: 0 })
chanceBonus: number;
@Column({ name: 'vitalite_bonus', default: 0 })
vitaliteBonus: number;
}

16
src/item/item.module.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Item } from './item.entity';
import { CharacterItem } from './character-item.entity';
import { ItemService } from './item.service';
import { ItemController } from './item.controller';
import { AuthModule } from '../auth/auth.module';
import { Character } from '../character/entities/character.entity';
@Module({
imports: [TypeOrmModule.forFeature([Item, CharacterItem, Character]), AuthModule],
controllers: [ItemController],
providers: [ItemService],
exports: [ItemService, TypeOrmModule],
})
export class ItemModule {}

100
src/item/item.service.ts Normal file
View File

@@ -0,0 +1,100 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Item } from './item.entity';
import { CharacterItem } from './character-item.entity';
import { Character } from '../character/entities/character.entity';
import { User } from '../user/user.entity';
@Injectable()
export class ItemService {
constructor(
@InjectRepository(Item)
private readonly itemRepository: Repository<Item>,
@InjectRepository(CharacterItem)
private readonly charItemRepository: Repository<CharacterItem>,
@InjectRepository(Character)
private readonly characterRepository: Repository<Character>,
) {}
findAll() {
return this.itemRepository.find({ order: { rarity: 'ASC', name: 'ASC' } });
}
async getInventory(user: User) {
const char = await this.getCharacter(user);
return this.charItemRepository.find({
where: { characterId: char.id },
order: { acquiredAt: 'DESC' },
});
}
async equip(charItemId: string, user: User) {
const char = await this.getCharacter(user);
const charItem = await this.charItemRepository.findOne({
where: { id: charItemId, characterId: char.id },
});
if (!charItem) throw new NotFoundException('Item non trouvé dans l\'inventaire');
// Déséquiper l'item du même slot (type) si existe
const currentEquipped = await this.charItemRepository
.createQueryBuilder('ci')
.leftJoinAndSelect('ci.item', 'item')
.where('ci.characterId = :cid', { cid: char.id })
.andWhere('ci.equipped = true')
.andWhere('item.type = :type', { type: charItem.item.type })
.getOne();
if (currentEquipped) {
currentEquipped.equipped = false;
await this.charItemRepository.save(currentEquipped);
}
charItem.equipped = true;
await this.charItemRepository.save(charItem);
return { equipped: true, item: charItem };
}
async unequip(slot: 'weapon' | 'armor', user: User) {
const char = await this.getCharacter(user);
const charItem = await this.charItemRepository
.createQueryBuilder('ci')
.leftJoinAndSelect('ci.item', 'item')
.where('ci.characterId = :cid', { cid: char.id })
.andWhere('ci.equipped = true')
.andWhere('item.type = :type', { type: slot })
.getOne();
if (!charItem) throw new NotFoundException(`Aucun ${slot} équipé`);
charItem.equipped = false;
await this.charItemRepository.save(charItem);
return { unequipped: true };
}
// Utilisé par CombatService pour charger l'équipement actif
async getEquippedItems(characterId: string): Promise<{ weapon: CharacterItem | null; armor: CharacterItem | null }> {
const equipped = await this.charItemRepository
.createQueryBuilder('ci')
.leftJoinAndSelect('ci.item', 'item')
.where('ci.characterId = :cid', { cid: characterId })
.andWhere('ci.equipped = true')
.getMany();
return {
weapon: equipped.find(ci => ci.item.type === 'weapon') ?? null,
armor: equipped.find(ci => ci.item.type === 'armor') ?? null,
};
}
// Utilisé par CraftService pour ajouter un item crafté
async addItemToInventory(characterId: string, itemId: string): Promise<CharacterItem> {
const charItem = this.charItemRepository.create({ characterId, itemId, forgeLevel: 0, equipped: false });
return this.charItemRepository.save(charItem);
}
private async getCharacter(user: User): Promise<Character> {
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
if (!char) throw new BadRequestException('Aucun personnage trouvé');
return char;
}
}

View File

@@ -0,0 +1,32 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Character } from '../character/entities/character.entity';
import { Material } from './material.entity';
@Entity('character_materials')
export class CharacterMaterial {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'character_id' })
characterId: string;
@ManyToOne(() => Character)
@JoinColumn({ name: 'character_id' })
character: Character;
@Column({ name: 'material_id' })
materialId: string;
@ManyToOne(() => Material, { eager: true })
@JoinColumn({ name: 'material_id' })
material: Material;
@Column({ default: 0 })
quantity: number;
}

View File

@@ -0,0 +1,21 @@
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { Request } from 'express';
import { MaterialService } from './material.service';
import { AuthGuard } from '../auth/guards/auth.guard';
import { User } from '../user/user.entity';
@Controller('materials')
export class MaterialController {
constructor(private readonly materialService: MaterialService) {}
@Get()
findAll() {
return this.materialService.findAll();
}
@Get('inventory')
@UseGuards(AuthGuard)
getInventory(@Req() req: Request & { user: User }) {
return this.materialService.getInventory(req.user);
}
}

View File

@@ -0,0 +1,18 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
export type MaterialRarity = 'common' | 'rare' | 'epic';
@Entity('materials')
export class Material {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'varchar', length: 20 })
rarity: MaterialRarity;
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Material } from './material.entity';
import { CharacterMaterial } from './character-material.entity';
import { MaterialService } from './material.service';
import { MaterialController } from './material.controller';
import { AuthModule } from '../auth/auth.module';
import { Character } from '../character/entities/character.entity';
@Module({
imports: [TypeOrmModule.forFeature([Material, CharacterMaterial, Character]), AuthModule],
controllers: [MaterialController],
providers: [MaterialService],
exports: [MaterialService, TypeOrmModule],
})
export class MaterialModule {}

View File

@@ -0,0 +1,61 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MoreThan, Repository } from 'typeorm';
import { Material } from './material.entity';
import { CharacterMaterial } from './character-material.entity';
import { Character } from '../character/entities/character.entity';
import { User } from '../user/user.entity';
@Injectable()
export class MaterialService {
constructor(
@InjectRepository(Material)
private readonly materialRepository: Repository<Material>,
@InjectRepository(CharacterMaterial)
private readonly charMatRepository: Repository<CharacterMaterial>,
@InjectRepository(Character)
private readonly characterRepository: Repository<Character>,
) {}
findAll() {
return this.materialRepository.find({ order: { rarity: 'ASC', name: 'ASC' } });
}
async getInventory(user: User) {
const char = await this.getCharacter(user);
return this.charMatRepository.find({
where: { characterId: char.id, quantity: MoreThan(0) },
});
}
// Appelé par CombatService après victoire (loot)
async addMaterial(characterId: string, materialId: string, quantity: number): Promise<CharacterMaterial> {
let entry = await this.charMatRepository.findOne({ where: { characterId, materialId } });
if (entry) {
entry.quantity += quantity;
} else {
entry = this.charMatRepository.create({ characterId, materialId, quantity });
}
return this.charMatRepository.save(entry);
}
// Appelé par CraftService pour consommer les ingrédients
async consumeMaterials(characterId: string, ingredients: { materialId: string; quantity: number }[]): Promise<void> {
for (const ing of ingredients) {
const entry = await this.charMatRepository.findOne({
where: { characterId, materialId: ing.materialId },
});
if (!entry || entry.quantity < ing.quantity) {
throw new BadRequestException('Matériaux insuffisants pour ce craft');
}
entry.quantity -= ing.quantity;
await this.charMatRepository.save(entry);
}
}
private async getCharacter(user: User): Promise<Character> {
const char = await this.characterRepository.findOne({ where: { userId: user.id } });
if (!char) throw new BadRequestException('Aucun personnage trouvé');
return char;
}
}

View File

@@ -36,4 +36,7 @@ export class Monster {
@Column({ name: 'gold_max' })
goldMax: number;
@Column({ name: 'drop_material_id', type: 'varchar', nullable: true })
dropMaterialId: string | null;
}

View File

@@ -0,0 +1,10 @@
import { Entity, PrimaryColumn, CreateDateColumn } from 'typeorm';
@Entity('processed_events')
export class ProcessedEvent {
@PrimaryColumn({ type: 'varchar', length: 255 })
id: string;
@CreateDateColumn({ name: 'processed_at' })
processedAt: Date;
}

View File

@@ -0,0 +1,148 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { TwitchEventService } from './twitch-event.service';
import { ProcessedEvent } from './entities/processed-event.entity';
import { TetardCoinService } from '../economy/tetard-coin.service';
import { ConversionService } from '../economy/conversion.service';
const makeProcessedRepo = () => ({
findOne: jest.fn(),
create: jest.fn((dto) => ({ ...dto })),
save: jest.fn(async (entity) => entity),
});
const makeTcService = () => ({
earn: jest.fn(async () => 0),
});
describe('TwitchEventService', () => {
let service: TwitchEventService;
let processedRepo: ReturnType<typeof makeProcessedRepo>;
let tcService: ReturnType<typeof makeTcService>;
beforeEach(async () => {
processedRepo = makeProcessedRepo();
tcService = makeTcService();
const module: TestingModule = await Test.createTestingModule({
providers: [
TwitchEventService,
ConversionService,
{ provide: getRepositoryToken(ProcessedEvent), useValue: processedRepo },
{ provide: TetardCoinService, useValue: tcService },
],
}).compile();
service = module.get<TwitchEventService>(TwitchEventService);
});
// ─── Cheer tests ────────────────────────────────────────────────────────
describe('handleCheer()', () => {
it('cheer 100 Bits → earn 10 TC (taux 10:1, pas de prime)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-1', 'user1', 100);
expect(tcService.earn).toHaveBeenCalledWith('user1', 10, 'cheer:100bits');
});
it('cheer 500 Bits → earn 55 TC (50 base + 5 prime seuil)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-2', 'user1', 500);
expect(tcService.earn).toHaveBeenCalledWith('user1', 55, 'cheer:500bits');
});
it('cheer 1000 Bits → earn 115 TC (100 base + 15 prime seuil)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-3', 'user1', 1000);
expect(tcService.earn).toHaveBeenCalledWith('user1', 115, 'cheer:1000bits');
});
it('cheer 5000 Bits → earn 575 TC (500 base + 75 prime seuil)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-4', 'user1', 5000);
expect(tcService.earn).toHaveBeenCalledWith('user1', 575, 'cheer:5000bits');
});
});
// ─── Idempotence tests ───────────────────────────────────────────────────
describe('idempotence', () => {
it('event déjà traité → pas de double crédit (cheer)', async () => {
processedRepo.findOne.mockResolvedValue({ id: 'msg-dup', processedAt: new Date() });
await service.handleCheer('msg-dup', 'user1', 100);
expect(tcService.earn).not.toHaveBeenCalled();
// Must not insert again
expect(processedRepo.save).not.toHaveBeenCalled();
});
it('event déjà traité → pas de double crédit (subscription)', async () => {
processedRepo.findOne.mockResolvedValue({ id: 'msg-sub-dup', processedAt: new Date() });
await service.handleSubscription('msg-sub-dup', 'user1', '1000', false);
expect(tcService.earn).not.toHaveBeenCalled();
});
it('premier event → inséré dans ProcessedEvent', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-new', 'user1', 100);
expect(processedRepo.save).toHaveBeenCalledTimes(1);
});
});
// ─── Subscription tests ──────────────────────────────────────────────────
describe('handleSubscription()', () => {
it('T1 subscribe → earn 50 TC', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-s1', 'user1', '1000', false);
expect(tcService.earn).toHaveBeenCalledWith('user1', 50, 'subscribe:tier1000');
});
it('T2 subscribe → earn 120 TC', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-s2', 'user1', '2000', false);
expect(tcService.earn).toHaveBeenCalledWith('user1', 120, 'subscribe:tier2000');
});
it('T3 subscribe → earn 350 TC', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-s3', 'user1', '3000', false);
expect(tcService.earn).toHaveBeenCalledWith('user1', 350, 'subscribe:tier3000');
});
it('Prime subscribe → earn 30 TC', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-sp', 'user1', 'prime', false);
expect(tcService.earn).toHaveBeenCalledWith('user1', 30, 'subscribe:tierprime');
});
it('gift sub → gifter earns TC (gifterUserId)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-gift', 'recipient', '1000', true, 'gifter1');
expect(tcService.earn).toHaveBeenCalledWith('gifter1', 50, 'gift-sub:tier1000');
});
});
// ─── Threshold bonus unit tests ──────────────────────────────────────────
describe('getThresholdBonus()', () => {
it('< 500 Bits → bonus 0', () => {
expect(service.getThresholdBonus(100)).toBe(0);
expect(service.getThresholdBonus(499)).toBe(0);
});
it('500 Bits → bonus +5', () => {
expect(service.getThresholdBonus(500)).toBe(5);
expect(service.getThresholdBonus(999)).toBe(5);
});
it('1000 Bits → bonus +15', () => {
expect(service.getThresholdBonus(1000)).toBe(15);
expect(service.getThresholdBonus(4999)).toBe(15);
});
it('5000 Bits → bonus +75', () => {
expect(service.getThresholdBonus(5000)).toBe(75);
expect(service.getThresholdBonus(10000)).toBe(75);
});
});
});

View File

@@ -0,0 +1,96 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TetardCoinService } from '../economy/tetard-coin.service';
import { ConversionService } from '../economy/conversion.service';
import { ProcessedEvent } from './entities/processed-event.entity';
// Subscription tier rewards — economy-design.md
const SUB_TC: Record<string, number> = {
'1000': 50, // T1
'2000': 120, // T2
'3000': 350, // T3
prime: 30,
};
// Bits threshold bonuses — economy-design.md
const BITS_BONUS: Array<{ threshold: number; bonus: number }> = [
{ threshold: 5000, bonus: 75 },
{ threshold: 1000, bonus: 15 },
{ threshold: 500, bonus: 5 },
];
@Injectable()
export class TwitchEventService {
private readonly logger = new Logger(TwitchEventService.name);
constructor(
@InjectRepository(ProcessedEvent)
private readonly processedRepo: Repository<ProcessedEvent>,
private readonly tcService: TetardCoinService,
private readonly conversionService: ConversionService,
) {}
/**
* Returns true if already processed (idempotence guard).
* If not seen before, inserts and returns false.
*/
async isAlreadyProcessed(messageId: string): Promise<boolean> {
const existing = await this.processedRepo.findOne({ where: { id: messageId } });
if (existing) return true;
await this.processedRepo.save(this.processedRepo.create({ id: messageId }));
return false;
}
/**
* channel.cheer — convert bits to TC with threshold bonus, then earn.
*/
async handleCheer(messageId: string, userId: string, bits: number): Promise<void> {
if (await this.isAlreadyProcessed(messageId)) {
this.logger.log(`Idempotent skip — messageId=${messageId}`);
return;
}
const baseTC = this.conversionService.bitsToTC(bits);
const bonus = this.getThresholdBonus(bits);
const total = baseTC + bonus;
await this.tcService.earn(userId, total, `cheer:${bits}bits`);
this.logger.log(`Cheer processed: userId=${userId} bits=${bits} tc=${total} (base=${baseTC} bonus=${bonus})`);
}
/**
* channel.subscribe / channel.subscription.gift
* gifted: if true, gifter earns TC (senderUserId), recipient earns nothing.
*/
async handleSubscription(
messageId: string,
userId: string,
tier: string,
gifted = false,
gifterUserId?: string,
): Promise<void> {
if (await this.isAlreadyProcessed(messageId)) {
this.logger.log(`Idempotent skip — messageId=${messageId}`);
return;
}
const tc = SUB_TC[tier] ?? SUB_TC['1000'];
const earnUserId = gifted && gifterUserId ? gifterUserId : userId;
const reason = gifted ? `gift-sub:tier${tier}` : `subscribe:tier${tier}`;
await this.tcService.earn(earnUserId, tc, reason);
this.logger.log(`Subscription processed: userId=${earnUserId} tier=${tier} tc=${tc} gifted=${gifted}`);
}
/**
* Compute threshold bonus based on economy-design.md table.
*/
getThresholdBonus(bits: number): number {
for (const { threshold, bonus } of BITS_BONUS) {
if (bits >= threshold) return bonus;
}
return 0;
}
}

View File

@@ -0,0 +1,97 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ForbiddenException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac } from 'crypto';
import { TwitchWebhookController } from './twitch-webhook.controller';
import { TwitchEventService } from './twitch-event.service';
const SECRET = 'test-webhook-secret';
const makeEventService = () => ({
handleCheer: jest.fn(),
handleSubscription: jest.fn(),
});
const makeConfigService = () => ({
get: jest.fn((key: string) => (key === 'TWITCH_WEBHOOK_SECRET' ? SECRET : undefined)),
});
function buildRequest(messageId: string, timestamp: string, bodyStr: string) {
const rawBody = Buffer.from(bodyStr, 'utf8');
const hmac = `sha256=${createHmac('sha256', SECRET)
.update(`${messageId}${timestamp}${bodyStr}`)
.digest('hex')}`;
return { rawBody, signature: hmac };
}
describe('TwitchWebhookController — signature', () => {
let controller: TwitchWebhookController;
let eventService: ReturnType<typeof makeEventService>;
beforeEach(async () => {
eventService = makeEventService();
const module: TestingModule = await Test.createTestingModule({
controllers: [TwitchWebhookController],
providers: [
{ provide: TwitchEventService, useValue: eventService },
{ provide: ConfigService, useValue: makeConfigService() },
],
}).compile();
controller = module.get<TwitchWebhookController>(TwitchWebhookController);
});
it('signature valide → traite la requête sans erreur', async () => {
const msgId = 'valid-msg-id';
const ts = new Date().toISOString();
const bodyObj = {
subscription: { type: 'channel.cheer' },
event: { user_id: 'user1', bits_used: 100 },
};
const bodyStr = JSON.stringify(bodyObj);
const { rawBody, signature } = buildRequest(msgId, ts, bodyStr);
const req = { rawBody } as any;
await expect(
controller.handleWebhook(req, msgId, ts, signature, 'notification', bodyObj as any),
).resolves.toEqual({ status: 'ok' });
});
it('signature invalide → ForbiddenException (400/403)', async () => {
const msgId = 'invalid-msg-id';
const ts = new Date().toISOString();
const bodyObj = {
subscription: { type: 'channel.cheer' },
event: { user_id: 'user1', bits_used: 100 },
};
const bodyStr = JSON.stringify(bodyObj);
const rawBody = Buffer.from(bodyStr, 'utf8');
const badSignature = 'sha256=invalidsignaturevalue00000000000000000000000000000000000000000000';
const req = { rawBody } as any;
await expect(
controller.handleWebhook(req, msgId, ts, badSignature, 'notification', bodyObj as any),
).rejects.toThrow(ForbiddenException);
expect(eventService.handleCheer).not.toHaveBeenCalled();
});
it('signature manquante → ForbiddenException', async () => {
const msgId = 'no-sig-msg';
const ts = new Date().toISOString();
const bodyObj = { subscription: { type: 'channel.cheer' }, event: {} };
const bodyStr = JSON.stringify(bodyObj);
const rawBody = Buffer.from(bodyStr, 'utf8');
const req = { rawBody } as any;
// Pass empty string as signature — length mismatch → ForbiddenException
await expect(
controller.handleWebhook(req, msgId, ts, '', 'notification', bodyObj as any),
).rejects.toThrow(ForbiddenException);
});
});

View File

@@ -0,0 +1,123 @@
import {
Controller,
Post,
Headers,
Body,
RawBodyRequest,
Req,
HttpCode,
HttpStatus,
Logger,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'crypto';
import { Request } from 'express';
import { TwitchEventService } from './twitch-event.service';
@Controller('twitch')
export class TwitchWebhookController {
private readonly logger = new Logger(TwitchWebhookController.name);
constructor(
private readonly configService: ConfigService,
private readonly twitchEventService: TwitchEventService,
) {}
@Post('webhook')
@HttpCode(HttpStatus.OK)
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('twitch-eventsub-message-id') messageId: string,
@Headers('twitch-eventsub-message-timestamp') timestamp: string,
@Headers('twitch-eventsub-message-signature') signature: string,
@Headers('twitch-eventsub-message-type') messageType: string,
@Body() body: Record<string, unknown>,
): Promise<{ status: string }> {
// Validate HMAC-SHA256 signature — security: validate-then-verify pattern
this.verifySignature(req, messageId, timestamp, signature);
// Twitch webhook verification challenge
if (messageType === 'webhook_callback_verification') {
return { status: body['challenge'] as string };
}
if (messageType === 'revocation') {
this.logger.warn(`Subscription revoked for messageId=${messageId}`);
return { status: 'ok' };
}
if (messageType !== 'notification') {
return { status: 'ok' };
}
const subscription = body['subscription'] as Record<string, unknown>;
const event = body['event'] as Record<string, unknown>;
const eventType = (subscription?.['type'] as string) ?? '';
switch (eventType) {
case 'channel.cheer':
await this.handleCheer(messageId, event);
break;
case 'channel.subscribe':
await this.handleSubscribe(messageId, event);
break;
case 'channel.subscription.gift':
await this.handleGiftSub(messageId, event);
break;
default:
this.logger.debug(`Unhandled event type: ${eventType}`);
}
return { status: 'ok' };
}
// ─── Private helpers ──────────────────────────────────────────────────────
private verifySignature(
req: RawBodyRequest<Request>,
messageId: string,
timestamp: string,
signature: string,
): void {
const secret = this.configService.get<string>('TWITCH_WEBHOOK_SECRET');
if (!secret) throw new ForbiddenException('Webhook secret not configured');
const rawBody = req.rawBody;
if (!rawBody) throw new ForbiddenException('Raw body unavailable');
const hmacMessage = `${messageId}${timestamp}${rawBody.toString('utf8')}`;
const expected = `sha256=${createHmac('sha256', secret).update(hmacMessage).digest('hex')}`;
// timingSafeEqual prevents timing attacks
const sigBuf = Buffer.from(signature ?? '');
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
this.logger.warn(`Invalid signature for messageId=${messageId}`);
throw new ForbiddenException('Invalid webhook signature');
}
}
private async handleCheer(messageId: string, event: Record<string, unknown>): Promise<void> {
const userId = event['user_id'] as string;
const bits = Number(event['bits_used'] ?? 0);
await this.twitchEventService.handleCheer(messageId, userId, bits);
}
private async handleSubscribe(messageId: string, event: Record<string, unknown>): Promise<void> {
const userId = event['user_id'] as string;
const tier = event['tier'] as string;
await this.twitchEventService.handleSubscription(messageId, userId, tier, false);
}
private async handleGiftSub(messageId: string, event: Record<string, unknown>): Promise<void> {
const gifterUserId = event['user_id'] as string;
const tier = event['tier'] as string;
// For gifts, gifter earns TC
await this.twitchEventService.handleSubscription(messageId, gifterUserId, tier, true, gifterUserId);
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { EconomyModule } from '../economy/economy.module';
import { ProcessedEvent } from './entities/processed-event.entity';
import { TwitchEventService } from './twitch-event.service';
import { TwitchWebhookController } from './twitch-webhook.controller';
@Module({
imports: [
ConfigModule,
TypeOrmModule.forFeature([ProcessedEvent]),
EconomyModule,
],
controllers: [TwitchWebhookController],
providers: [TwitchEventService],
exports: [TwitchEventService],
})
export class TwitchModule {}