Compare commits

..

3 Commits

Author SHA1 Message Date
cc450f2113 merge: feat/turn-combat — Lore Odyssée + Combat tour par tour + CompanionAI
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 40s
2026-03-25 00:58:52 +01:00
9d50adf523 feat: Combat tour par tour — Phases A-D complètes
TurnManager stateless avec sessions en mémoire (TTL 10min).
SpellSystem : 15 sorts (5 par voie du Dao), mana, cooldowns, buffs/debuffs.
CompanionAI : Mira (heal/support) et Vell (tank/dps) — IA contextuelle.
Monster AI : 3 profils (agressif, défensif, chaotique).

Nouvelles entités : Spell, PlayerSpell, PlayerDaoPath.
Character +mana. Monster +aiProfile +isBoss.
Migration : 1743004800000-TurnCombatSystem.

Frontend : TurnCombatPage (select/combat/result), sélecteur compagnon,
barres HP/MP, log scrollable, sous-menu sorts avec cooldowns.

Endpoints : 8 routes sous /combat/turn/ (start, action, session, spells,
unlocked, unlock, dao, dao/choose).

Combat simple (POST /combat/start) et grind ×5/×10 inchangés.
2026-03-25 00:58:47 +01:00
4beb1b2ed9 feat: Phase 3 Lore & Contenu — L'Odyssée d'un têtard
Lore Bible (canon narratif complet) + Engine Design (séparation moteur/univers).

4 nouvelles zones (Ruisseau Miroir, Marais des Murmures, Torrent Brisé, Source du Courant)
dans la chaîne d'unlock après desert (niv 16-25+).

Module NPC complet (entity, service, controller) — 8 PNJ avec dialogues évolutifs
par palier de niveau : Gorn (niv 1-15), Pierre-Mémoire (niv 16+), Mira, Vell,
La Batracienne, Le Forgeron, Le Marchand.

20 monstres lore-friendly, 12 matériaux, 15 items (dont Bâton de Gorn légendaire).

17 quêtes narratives (4 arcs ch.9-12) avec textes acceptText/completeText
qui racontent l'Odyssée. Nouveau type story_event pour les moments narratifs purs.
3 quêtes répétables optionnelles.

Seed runner : npm run seed:odyssee

Tout est additif — zéro impact sur le contenu existant niv 1-15.
2026-03-25 00:52:14 +01:00
37 changed files with 4424 additions and 15 deletions

104
docs/engine-design.md Normal file
View File

@@ -0,0 +1,104 @@
# TetaRdPG — Engine Design
> Version : 1.0 — 2026-03-25
> Décision architecturale : le moteur RPG est un template neutre, l'Odyssée est sa première customisation.
---
## Principe
```
Couche 1 — Moteur (template, forkable)
CombatService → attaque / fuir, formules dégâts GDD
Stats → Force, Agilité, Intelligence, Chance, Vitalité
Économie → Or, monnaie premium, endurance
Progression → XP, niveaux, déblocages
Forge / Craft → amélioration, recettes, matériaux
Quêtes → fetch, complete, rewards
Achievements → event-driven, paliers
Couche 2 — Univers (spécifique à l'Odyssée, pas dans le template)
TurnManager → tour par tour enrichi (sorts, compagnons, patterns IA)
SpellSystem → 3 voies du Dao, arbre de sorts, mana/endurance
CompanionModule → Mira/Vell, IA contextuelle
NarrativeEngine → PNJ évolutifs, dialogues par palier, fragments du Chant
BossPhaseSystem → mécaniques uniques (ex: guérison Hydre ≠ DPS)
```
## Règle
Le moteur (couche 1) ne doit **jamais** dépendre de la couche 2.
Un fork du moteur = combat basique fonctionnel, zéro référence à l'Odyssée.
L'Odyssée importe le moteur, pas l'inverse.
## Impact sur le combat rework
Le `CombatService` actuel n'est pas jeté — il devient le moteur de base.
Le rework **étend** sans casser :
```
CombatService (existant) → reste, calcule dégâts/XP/loot
└── TurnManager (nouveau) → orchestre les tours, gère l'ordre
├── SpellSystem → sorts par voie, coût, effets
├── CompanionAI → décisions IA Mira/Vell
└── BossPhaseManager → phases spéciales (narratif)
```
## La Métamorphose — Evoland pattern (décision 2026-03-25)
Finir les 3 arcs de l'Acte I (Marais → Égouts → Désert) déclenche **Le Serment des Trois**.
Le jeu lui-même évolue — le joueur vit la métamorphose du têtard.
```
ACTE I (niv 1-13) — Le monde simple
Combat : auto (POST /combat/start)
Slots : 🗡️ Arme + 🛡️ Armure (2 slots)
Types : weapon | armor | consumable
Sorts : aucun
Compagnons : aucun
⬇️ LE SERMENT DES TROIS ⬇️
(quête narrative, arc desert complété)
ACTE II (niv 13+) — Le monde éveillé
Combat : tour par tour (POST /combat/turn/start)
Slots : 🗡️ Main droite + 🛡️ Main gauche + 🪖 Casque + 👕 Armure + 💍 Anneau (5 slots)
OU ⚔️ Arme deux mains (remplace main droite + gauche)
Types : weapon_1h | weapon_2h | shield | helmet | armor | ring | consumable
Sorts : Dao du Courant (3 voies, 15 sorts)
Compagnons : Mira, Vell (quêtes narratives)
```
### Règle de coexistence
- Zones 1-3 gardent le combat simple (grind rapide, ×5/×10 toujours dispo)
- Zones 4+ forcent le combat tour par tour (narratif, sorts, compagnons)
- Les items Acte I restent utilisables — les nouveaux types n'existent qu'en Acte II
- Le joueur garde tout (or, items, stats) — c'est une évolution, pas un reset
### Impact sur l'Item entity
```
Acte I (existant, inchangé) :
ItemType = 'weapon' | 'armor' | 'consumable'
equipped = boolean (1 arme + 1 armure max)
Acte II (extension) :
ItemType += 'weapon_2h' | 'shield' | 'helmet' | 'ring'
equipped_slot = 'main_hand' | 'off_hand' | 'head' | 'body' | 'ring' | null
Contrainte : weapon_2h → off_hand bloqué
```
### Pourquoi c'est cohérent
Le GDD original prévoit des déblocages par niveau (forge niv 10, boutique avancée niv 15).
L'Acte II est le plus gros déblocage du jeu — le gameplay entier évolue.
Narrativement : le têtard commence à se transformer. Le Dao s'éveille.
## Conséquence template
Quand un streamer fork TetaRdPG pour son royaume (Phase 4 — Twitch Kingdom) :
- Il hérite du moteur complet (couche 1)
- Il peut remplacer l'Odyssée par son propre lore (couche 2)
- Ou il joue dans l'univers partagé de l'Odyssée — c'est son choix

432
docs/lore-bible.md Normal file
View File

@@ -0,0 +1,432 @@
# TetaRdPG — Lore Bible
> Version : 1.0 — 2026-03-25
> Source : "L'Odyssée d'un têtard" (histoire originale par Tetardtek)
> Statut : Canon de base — à étoffer par game-designer + storyman après combat rework
> Objectif : Ancrer l'univers, les personnages, les lieux et la mythologie du jeu
---
## Univers
### Le monde
Un univers aquatique et amphibien où l'eau est le tissu de la réalité. Les étangs, ruisseaux, marais et torrents forment un réseau vivant relié par le **Courant** — une force mystique qui traverse toute chose. L'eau n'est pas un décor, c'est le medium de la magie, de la mémoire et du destin.
### Mythologie fondatrice
Au commencement, l'eau **chantait**. Un chant pur, tissé de toutes les vies passées et futures, maintenait l'harmonie du monde. Ce chant s'est tu. L'équilibre s'est rompu. Les gardiens anciens ont sombré dans l'oubli ou le chaos.
La prophétie dit que le chant reviendra quand l'eau trouvera son **Élu** — celui qui saura écouter, résonner et harmoniser.
### Le Dao du Courant
Philosophie et système de magie central. Le Dao du Courant est l'art de danser avec l'eau plutôt que de la combattre. Trois voies le composent :
| Voie | Principe | Style | Archétype |
|------|----------|-------|-----------|
| **Écoute** | Percevoir les flux invisibles | Contrôle, stratégie, chant offensif | Tetardtek — le stratège |
| **Résonance** | Amplifier la force par l'harmonie | Tank, protection, contre-attaque | Vell — le protecteur |
| **Harmonie** | Tisser l'équilibre entre les êtres | Support, heal, apaisement | Mira — l'harmoniste |
> Le joueur n'est pas enfermé dans une voie. Sa voie principale progresse plus vite, mais il peut explorer les trois. La voie se révèle progressivement via les choix narratifs.
---
## Personnages
### Personnages principaux
#### Tetardtek — "Celui qui flotte entre les mondes"
- **Rôle :** Protagoniste / avatar du joueur
- **Personnalité :** Observateur, curieux, déterminé. Il perçoit ce que les autres ignorent.
- **Arc :** De têtard rêveur à porteur du Chant. Apprend l'Écoute — percevoir les courants invisibles, éveiller les mémoires enfouies.
- **Pouvoir :** Perception du Courant, Chant restaurateur, ancrage mémoriel
- **Citation :** *"Était-il destiné à rester, ou son avenir se trouvait-il ailleurs ?"*
#### Vell — Le rival devenu frère
- **Rôle :** Rival amical → compagnon de combat
- **Personnalité :** Impétueux, fier, loyal. Confond d'abord force et valeur.
- **Arc :** De la force brute à la maîtrise — apprend que la vraie puissance est dans l'écoute, pas la domination. Son tournant : le Torrent Brisé (ch.11).
- **Pouvoir :** Résonance — onde de choc, bouclier de flux, contre-attaque amplifiée
- **Citation :** *"La vraie force... c'est de ne pas lutter seul."*
- **En jeu :** PNJ au village (arène), compagnon de combat, défis rivalité
#### Mira — L'harmoniste naturelle
- **Rôle :** Compagnon support / guide émotionnel
- **Personnalité :** Douce, intuitive, silencieusement puissante. Connectée au Courant sans le savoir.
- **Arc :** De la grâce inconsciente à la maîtrise assumée. Son tournant : le Ruisseau Miroir (ch.9), où elle accepte son chant.
- **Pouvoir :** Harmonie — chant guérisseur, apaisement, dissolution des illusions
- **Citation :** *"Le chant guérit. Mais il faut l'entendre. Et l'accepter."*
- **En jeu :** PNJ au village (place centrale, quêtes narratives, heal), compagnon de combat
#### Gorn — L'ancien
- **Rôle :** Mentor, gardien du savoir ancien
- **Personnalité :** Sage, grave, bienveillant. Porte un poids qu'il n'a jamais partagé.
- **Arc :** Guide les héros puis se sacrifie face à l'Hydre (ch.7). Son héritage persiste à travers la Pierre-Mémoire et ses échos.
- **Pouvoir :** Connaissance du Dao, lien avec la Pierre-Mémoire, lumière protectrice
- **Citation :** *"Le courant t'attend. Va. Moi, je dois clore un cycle."*
- **En jeu :** PNJ au village (tableau des quêtes) niv 1-15. Après ch.7 → remplacé par la Pierre-Mémoire (ses échos guident le joueur)
### Entités mystiques
#### L'Anguille de l'Oubli
- **Nature :** Gardienne des souvenirs engloutis — ni alliée, ni ennemie
- **Apparence :** Ombre longue et sinueuse, yeux vides et profonds
- **Rôle narratif :** Oracle et seuil. Apparaît aux moments charnières. Son regard perce les âmes.
- **Citation :** *"Tu crois que l'étang est éternel ? L'instant viendra où tu devras choisir : flotter ou bondir."*
- **En jeu :** Boss/oracle récurrent. Apparaît à chaque palier de niveau clé. Pas un ennemi à tuer — un test à comprendre.
#### L'Hydre des Profondeurs
- **Nature :** Ancienne gardienne des eaux, corrompue par la rupture du Chant
- **Apparence :** Immense, sinueuse, écailles sombres et luisantes, éclat rouge au cœur
- **Vérité cachée :** L'Hydre était la chantre des profondeurs — aimée, vénérée. Quand le Chant a été brisé, elle a sombré dans le chaos. Elle n'est pas le mal, elle est la douleur.
- **Arc :** Antagoniste → être à guérir. Le combat final n'est pas une mise à mort mais une restauration du Chant.
- **En jeu :** Boss mid-game (ch.7, destruction de l'étang) + boss final (ch.14, guérison par le Chant)
#### L'Éclaireuse du Courant (libellule dorée)
- **Nature :** Messagère du Dao. Apparaît aux âmes prêtes.
- **Apparence :** Libellule dorée, ailes translucides veinées d'argent, projette des symboles lumineux
- **Rôle :** Signal visuel et narratif — marque les moments d'éveil
- **En jeu :** Créature guide, indices visuels in-game, notifications de progression narrative
#### La Batracienne du Marais
- **Nature :** Sage ancienne, gardienne des visions
- **Apparence :** Batracienne vêtue de lianes et d'algues, yeux voilés mais brillants de lumière verte
- **Rôle :** Donne les visions du passé, révèle la vérité sur l'Hydre
- **Citation :** *"Tu portes le chant en germe, petit têtard. Mais avant la source, il faut comprendre le courant."*
- **En jeu :** PNJ zone Marais des Murmures — quêtes vision, unlock lore profond
### Faune de l'étang (PNJ secondaires / ambiance)
| Créature | Rôle | Trait |
|----------|------|-------|
| Escargots aquatiques | Ambiance, PNJ marchands | Lents, sages, tracent des sillons sur les pierres |
| Notonectes | Éclaireurs, sentinelles | Planent à la surface, fuient au danger |
| Poissons-argent | Ambiance, indicateurs d'état du monde | Nuées brillantes — vifs = étang sain, figés = danger |
| Larves de libellules | Gardiennes des recoins | Patientes, guetteuses |
| Gerris | Patrouilleurs de surface | Insectes géants pour un têtard |
| Crapaud-moine | Sage secondaire | Chante des mantras anciens |
---
## Lieux
### L'Étang — Zone de départ (niv 1-15)
Berceau du joueur. Univers miniature harmonieux… jusqu'à la venue de l'Hydre.
| Sous-zone | Chapitres | Niveaux | Ambiance |
|-----------|-----------|---------|----------|
| Surface de l'Étang | 1-3 | 1-5 | Paisible, tutoriel, nénuphars, roseaux, lucioles |
| Profondeurs de l'Étang | 4-6 | 6-10 | Mystérieux, Pierre-Mémoire, lueurs violettes |
| L'Étang Brisé | 7-8 | 11-15 | Dévasté, roseaux flétris, eau trouble, départ |
**Événements clés :**
- Apparition de l'Anguille de l'Oubli (ch.1)
- Première tentative / vision de la Grenouille émeraude (ch.2)
- Course Tetardtek vs Vell / éveil du Dao (ch.3-4)
- Pierre-Mémoire / transmission de Gorn (ch.5-6)
- Destruction par l'Hydre / sacrifice de Gorn / Serment des Trois (ch.7-8)
### Ruisseau Miroir — Zone 4 (niv 16-18)
Eau claire comme du verre fondu, pierres polies. Montre ce qu'on ne veut pas voir.
| Élément | Description |
|---------|-------------|
| Mécanique | Les ennemis sont des **reflets** du joueur — doubles sombres avec ses propres stats |
| Test | Affronter ses peurs et ses doutes |
| Résolution | Mira chante, dissipe les illusions — le groupe traverse ensemble |
| PNJ | Aucun — zone de solitude intérieure |
### Marais des Murmures — Zone 5 (niv 19-21)
Terres basses, brume, arbres morts. L'eau retient les souvenirs et les murmure.
| Élément | Description |
|---------|-------------|
| Ambiance | Oppressant, chuchotements, voix dans le courant |
| PNJ clé | La Batracienne — donne visions, révèle la vérité sur l'Hydre |
| Révélation | L'Hydre était gardienne avant d'être chaos — le Chant dévoyé l'a brisée |
| Mécanique | Quêtes vision — le joueur vit des flashbacks du passé |
| Loot | Fragments du Chant (collectibles narratifs) |
### Torrent Brisé — Zone 6 (niv 22-24)
Eaux violentes, rochers acérés, grondement permanent. La force brute ne passe pas.
| Élément | Description |
|---------|-------------|
| Mécanique | Zone haute difficulté — les attaques physiques sont réduites par le courant |
| Test | Vell comprend que la force s'écoute, ne se force pas |
| Résolution | Coopération : Mira lit les remous, Tetardtek fredonne, Vell nage entre eux |
| Boss zone | Le Torrent lui-même — obstacle vivant à traverser en groupe |
### Source du Courant — Zone 7 (niv 25)
Vasque de pierre, eau en fils de lumière, harmonie. Lieu légendaire de pouvoir.
| Élément | Description |
|---------|-------------|
| Événement | Palier max Phase 3 — les trois héros reçoivent la pleine maîtrise du Dao |
| Visions | Chacun a une vision personnelle (Tetardtek → Gorn apaisé, Mira → mille voix, Vell → sa force tranquille) |
| Pouvoir | Filament de lumière violette entre en chacun — unlock arbre de sorts complet |
| Loot | Artefact de la Source — item légendaire lié à la voie du joueur |
### Courants d'Épreuve — Zone 8 (niv 26-28, endgame Phase 3)
Trois épreuves qui testent la maîtrise du Dao.
| Épreuve | Test | Voie principale |
|---------|------|-----------------|
| Vents Croisés | Lac suspendu, vents destructeurs — harmoniser le chaos | Harmonie (Mira) |
| Remous de l'Oubli | Zone stagnante qui avale les souvenirs — ancrer la mémoire | Écoute (Tetardtek) |
| Enclave des Roches | Cascade souterraine bloquée — résonance pour ouvrir | Résonance (Vell) |
### L'Étang Restauré — Zone 9 (niv 29-30, boss final)
Retour à l'étang — mais transformé, en attente de guérison.
| Élément | Description |
|---------|-------------|
| Boss final | L'Hydre des Profondeurs — combat en 3 phases |
| Mécanique unique | Pas de DPS brut. Restaurer le Chant via les 3 voies synchronisées |
| Résolution | L'Hydre est guérie, réintégrée à l'harmonie. L'étang revit. |
| Épilogue | Le Chant résonne à nouveau. "L'odyssée des trois héros commençait vraiment." |
### Le Delta — Tease Phase 4 (niv 30+)
> *"Il existe d'autres étangs, d'autres courants. Et tous chantent leur propre mélodie."*
Le Delta est le réseau des courants — là où les étangs se connectent. C'est le setup narratif pour **Twitch Kingdom** : chaque streamer = un étang, chaque communauté = un courant.
---
## Artefacts & Symboles récurrents
| Artefact | Signification | Rôle en jeu |
|----------|--------------|-------------|
| **La Pierre-Mémoire** | Pierre ancienne gravée de symboles, contient les échos du passé | Remplace Gorn comme guide après ch.7 — hub de quêtes narratives |
| **Le fragment de coquille** | Mémoire laissée par Gorn, gravée de la route vers la Source | Item de quête — boussole narrative |
| **Les Fragments du Chant** | Morceaux de la mélodie perdue, dispersés dans les zones | Collectibles — rassembler = unlock fin vraie |
| **La lumière violette** | Filament mystique liant les porteurs du Dao | Marqueur visuel récurrent — progression, éveil, lien |
| **Les cercles et spirales** | Symboles projetés par l'Éclaireuse | Langage visuel du Courant — UI motif |
---
## Arc narratif global — Structure
```
Acte I — L'Étang (ch.1-8, niv 1-15)
Naissance → Éveil → Rivalité → Dao → Mentor → Catastrophe → Serment → Départ
Thème : "Qui suis-je ?"
Acte II — Le Voyage (ch.9-13, niv 16-28)
Miroir → Murmures → Torrent → Source → Épreuves
Thème : "Que puis-je devenir ?"
Acte III — Le Retour (ch.14-15, niv 29-30)
Combat final → Guérison de l'Hydre → Restauration du Chant
Thème : "Quel monde vais-je construire ?"
Épilogue — L'Onde Infinie (ch.15bis, niv 30+)
Tease Phase 4 — d'autres étangs, d'autres courants
Thème : "L'odyssée ne fait que commencer."
```
---
## Tons et ambiances par zone
| Zone | Palette | Son | Émotion |
|------|---------|-----|---------|
| Étang (début) | Bleu-vert, argenté, lucioles | Eau calme, roseaux, nuit | Sérénité, curiosité |
| Étang (profond) | Violet sombre, lueurs dorées | Échos, vibrations basses | Mystère, révélation |
| Étang (brisé) | Gris-noir, rouge | Grondement, silence | Terreur, perte, détermination |
| Ruisseau Miroir | Cristal, reflets parfaits | Silence troublant, distorsion | Doute, confrontation |
| Marais des Murmures | Vert sombre, brume, lueur verte | Chuchotements, brume | Oppression, sagesse cachée |
| Torrent Brisé | Blanc d'écume, gris roche | Rugissement, fracas | Épreuve, dépassement |
| Source du Courant | Or, lumière pure, translucide | Harmonie, chant pur | Transcendance, plénitude |
| Courants d'Épreuve | Variable par épreuve | Variable | Maîtrise, derniers doutes |
| Étang Restauré | Arc-en-ciel aquatique | Chant complet | Victoire, émotion, paix |
---
## Hub Village — PNJ et évolution
Le village (dashboard) évolue avec l'histoire :
### Avant ch.7 (niv 1-15)
| Lieu | PNJ | Fonction |
|------|-----|----------|
| Place centrale | Mira | Quêtes narratives, heal entre combats |
| Arène | Vell | Défis combat, entraînement, rivalité |
| Tableau des quêtes | Gorn | Quêtes principales, lore, conseils |
| Forge | Forgeron | Craft & amélioration (existant) |
| Boutique | Marchand | Shop (existant) |
| Portail des zones | — | Accès biomes |
### Après ch.7 (niv 15+)
| Changement | Détail |
|------------|--------|
| Gorn disparaît | Remplacé par la **Pierre-Mémoire** — ses échos guident les quêtes |
| Vell évolue | Dialogues mûris, reconnaît la valeur de l'écoute |
| Mira évolue | Assume son rôle de chanteuse, donne des quêtes de chant |
| Ambiance | L'étang est brisé — le village montre les stigmates |
### Après ch.14 (niv 30)
| Changement | Détail |
|------------|--------|
| Étang restauré | Le village retrouve sa beauté, amplifiée |
| Gorn en écho | La Pierre-Mémoire montre parfois son visage, apaisé |
| Portail du Delta | Nouveau point d'accès — tease Phase 4 |
---
## Compagnons de combat
Mira et Vell accompagnent le joueur dans certaines zones clés :
| Compagnon | Disponible | Style IA |
|-----------|-----------|----------|
| Mira | Quêtes narratives + zones 4-9 | Heal si HP < 40%, buff sinon, chant si boss |
| Vell | Défis arène + zones 6-9 | Protège si joueur en danger, contre-attaque, onde de choc |
> Les compagnons ne sont pas permanents — ils rejoignent sur les quêtes narratives clés et les zones de l'odyssée. Le joueur combat seul sur le contenu libre (grind, quêtes secondaires).
---
## La Métamorphose — Le jeu évolue avec le joueur
> Inspiré d'Evoland : le jeu lui-même change quand l'histoire le justifie.
L'Acte I est le monde simple. Le têtard découvre, apprend, combat basiquement.
Quand il prête le Serment des Trois (fin de l'Acte I), **le jeu se transforme** :
| Aspect | Acte I (niv 1-13) | Acte II (niv 13+) |
|--------|-------------------|-------------------|
| Combat | Auto (attaque simple) | Tour par tour (sorts, stratégie) |
| Équipement | 2 slots (arme + armure) | 5 slots (main droite, main gauche/bouclier, casque, armure, anneau) |
| Armes | Une seule catégorie | 1 main vs 2 mains (choix tactique) |
| Magie | Aucune | Dao du Courant (3 voies, 15 sorts) |
| Compagnons | Seul | Mira et Vell rejoignent le combat |
| Grind | ×1/×5/×10 auto | Tour par tour narratif + grind simple en zones 1-3 |
> Le joueur ne perd rien — il gagne. C'est la métamorphose du têtard.
> Les anciens items restent valides. Les nouveaux slots s'ajoutent, ils ne remplacent pas.
> Le joueur peut toujours retourner grind les zones 1-3 en combat simple pour farmer.
---
## Système de combat — Direction
> Le combat simple (Acte I) reste le moteur de base. Le tour par tour (Acte II) s'y ajoute.
> Design technique complet : voir `docs/engine-design.md`
### Vision cible
```
Tour du joueur :
[Attaque] [Sorts (voie du Dao)] [Items] [Fuir]
Tour du compagnon (si présent) :
IA contextuelle selon le rôle (Mira = support, Vell = tank)
Tour de l'ennemi :
Pattern par monstre (agressif, défensif, chaotique)
```
### Sorts par voie (exemples à étoffer par game-designer)
| Voie | Sorts exemple | Effet |
|------|--------------|-------|
| Écoute | Perception du flux | Révèle faiblesses ennemies (1 tour) |
| Écoute | Chant d'éveil | Dégâts magiques + debuff confusion |
| Écoute | Ancrage mémoriel | Annule le prochain debuff |
| Résonance | Onde de choc | Dégâts AoE physiques |
| Résonance | Bouclier de flux | Réduit dégâts reçus (2 tours) |
| Résonance | Contre-courant | Riposte automatique au prochain coup |
| Harmonie | Chant apaisant | Heal moyen |
| Harmonie | Dissolution | Retire les buffs ennemis |
| Harmonie | Onde de sérénité | Buff défense + regen toute l'équipe |
### Combat final — mécanique unique
Le boss final (Hydre) ne se bat pas en DPS. Trois phases :
1. **Contenir** — Vell résonne, absorbe le chaos
2. **Apaiser** — Mira chante, ouvre une brèche dans la douleur
3. **Restaurer** — Tetardtek chante le Chant complet → l'Hydre est guérie
> Cette mécanique sera le climax du jeu. Elle doit être narrativement et mécaniquement différente de tout le reste.
---
## Fragments du Chant — Collectibles narratifs
Morceaux de la mélodie perdue, dispersés dans les zones. Les rassembler débloque la capacité de guérir l'Hydre.
| Fragment | Zone | Comment l'obtenir |
|----------|------|------------------|
| Fragment de l'Éveil | Étang (surface) | Quête ch.1 — écouter l'Anguille |
| Fragment de la Vision | Étang (profondeur) | Quête ch.4 — course + éveil du Dao |
| Fragment de la Mémoire | Étang (profondeur) | Quête ch.6 — Pierre-Mémoire |
| Fragment du Serment | Étang (brisé) | Quête ch.8 — le serment des trois |
| Fragment du Miroir | Ruisseau Miroir | Vaincre son reflet |
| Fragment des Murmures | Marais des Murmures | Vision de la Batracienne |
| Fragment du Torrent | Torrent Brisé | Traverser ensemble |
| Fragment de la Source | Source du Courant | Toucher la vasque |
> 8 fragments = Chant complet → unlock combat final de guérison
---
## Glossaire du canon
| Terme | Définition |
|-------|-----------|
| **Le Courant** | Force mystique traversant toute eau — medium de la magie et de la mémoire |
| **Le Dao du Courant** | Art de danser avec l'eau. Trois voies : Écoute, Résonance, Harmonie |
| **Le Chant Perdu** | Mélodie ancienne qui maintenait l'harmonie du monde. Brisé, à restaurer. |
| **La Marée Silencieuse** | Phénomène rare — le temps se fige, les mondes se frôlent |
| **La Pierre-Mémoire** | Pierre ancienne contenant les échos de toutes les vies passées |
| **L'Onde Infinie** | Le réseau des courants reliant tous les étangs — métaphore du Delta (Phase 4) |
| **Flotter ou bondir** | Choix fondamental — rester dans le confort ou embrasser son destin |
---
## Notes pour le game-designer et le storyman
### Ce qui est posé (canon non négociable)
- Les 4 personnages principaux et leurs arcs
- Le Dao du Courant comme système de magie à 3 voies
- L'Hydre = gardienne corrompue à guérir, pas un ennemi à tuer
- Le Chant Perdu comme fil rouge narratif
- La structure en 3 actes + épilogue
### Ce qui est à étoffer
- **Dialogues** — chaque PNJ a besoin de lignes de dialogue par palier de niveau
- **Quêtes secondaires** — le squelette narratif est là, il faut du contenu entre les quêtes principales
- **Bestiaire** — les monstres de chaque zone (noms, lore, lien au Courant)
- **Items narratifs** — équipements liés à l'histoire (Bâton de Gorn, Collier de Mira, etc.)
- **Événements de zone** — micro-événements aléatoires liés au lore (apparition de l'Éclaireuse, murmures, marée silencieuse)
- **Le passé de l'Hydre** — développer l'époque dorée avant la rupture du Chant
- **L'histoire entre les chapitres** — ce que le joueur vit pendant le grind, pas juste les moments clés
- **Le Delta (Phase 4)** — architecture narrative pour Twitch Kingdom
### Principe directeur
> L'histoire est courte mais dense. Chaque chapitre est un noyau narratif.
> Le game-designer ajoute du gameplay entre les noyaux.
> Le storyman ajoute de la chair narrative autour des noyaux.
> Le squelette ne change pas. La chair peut être aussi épaisse qu'on veut.

View File

@@ -7,6 +7,7 @@ import { LoginPage } from './pages/LoginPage';
import { AuthCallback } from './pages/AuthCallback';
import { DashboardPage } from './pages/DashboardPage';
import { CombatPage } from './pages/CombatPage';
import { TurnCombatPage } from './pages/TurnCombatPage';
import { InventoryPage } from './pages/InventoryPage';
import { CraftPage } from './pages/CraftPage';
import { ForgePage } from './pages/ForgePage';
@@ -38,6 +39,7 @@ function AppRoutes() {
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
<Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} />
<Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} />
<Route path="/combat/tactical" element={<ProtectedLayout><TurnCombatPage /></ProtectedLayout>} />
<Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
<Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />

View File

@@ -2,6 +2,7 @@ import { api } from './client';
import type {
User, Character, Monster, CombatLog,
CharacterItem, CharacterMaterial, Recipe, CraftJob, Item,
TurnResult, TurnSpell, DaoPathProgress,
} from './types';
// Auth
@@ -31,6 +32,23 @@ export const combatApi = {
history: () => api.get<CombatLog[]>('/combat/history'),
};
// Turn Combat
export const turnCombatApi = {
start: (monsterId: string, attackType: string, companion?: string | null) =>
api.post<TurnResult>('/combat/turn/start', { monsterId, attackType, ...(companion ? { companion } : {}) }),
action: (sessionId: string, type: string, spellId?: string) =>
api.post<TurnResult>('/combat/turn/action', { sessionId, type, ...(spellId ? { spellId } : {}) }),
session: (sessionId: string) =>
api.get<TurnResult>(`/combat/turn/session/${sessionId}`),
spells: () => api.get<TurnSpell[]>('/combat/turn/spells'),
unlockedSpells: () => api.get<TurnSpell[]>('/combat/turn/spells/unlocked'),
unlockSpell: (spellId: string) =>
api.post<any>('/combat/turn/spells/unlock', { spellId }),
dao: () => api.get<DaoPathProgress[]>('/combat/turn/dao'),
chooseDaoPath: (path: string) =>
api.post<DaoPathProgress>('/combat/turn/dao/choose', { path }),
};
// Items
export const itemApi = {
catalogue: () => api.get<Item[]>('/items'),

View File

@@ -113,6 +113,85 @@ export interface CombatLog {
monster: { id: string; name: string; minLevel: number; maxLevel: number };
}
// ---------- Turn Combat ----------
export interface TurnBuff {
id: string;
name: string;
stat: string;
value: number;
isPercent: boolean;
remainingTurns: number;
}
export interface TurnLogEntry {
round: number;
actor: string;
action: string;
detail: string;
hpAfter: { player: number; monster: number; companion?: number };
}
export interface TurnResult {
sessionId: string;
round: number;
playerName: string;
monsterName: string;
events: TurnLogEntry[];
playerHp: number;
playerHpMax: number;
playerMana: number;
playerManaMax: number;
monsterHp: number;
monsterHpMax: number;
companion?: {
name: string;
type: 'mira' | 'vell';
hpCurrent: number;
hpMax: number;
manaCurrent: number;
manaMax: number;
activeBuffs: TurnBuff[];
activeDebuffs: TurnBuff[];
} | null;
activeBuffs: TurnBuff[];
activeDebuffs: TurnBuff[];
monsterBuffs: TurnBuff[];
monsterDebuffs: TurnBuff[];
spellCooldowns: Record<string, number>;
bossPhase: number;
status: 'awaiting_player' | 'resolving' | 'finished';
winner?: 'player' | 'monster';
rewards?: {
xp: number;
gold: number;
levelUp: boolean;
newLevel: number;
statPointsGained: number;
};
}
export interface TurnSpell {
id: string;
name: string;
path: string;
pathLevel: number;
manaCost: number;
cooldown: number;
targetType: string;
description: string;
}
export interface DaoPathProgress {
id: string;
path: string;
isPrimary: boolean;
pathPoints: number;
pathLevel: number;
}
// ---------- Items & Economy ----------
export type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface Item {

View File

@@ -16,9 +16,13 @@ export const RARITY_LABELS: Record<string, string> = {
};
export const ZONE_INFO: Record<string, { name: string; emoji: string; color: string }> = {
marais: { name: 'Les Marais', emoji: '🌿', color: '#3ddc84' },
egouts: { name: 'Les Égouts', emoji: '🕳️', color: '#5ba4f5' },
desert: { name: 'Le Désert', emoji: '🏜️', color: '#f4c94e' },
marais: { name: 'Les Marais', emoji: '🌿', color: '#3ddc84' },
egouts: { name: 'Les Égouts', emoji: '🕳️', color: '#5ba4f5' },
desert: { name: 'Le Désert', emoji: '🏜️', color: '#f4c94e' },
ruisseau_miroir: { name: 'Ruisseau Miroir', emoji: '🪞', color: '#88c8e8' },
marais_murmures: { name: 'Marais des Murmures', emoji: '🌫️', color: '#6b8a6b' },
torrent_brise: { name: 'Torrent Brisé', emoji: '🌊', color: '#4a7ab5' },
source_courant: { name: 'Source du Courant', emoji: '✨', color: '#d4af37' },
};
export const STAT_LABELS: Record<string, string> = {

View File

@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { combatApi, characterApi } from '../api/endpoints';
import type { Monster, CombatResult, MultiCombatResult } from '../api/types';
import { Swords, Clock, Zap, Heart, Lock } from 'lucide-react';
import { Swords, Clock, Zap, Heart, Lock, Sparkles } from 'lucide-react';
import { Link } from 'react-router-dom';
import { COMBAT_COST, REST_COST, ATTACK_TYPES, ZONE_INFO } from '../constants';
import { MonsterCard } from '../components/MonsterCard';
import { CombatLogView, MultiCombatView, HistoryEntry } from '../components/CombatViews';
@@ -85,7 +86,12 @@ export function CombatPage() {
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}> Combat</h2>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, color: '#f4c94e', fontSize: 20 }}> Combat</h2>
<Link to="/combat/tactical" className="btn btn-blue" style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, padding: '0.5rem 1rem' }}>
<Sparkles size={14} /> Combat Tactique
</Link>
</div>
<div className="grid-2" style={{ marginBottom: '1rem' }}>
{/* Choix monstre par zone */}

View File

@@ -0,0 +1,420 @@
import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { combatApi, turnCombatApi, characterApi } from '../api/endpoints';
import type { Monster, TurnResult, TurnSpell, TurnBuff } from '../api/types';
import { Swords, Sparkles, PackageOpen, ArrowLeft, Zap, Heart, Shield, Skull, Trophy, Users } from 'lucide-react';
import { COMBAT_COST, ATTACK_TYPES, ZONE_INFO } from '../constants';
import { MonsterCard } from '../components/MonsterCard';
type Phase = 'select' | 'combat' | 'result';
export function TurnCombatPage() {
const qc = useQueryClient();
const [phase, setPhase] = useState<Phase>('select');
const [selectedMonster, setSelectedMonster] = useState<Monster | null>(null);
const [attackType, setAttackType] = useState('melee');
const [combat, setCombat] = useState<TurnResult | null>(null);
const [companion, setCompanion] = useState<'mira' | 'vell' | null>(null);
const [spellMenuOpen, setSpellMenuOpen] = useState(false);
const logRef = useRef<HTMLDivElement>(null);
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const { data: monsters } = useQuery({ queryKey: ['monsters'], queryFn: combatApi.monsters });
const { data: zones } = useQuery({ queryKey: ['zones'], queryFn: combatApi.zones });
const { data: spells } = useQuery({ queryKey: ['turnSpells'], queryFn: turnCombatApi.unlockedSpells });
const endurance = char?.enduranceCurrent ?? 0;
const canFight = endurance >= COMBAT_COST;
// Scroll log vers le bas
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [combat?.events]);
// --- Start combat ---
const startMut = useMutation({
mutationFn: () => turnCombatApi.start(selectedMonster!.id, attackType, companion),
onSuccess: (result) => {
setCombat(result);
setPhase('combat');
setSpellMenuOpen(false);
},
onError: (err: Error) => toast.error(err.message),
});
// --- Submit action ---
const actionMut = useMutation({
mutationFn: (params: { type: string; spellId?: string }) =>
turnCombatApi.action(combat!.sessionId, params.type, params.spellId),
onSuccess: (result) => {
setCombat(result);
setSpellMenuOpen(false);
if (result.status === 'finished') {
setPhase('result');
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['combatHistory'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
}
},
onError: (err: Error) => toast.error(err.message),
});
const doAction = (type: string, spellId?: string) => {
if (actionMut.isPending) return;
actionMut.mutate({ type, spellId });
};
// ========== PHASE: SELECT ==========
if (phase === 'select') {
const monstersByZone = new Map<string, Monster[]>();
for (const m of (monsters ?? [])) {
const zone = (m as any).zone ?? 'marais';
const list = monstersByZone.get(zone) ?? [];
list.push(m);
monstersByZone.set(zone, list);
}
const lockedZones = (zones ?? []).filter((z: any) => !z.unlocked);
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>
<Swords size={18} style={{ display: 'inline', marginRight: 6 }} />
Combat Tactique
</h2>
<div className="grid-2" style={{ marginBottom: '1rem' }}>
<div>
{Array.from(monstersByZone.entries()).map(([zone, zoneMonsters]) => {
const info = ZONE_INFO[zone] ?? { name: zone, emoji: '📍' };
return (
<div key={zone} style={{ marginBottom: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#9ca3af' }}>
{info.emoji} {info.name}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{zoneMonsters.sort((a, b) => a.minLevel - b.minLevel).map(m => (
<MonsterCard
key={m.id}
m={m}
selected={selectedMonster?.id === m.id}
onSelect={() => setSelectedMonster(m)}
playerLevel={char?.level ?? 1}
/>
))}
</div>
</div>
);
})}
{lockedZones.map((z: any) => (
<div key={z.id} className="card" style={{ marginBottom: '0.5rem', opacity: 0.4, textAlign: 'center', padding: '1rem' }}>
<span style={{ fontSize: 13, color: '#6b7a99' }}>{z.emoji} {z.name} Verrouillee</span>
</div>
))}
</div>
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Type d'attaque
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: '1rem' }}>
{ATTACK_TYPES.map(a => (
<div
key={a.id}
className={`card card-hover ${attackType === a.id ? 'card-gold' : ''}`}
onClick={() => setAttackType(a.id)}
style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}
>
<span style={{ fontSize: 18 }}>{a.emoji}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 13, color: attackType === a.id ? '#f4c94e' : '#dce4f0' }}>{a.label}</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>{a.stat}</div>
</div>
</div>
))}
</div>
{/* Compagnon */}
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
<Users size={11} style={{ display: 'inline', marginRight: 4 }} />
Compagnon (optionnel)
</p>
<div style={{ display: 'flex', gap: 6, marginBottom: '1rem' }}>
{[
{ id: null as 'mira' | 'vell' | null, label: 'Solo', emoji: '🐸', desc: '' },
{ id: 'mira' as const, label: 'Mira', emoji: '🌊', desc: 'Support heal + buff' },
{ id: 'vell' as const, label: 'Vell', emoji: '🪨', desc: 'Tank taunt + DPS' },
].map(c => (
<div
key={c.label}
className={`card card-hover ${companion === c.id ? 'card-gold' : ''}`}
onClick={() => setCompanion(c.id)}
style={{ flex: 1, cursor: 'pointer', textAlign: 'center', padding: '0.5rem' }}
>
<div style={{ fontSize: 20 }}>{c.emoji}</div>
<div style={{ fontWeight: 700, fontSize: 12, color: companion === c.id ? '#f4c94e' : '#dce4f0' }}>{c.label}</div>
{c.desc && <div style={{ fontSize: 10, color: '#6b7a99' }}>{c.desc}</div>}
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 8, fontSize: 12, color: canFight ? '#5ba4f5' : '#e84040' }}>
<Zap size={12} /> Cout : {COMBAT_COST} endurance — Dispo : {endurance}
</div>
<button
className="btn btn-red"
style={{ width: '100%', fontSize: 14, padding: '0.75rem', opacity: canFight && selectedMonster ? 1 : 0.5 }}
disabled={!selectedMonster || startMut.isPending || !canFight}
onClick={() => startMut.mutate()}
>
{startMut.isPending ? 'Lancement...' : `Lancer le combat tactique (${COMBAT_COST} endurance)`}
</button>
</div>
</div>
</div>
);
}
// ========== PHASE: COMBAT ==========
if (phase === 'combat' && combat) {
const playerHpPct = Math.round((combat.playerHp / combat.playerHpMax) * 100);
const monsterHpPct = Math.round((combat.monsterHp / combat.monsterHpMax) * 100);
const manaPct = Math.round((combat.playerMana / combat.playerManaMax) * 100);
const isActing = actionMut.isPending;
return (
<div>
<h2 style={{ margin: '0 0 0.5rem', color: '#f4c94e', fontSize: 16 }}>
Tour {combat.round}
</h2>
{/* Combatants */}
<div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
{/* Player */}
<div className="card" style={{ flex: 1, padding: '0.75rem' }}>
<div style={{ fontWeight: 700, fontSize: 14, color: '#3ddc84', marginBottom: 4 }}>
{combat.playerName}
</div>
<BarDisplay label="HP" value={combat.playerHp} max={combat.playerHpMax} pct={playerHpPct} color="#3ddc84" />
<BarDisplay label="MP" value={combat.playerMana} max={combat.playerManaMax} pct={manaPct} color="#5ba4f5" />
<BuffList buffs={combat.activeBuffs} debuffs={combat.activeDebuffs} />
</div>
{/* Companion */}
{combat.companion && (
<div className="card" style={{ flex: 1, padding: '0.75rem', opacity: combat.companion.hpCurrent <= 0 ? 0.4 : 1 }}>
<div style={{ fontWeight: 700, fontSize: 14, color: combat.companion.type === 'mira' ? '#5ba4f5' : '#f4c94e', marginBottom: 4 }}>
{combat.companion.type === 'mira' ? '🌊' : '🪨'} {combat.companion.name}
{combat.companion.hpCurrent <= 0 && ' (KO)'}
</div>
<BarDisplay
label="HP"
value={combat.companion.hpCurrent}
max={combat.companion.hpMax}
pct={Math.round((combat.companion.hpCurrent / combat.companion.hpMax) * 100)}
color={combat.companion.type === 'mira' ? '#5ba4f5' : '#f4c94e'}
/>
<BarDisplay
label="MP"
value={combat.companion.manaCurrent}
max={combat.companion.manaMax}
pct={Math.round((combat.companion.manaCurrent / combat.companion.manaMax) * 100)}
color="#a78bfa"
/>
<BuffList buffs={combat.companion.activeBuffs} debuffs={combat.companion.activeDebuffs} />
</div>
)}
{/* Monster */}
<div className="card" style={{ flex: 1, padding: '0.75rem' }}>
<div style={{ fontWeight: 700, fontSize: 14, color: '#e84040', marginBottom: 4 }}>
{combat.monsterName}
</div>
<BarDisplay label="HP" value={combat.monsterHp} max={combat.monsterHpMax} pct={monsterHpPct} color="#e84040" />
<BuffList buffs={combat.monsterBuffs} debuffs={combat.monsterDebuffs} />
</div>
</div>
{/* Log */}
<div
ref={logRef}
className="card"
style={{ padding: '0.75rem', marginBottom: 12, maxHeight: 200, overflowY: 'auto', fontSize: 12 }}
>
{combat.events.length === 0 && (
<p style={{ color: '#6b7a99', margin: 0 }}>Le combat commence...</p>
)}
{combat.events.map((e, i) => (
<div key={i} style={{ marginBottom: 2, color: eventColor(e.actor, combat!) }}>
<span style={{ color: '#6b7a99' }}>[T{e.round}]</span> {e.detail}
</div>
))}
</div>
{/* Actions */}
{!spellMenuOpen ? (
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-red" style={{ flex: 2 }} disabled={isActing} onClick={() => doAction('attack')}>
<Swords size={14} style={{ display: 'inline', marginRight: 4 }} />
Attaque
</button>
<button
className="btn btn-blue"
style={{ flex: 2 }}
disabled={isActing || !spells?.length}
onClick={() => setSpellMenuOpen(true)}
>
<Sparkles size={14} style={{ display: 'inline', marginRight: 4 }} />
Sorts
</button>
<button className="btn btn-ghost" style={{ flex: 1 }} disabled={isActing} onClick={() => doAction('item')}>
<PackageOpen size={14} style={{ display: 'inline', marginRight: 4 }} />
Items
</button>
<button className="btn btn-ghost" style={{ flex: 1 }} disabled={isActing} onClick={() => doAction('flee')}>
<ArrowLeft size={14} style={{ display: 'inline', marginRight: 4 }} />
Fuir
</button>
</div>
) : (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{(spells ?? []).map(spell => {
const cd = combat.spellCooldowns[spell.id] ?? 0;
const notEnoughMana = combat.playerMana < spell.manaCost;
const disabled = isActing || cd > 0 || notEnoughMana;
return (
<button
key={spell.id}
className={`card card-hover ${disabled ? '' : 'card-gold'}`}
style={{ textAlign: 'left', cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, padding: '0.5rem 0.75rem' }}
disabled={disabled}
onClick={() => !disabled && doAction('spell', spell.id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span style={{ fontWeight: 700, fontSize: 13, color: pathColor(spell.path) }}>{spell.name}</span>
<span style={{ fontSize: 11, color: '#6b7a99', marginLeft: 8 }}>{spell.manaCost} MP</span>
</div>
<div style={{ fontSize: 11 }}>
{cd > 0 ? (
<span style={{ color: '#e84040' }}>CD: {cd}</span>
) : (
<span style={{ color: '#3ddc84' }}>PRET</span>
)}
</div>
</div>
<div style={{ fontSize: 11, color: '#6b7a99', marginTop: 2 }}>{spell.description}</div>
</button>
);
})}
</div>
<button
className="btn btn-ghost"
style={{ width: '100%', marginTop: 8, fontSize: 12 }}
onClick={() => setSpellMenuOpen(false)}
>
Retour
</button>
</div>
)}
</div>
);
}
// ========== PHASE: RESULT ==========
if (phase === 'result' && combat) {
const won = combat.winner === 'player';
return (
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
<div style={{ fontSize: 48, marginBottom: 8 }}>
{won ? <Trophy size={48} color="#f4c94e" /> : <Skull size={48} color="#e84040" />}
</div>
<h2 style={{ color: won ? '#f4c94e' : '#e84040', fontSize: 24, margin: '0 0 8px' }}>
{won ? 'Victoire !' : 'Defaite...'}
</h2>
<p style={{ color: '#6b7a99', fontSize: 14, margin: '0 0 16px' }}>
Combat termine en {combat.round} tour{combat.round > 1 ? 's' : ''}
</p>
{won && combat.rewards && (
<div className="card" style={{ display: 'inline-block', padding: '1rem 2rem', textAlign: 'left', marginBottom: 16 }}>
<div style={{ fontSize: 13, color: '#dce4f0', marginBottom: 4 }}>
+{combat.rewards.xp} XP &nbsp; +{combat.rewards.gold} Or
</div>
{combat.rewards.levelUp && (
<div style={{ fontSize: 14, color: '#f4c94e', fontWeight: 700 }}>
LEVEL UP ! Niveau {combat.rewards.newLevel} (+{combat.rewards.statPointsGained} stat points)
</div>
)}
</div>
)}
<div>
<button
className="btn btn-gold"
style={{ fontSize: 14, padding: '0.75rem 2rem' }}
onClick={() => { setPhase('select'); setCombat(null); }}
>
Retour
</button>
</div>
</div>
);
}
return null;
}
// ========== Sous-composants ==========
function BarDisplay({ label, value, max, pct, color }: { label: string; value: number; max: number; pct: number; color: string }) {
return (
<div style={{ marginBottom: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#6b7a99', marginBottom: 2 }}>
<span>{label}</span>
<span>{value}/{max}</span>
</div>
<div style={{ height: 6, background: '#1a1f2e', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 3, transition: 'width 0.3s' }} />
</div>
</div>
);
}
function BuffList({ buffs, debuffs }: { buffs: TurnBuff[]; debuffs: TurnBuff[] }) {
if (!buffs.length && !debuffs.length) return null;
return (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }}>
{buffs.map(b => (
<span key={b.id} className="badge badge-green" style={{ fontSize: 10 }}>
<Shield size={8} style={{ display: 'inline', marginRight: 2 }} />
{b.name} ({b.remainingTurns})
</span>
))}
{debuffs.map(d => (
<span key={d.id} className="badge badge-red" style={{ fontSize: 10 }}>
{d.name} ({d.remainingTurns})
</span>
))}
</div>
);
}
function eventColor(actor: string, combat: TurnResult): string {
if (actor === combat.playerName) return '#3ddc84';
if (actor === combat.monsterName) return '#e84040';
if (actor === 'Mira') return '#5ba4f5';
if (actor === 'Vell') return '#f4c94e';
return '#dce4f0';
}
function pathColor(path: string): string {
switch (path) {
case 'ecoute': return '#5ba4f5';
case 'resonance': return '#e84040';
case 'harmonie': return '#3ddc84';
default: return '#dce4f0';
}
}

View File

@@ -10,6 +10,7 @@
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
"seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts",
"seed:items": "ts-node -r tsconfig-paths/register src/database/items-seed.ts",
"seed:odyssee": "ts-node -r tsconfig-paths/register src/database/seed-odyssee.ts",
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
"test": "jest",
"test:watch": "jest --watch",

View File

@@ -19,6 +19,7 @@ import { HallOfFameModule } from './halloffame/halloffame.module';
import { ProfileModule } from './profile/profile.module';
import { QuestModule } from './quest/quest.module';
import { ShopModule } from './shop/shop.module';
import { NpcModule } from './npc/npc.module';
import { HealthController } from './common/health.controller';
@Module({
@@ -61,6 +62,7 @@ import { HealthController } from './common/health.controller';
ProfileModule,
QuestModule,
ShopModule,
NpcModule,
],
controllers: [HealthController],
})

View File

@@ -57,6 +57,13 @@ export class Character {
@Column({ name: 'hp_max', default: 100 })
hpMax: number;
// Mana du Courant (sorts — combat tour par tour)
@Column({ name: 'mana_current', default: 50 })
manaCurrent: number;
@Column({ name: 'mana_max', default: 50 })
manaMax: number;
// Endurance — lazy calculation (pas de timer actif)
@Column({ name: 'endurance_saved', default: 100 })
enduranceSaved: number;

View File

@@ -9,17 +9,23 @@ import { AuthModule } from '../auth/auth.module';
import { ItemModule } from '../item/item.module';
import { MaterialModule } from '../material/material.module';
import { CommunityModule } from '../community/community.module';
import { Spell } from './turn/spell.entity';
import { PlayerSpell } from './turn/player-spell.entity';
import { PlayerDaoPath } from './turn/player-dao-path.entity';
import { SpellSystem } from './turn/spell.system';
import { TurnCombatService } from './turn/turn-combat.service';
import { TurnCombatController } from './turn/turn-combat.controller';
@Module({
imports: [
TypeOrmModule.forFeature([Character, CombatLog]),
TypeOrmModule.forFeature([Character, CombatLog, Spell, PlayerSpell, PlayerDaoPath]),
MonsterModule,
AuthModule,
ItemModule,
MaterialModule,
CommunityModule,
],
controllers: [CombatController],
providers: [CombatService],
controllers: [CombatController, TurnCombatController],
providers: [CombatService, SpellSystem, TurnCombatService],
})
export class CombatModule {}

View File

@@ -0,0 +1,425 @@
import {
CompanionState,
CombatSession,
TurnLogEntry,
Buff,
Debuff,
} from './types';
import { calcMonsterDamage, rollCrit, rollDodge } from '../combat.engine';
// ---------- Companion Factory ----------
export type CompanionType = 'mira' | 'vell';
const MIRA_HP_RATIO = 0.6;
const VELL_HP_RATIO = 1.2;
export function createCompanion(
type: CompanionType,
playerHpMax: number,
playerIntelligence: number,
playerForce: number,
): CompanionState {
if (type === 'mira') {
return {
name: 'Mira',
type: 'mira',
hpCurrent: Math.floor(playerHpMax * MIRA_HP_RATIO),
hpMax: Math.floor(playerHpMax * MIRA_HP_RATIO),
manaCurrent: 40,
manaMax: 40,
force: Math.floor(playerForce * 0.3),
agilite: 5,
intelligence: Math.floor(playerIntelligence * 1.2),
chance: 3,
activeBuffs: [],
activeDebuffs: [],
};
}
// vell
return {
name: 'Vell',
type: 'vell',
hpCurrent: Math.floor(playerHpMax * VELL_HP_RATIO),
hpMax: Math.floor(playerHpMax * VELL_HP_RATIO),
manaCurrent: 20,
manaMax: 20,
force: Math.floor(playerForce * 1.3),
agilite: 8,
intelligence: Math.floor(playerIntelligence * 0.3),
chance: 5,
activeBuffs: [],
activeDebuffs: [],
};
}
// ---------- Companion AI Decision ----------
export interface CompanionAction {
action: string;
events: TurnLogEntry[];
}
/**
* Decide et execute l'action du compagnon.
* Modifie la session directement (HP, buffs, etc.).
*/
export function resolveCompanionTurn(session: CombatSession): CompanionAction {
const companion = session.companion;
if (!companion || companion.hpCurrent <= 0) {
return { action: 'ko', events: [] };
}
// Tick buffs/debuffs compagnon
companion.activeBuffs = companion.activeBuffs
.map((b) => ({ ...b, remainingTurns: b.remainingTurns - 1 }))
.filter((b) => b.remainingTurns > 0);
companion.activeDebuffs = companion.activeDebuffs
.map((d) => ({ ...d, remainingTurns: d.remainingTurns - 1 }))
.filter((d) => d.remainingTurns > 0);
// Mana regen compagnon (+3/tour)
companion.manaCurrent = Math.min(companion.manaMax, companion.manaCurrent + 3);
if (companion.type === 'mira') {
return miraAI(session);
}
return vellAI(session);
}
// ==================== MIRA — Harmoniste (support/heal) ====================
// Priorites :
// 1. URGENCE — joueur HP < 25% → heal puissant
// 2. PURGE — joueur a >= 2 debuffs → onde de serenite
// 3. BOSS SPECIAL — boss phase change → dissolution
// 4. SOUTIEN — joueur HP < 40% → heal
// 5. BUFF — joueur n'a pas de buff defense → buff
// 6. ATTAQUE — defaut (rare)
function miraAI(session: CombatSession): CompanionAction {
const c = session.companion!;
const events: TurnLogEntry[] = [];
const playerHpRatio = session.playerHp / session.playerHpMax;
const hpAfter = () => ({
player: session.playerHp,
monster: session.monsterHp,
companion: c.hpCurrent,
});
// 1. URGENCE — joueur HP < 25% → heal puissant
if (playerHpRatio < 0.25 && c.manaCurrent >= 15) {
const heal = Math.floor(c.intelligence * 2) + Math.floor(session.playerHpMax * 0.1);
session.playerHp = Math.min(session.playerHpMax, session.playerHp + heal);
c.manaCurrent -= 15;
// Si HP < 15% et mana suffisant → Symphonie (full heal)
if (playerHpRatio < 0.15 && c.manaCurrent >= 30) {
const fullHeal = session.playerHpMax - session.playerHp;
session.playerHp = session.playerHpMax;
c.manaCurrent -= 30;
// Purge debuffs
session.activeDebuffs = [];
events.push({
round: session.round,
actor: 'Mira',
action: 'Symphonie Restauratrice',
detail: `Mira entonne la Symphonie ! ${session.playerName} est completement soigne (+${fullHeal + heal} HP) et purifie !`,
hpAfter: hpAfter(),
});
return { action: 'symphonie', events };
}
events.push({
round: session.round,
actor: 'Mira',
action: 'Chant Apaisant',
detail: `Mira chante pour ${session.playerName} — +${heal} HP !`,
hpAfter: hpAfter(),
});
return { action: 'heal', events };
}
// 2. PURGE — joueur a >= 2 debuffs
if (session.activeDebuffs.length >= 2 && c.manaCurrent >= 25) {
c.manaCurrent -= 25;
// Buff defense + regen
const defBuff: Buff = {
id: `mira-serenite-${session.round}`,
name: 'Onde de Serenite',
stat: 'defense',
value: 25,
isPercent: true,
remainingTurns: 3,
sourceSpellId: 'mira-serenite',
};
const regenBuff: Buff = {
id: `mira-regen-${session.round}`,
name: 'Regen (Mira)',
stat: 'regen',
value: 5,
isPercent: true,
remainingTurns: 3,
sourceSpellId: 'mira-serenite',
};
session.activeBuffs.push(defBuff, regenBuff);
// Purge 1 debuff
if (session.activeDebuffs.length > 0) {
session.activeDebuffs.shift();
}
events.push({
round: session.round,
actor: 'Mira',
action: 'Onde de Serenite',
detail: `Mira repand une onde de serenite ! Defense +25%, regen active, debuff purifie.`,
hpAfter: hpAfter(),
});
return { action: 'serenite', events };
}
// 3. BOSS SPECIAL — dissolution des buffs boss
if (session.isBoss && session.monsterBuffs.length > 0 && c.manaCurrent >= 20) {
c.manaCurrent -= 20;
const removed = session.monsterBuffs.length;
session.monsterBuffs = [];
events.push({
round: session.round,
actor: 'Mira',
action: 'Dissolution',
detail: `Mira dissout les protections de ${session.monsterName} ! (${removed} buff${removed > 1 ? 's' : ''} retire${removed > 1 ? 's' : ''})`,
hpAfter: hpAfter(),
});
return { action: 'dissolution', events };
}
// 4. SOUTIEN — joueur HP < 40%
if (playerHpRatio < 0.4 && c.manaCurrent >= 15) {
const heal = Math.floor(c.intelligence * 2) + Math.floor(session.playerHpMax * 0.1);
session.playerHp = Math.min(session.playerHpMax, session.playerHp + heal);
c.manaCurrent -= 15;
events.push({
round: session.round,
actor: 'Mira',
action: 'Chant Apaisant',
detail: `Mira chante pour ${session.playerName} — +${heal} HP.`,
hpAfter: hpAfter(),
});
return { action: 'heal', events };
}
// 5. BUFF — joueur sans buff defense actif
const hasDefBuff = session.activeBuffs.some((b) => b.stat === 'defense');
if (!hasDefBuff && c.manaCurrent >= 25) {
c.manaCurrent -= 25;
session.activeBuffs.push({
id: `mira-serenite-${session.round}`,
name: 'Onde de Serenite',
stat: 'defense',
value: 25,
isPercent: true,
remainingTurns: 3,
sourceSpellId: 'mira-serenite',
});
events.push({
round: session.round,
actor: 'Mira',
action: 'Onde de Serenite',
detail: `Mira renforce la defense de l'equipe ! (+25%, 3 tours)`,
hpAfter: hpAfter(),
});
return { action: 'buff', events };
}
// 6. ATTAQUE — defaut (Mira attaque rarement)
const damage = Math.max(1, Math.floor(c.intelligence * 0.8));
session.monsterHp = Math.max(0, session.monsterHp - damage);
events.push({
round: session.round,
actor: 'Mira',
action: 'Attaque',
detail: `Mira lance une onde vers ${session.monsterName}${damage} degats.`,
hpAfter: hpAfter(),
});
return { action: 'attack', events };
}
// ==================== VELL — Resonant (tank/dps) ====================
// Priorites :
// 1. PROTECTION — joueur HP < 30% → taunt (Ancre de Pierre)
// 2. RIPOSTE — Vell a recu un coup au tour precedent → Contre-Courant
// 3. BOSS PHASE — boss phase >= 2 → degats massifs
// 4. OUVERTURE — round <= 2 → onde de choc
// 5. DPS — defaut → attaque force
function vellAI(session: CombatSession): CompanionAction {
const c = session.companion!;
const events: TurnLogEntry[] = [];
const playerHpRatio = session.playerHp / session.playerHpMax;
const hpAfter = () => ({
player: session.playerHp,
monster: session.monsterHp,
companion: c.hpCurrent,
});
// 1. PROTECTION — joueur HP < 30% → taunt
if (playerHpRatio < 0.3 && c.manaCurrent >= 10) {
const hasTaunt = c.activeBuffs.some((b) => b.stat === 'taunt');
if (!hasTaunt) {
c.manaCurrent -= 10;
c.activeBuffs.push({
id: `vell-taunt-${session.round}`,
name: 'Ancre de Pierre',
stat: 'taunt',
value: 1,
isPercent: false,
remainingTurns: 2,
sourceSpellId: 'vell-taunt',
});
c.activeBuffs.push({
id: `vell-def-${session.round}`,
name: 'Defense (Vell)',
stat: 'damage_reduction',
value: 50,
isPercent: true,
remainingTurns: 2,
sourceSpellId: 'vell-taunt',
});
events.push({
round: session.round,
actor: 'Vell',
action: 'Ancre de Pierre',
detail: `Vell s'ancre devant ${session.playerName} ! (taunt + def +50%, 2 tours)`,
hpAfter: hpAfter(),
});
return { action: 'taunt', events };
}
// Taunt deja actif → bouclier de flux sur le joueur
if (c.manaCurrent >= 10) {
c.manaCurrent -= 10;
session.activeBuffs.push({
id: `vell-bouclier-${session.round}`,
name: 'Bouclier de Flux',
stat: 'damage_reduction',
value: 40,
isPercent: true,
remainingTurns: 2,
sourceSpellId: 'vell-bouclier',
});
events.push({
round: session.round,
actor: 'Vell',
action: 'Bouclier de Flux',
detail: `Vell erige un bouclier de flux autour de ${session.playerName} ! (-40% degats, 2 tours)`,
hpAfter: hpAfter(),
});
return { action: 'shield', events };
}
}
// 2. RIPOSTE — si Vell vient de se faire toucher (taunt actif = il prend les coups)
const hasTaunt = c.activeBuffs.some((b) => b.stat === 'taunt');
if (hasTaunt && c.manaCurrent >= 5) {
c.manaCurrent -= 5;
const riposteDmg = Math.floor(c.force * 2);
session.monsterHp = Math.max(0, session.monsterHp - riposteDmg);
events.push({
round: session.round,
actor: 'Vell',
action: 'Contre-Courant',
detail: `Vell contre-attaque ${session.monsterName}${riposteDmg} degats !`,
hpAfter: hpAfter(),
});
return { action: 'riposte', events };
}
// 3. BOSS PHASE >= 2 → degats massifs
if (session.isBoss && session.bossPhase >= 2 && c.manaCurrent >= 15) {
c.manaCurrent -= 15;
const bigDmg = Math.floor(c.force * 3.5);
session.monsterHp = Math.max(0, session.monsterHp - bigDmg);
events.push({
round: session.round,
actor: 'Vell',
action: 'Fracture Sismique',
detail: `Vell fracture le sol sous ${session.monsterName}${bigDmg} degats massifs !`,
hpAfter: hpAfter(),
});
return { action: 'fracture', events };
}
// 4. OUVERTURE — round <= 2 → onde de choc
if (session.round <= 2 && c.manaCurrent >= 8) {
c.manaCurrent -= 8;
const aoeDmg = Math.floor(c.force * 1.5);
session.monsterHp = Math.max(0, session.monsterHp - aoeDmg);
events.push({
round: session.round,
actor: 'Vell',
action: 'Onde de Choc',
detail: `Vell declenche une onde de choc — ${aoeDmg} degats !`,
hpAfter: hpAfter(),
});
return { action: 'onde', events };
}
// 5. DPS — attaque force
const isCrit = rollCrit(c.chance);
let damage = Math.max(1, Math.floor(c.force * 1.2));
if (isCrit) damage = Math.floor(damage * 1.5);
session.monsterHp = Math.max(0, session.monsterHp - damage);
const critText = isCrit ? ' (CRITIQUE !)' : '';
events.push({
round: session.round,
actor: 'Vell',
action: 'Attaque',
detail: `Vell frappe ${session.monsterName}${damage} degats${critText} !`,
hpAfter: hpAfter(),
});
return { action: 'attack', events };
}
// ---------- Monster targets companion if taunt active ----------
/**
* Determine si le monstre doit cibler le compagnon (taunt actif).
* Si oui, applique les degats au compagnon au lieu du joueur.
* Retourne true si le compagnon a absorbe l'attaque.
*/
export function companionAbsorbAttack(
session: CombatSession,
rawDamage: number,
events: TurnLogEntry[],
): boolean {
const c = session.companion;
if (!c || c.hpCurrent <= 0) return false;
const hasTaunt = c.activeBuffs.some((b) => b.stat === 'taunt');
if (!hasTaunt) return false;
// Appliquer reduction de degats compagnon
let damage = rawDamage;
const reduction = c.activeBuffs
.filter((b) => b.stat === 'damage_reduction')
.reduce((acc, b) => acc + (b.isPercent ? b.value : 0), 0);
if (reduction > 0) {
damage = Math.floor(damage * (1 - reduction / 100));
}
damage = Math.max(1, damage);
c.hpCurrent = Math.max(0, c.hpCurrent - damage);
events.push({
round: session.round,
actor: session.monsterName,
action: 'Attaque',
detail: `${c.name} intercepte l'attaque ! ${damage} degats absorbes.${c.hpCurrent <= 0 ? ` ${c.name} est KO !` : ''}`,
hpAfter: {
player: session.playerHp,
monster: session.monsterHp,
companion: c.hpCurrent,
},
});
return true;
}

View File

@@ -0,0 +1,7 @@
import { IsIn } from 'class-validator';
import { DaoPath } from '../types';
export class ChooseDaoPathDto {
@IsIn(['ecoute', 'resonance', 'harmonie'])
path: DaoPath;
}

View File

@@ -0,0 +1,15 @@
import { IsUUID, IsIn, IsOptional } from 'class-validator';
import { AttackType } from '../../../monster/monster.entity';
export class StartTurnCombatDto {
@IsUUID()
monsterId: string;
@IsIn(['melee', 'ranged', 'magic'])
attackType: AttackType;
/** Compagnon IA optionnel — present si quete narrative */
@IsOptional()
@IsIn(['mira', 'vell'])
companion?: 'mira' | 'vell' | null;
}

View File

@@ -0,0 +1,18 @@
import { IsUUID, IsIn, IsOptional } from 'class-validator';
import { TurnActionType } from '../types';
export class TurnActionDto {
@IsUUID()
sessionId: string;
@IsIn(['attack', 'spell', 'item', 'flee'])
type: TurnActionType;
@IsOptional()
@IsUUID()
spellId?: string;
@IsOptional()
@IsUUID()
itemId?: string;
}

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class UnlockSpellDto {
@IsUUID()
spellId: string;
}

View File

@@ -0,0 +1,36 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { Character } from '../../character/entities/character.entity';
import { DaoPath } from './types';
@Entity('player_dao_paths')
@Unique(['characterId', 'path'])
export class PlayerDaoPath {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'character_id' })
characterId: string;
@ManyToOne(() => Character)
@JoinColumn({ name: 'character_id' })
character: Character;
@Column({ type: 'varchar', length: 20 })
path: DaoPath;
@Column({ name: 'is_primary', default: false })
isPrimary: boolean;
@Column({ name: 'path_points', default: 0 })
pathPoints: number;
@Column({ name: 'path_level', default: 0 })
pathLevel: number;
}

View File

@@ -0,0 +1,35 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
Unique,
} from 'typeorm';
import { Character } from '../../character/entities/character.entity';
import { Spell } from './spell.entity';
@Entity('player_spells')
@Unique(['characterId', 'spellId'])
export class PlayerSpell {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'character_id' })
characterId: string;
@ManyToOne(() => Character)
@JoinColumn({ name: 'character_id' })
character: Character;
@Column({ name: 'spell_id' })
spellId: string;
@ManyToOne(() => Spell)
@JoinColumn({ name: 'spell_id' })
spell: Spell;
@CreateDateColumn({ name: 'unlocked_at' })
unlockedAt: Date;
}

View File

@@ -0,0 +1,36 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { DaoPath, SpellTargetType } from './types';
@Entity('spells')
export class Spell {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'varchar', length: 20 })
path: DaoPath;
@Column({ name: 'path_level' })
pathLevel: number;
@Column({ name: 'mana_cost' })
manaCost: number;
@Column()
cooldown: number;
@Column({ name: 'target_type', type: 'varchar', length: 20 })
targetType: SpellTargetType;
@Column({ type: 'text' })
description: string;
/** JSON des effets du sort — SpellEffect[] */
@Column({ type: 'json' })
effects: object;
@Column({ name: 'unlock_cost', default: 0 })
unlockCost: number;
}

View File

@@ -0,0 +1,397 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Spell } from './spell.entity';
import { PlayerSpell } from './player-spell.entity';
import { PlayerDaoPath } from './player-dao-path.entity';
import {
DaoPath,
SpellEffect,
Buff,
Debuff,
CombatSession,
TurnLogEntry,
MANA_REGEN_PER_TURN,
} from './types';
// ---------- Resultat d'un cast ----------
export interface CastResult {
success: boolean;
manaCost: number;
events: TurnLogEntry[];
damageDealt: number;
healDone: number;
buffsApplied: Buff[];
debuffsApplied: Debuff[];
purgedBuffs: number;
purgedDebuffs: number;
}
@Injectable()
export class SpellSystem {
constructor(
@InjectRepository(Spell)
private readonly spellRepo: Repository<Spell>,
@InjectRepository(PlayerSpell)
private readonly playerSpellRepo: Repository<PlayerSpell>,
@InjectRepository(PlayerDaoPath)
private readonly daoPathRepo: Repository<PlayerDaoPath>,
) {}
// ---------- Lecture ----------
/** Sorts debloques par le joueur */
async getUnlockedSpells(characterId: string): Promise<Spell[]> {
const playerSpells = await this.playerSpellRepo.find({
where: { characterId },
relations: ['spell'],
});
return playerSpells.map((ps) => ps.spell);
}
/** Tous les sorts (pour affichage arbre) */
async getAllSpells(): Promise<Spell[]> {
return this.spellRepo.find({ order: { path: 'ASC', pathLevel: 'ASC' } });
}
/** Progression du joueur dans les voies */
async getDaoPaths(characterId: string): Promise<PlayerDaoPath[]> {
return this.daoPathRepo.find({ where: { characterId } });
}
// ---------- Deblocage ----------
async unlockSpell(characterId: string, spellId: string): Promise<PlayerSpell> {
const spell = await this.spellRepo.findOne({ where: { id: spellId } });
if (!spell) throw new BadRequestException('Sort introuvable');
// Verifier que le joueur a la voie et le niveau requis
const daoPath = await this.daoPathRepo.findOne({
where: { characterId, path: spell.path },
});
if (!daoPath) {
throw new BadRequestException(
`Voie ${spell.path} non initiee. Choisissez votre voie du Dao.`,
);
}
if (daoPath.pathPoints < spell.unlockCost) {
throw new BadRequestException(
`Points de voie insuffisants (${daoPath.pathPoints}/${spell.unlockCost})`,
);
}
// Verifier pas deja debloque
const existing = await this.playerSpellRepo.findOne({
where: { characterId, spellId },
});
if (existing) throw new BadRequestException('Sort deja debloque');
// Verifier que le sort precedent dans la voie est debloque (sauf niv 1)
if (spell.pathLevel > 1) {
const previousSpell = await this.spellRepo.findOne({
where: { path: spell.path, pathLevel: spell.pathLevel - 1 },
});
if (previousSpell) {
const hasPrevious = await this.playerSpellRepo.findOne({
where: { characterId, spellId: previousSpell.id },
});
if (!hasPrevious) {
throw new BadRequestException(
`Debloque d'abord ${previousSpell.name} (niveau ${previousSpell.pathLevel})`,
);
}
}
}
// Depenser les points
daoPath.pathPoints -= spell.unlockCost;
if (spell.pathLevel > daoPath.pathLevel) {
daoPath.pathLevel = spell.pathLevel;
}
await this.daoPathRepo.save(daoPath);
const playerSpell = this.playerSpellRepo.create({ characterId, spellId });
return this.playerSpellRepo.save(playerSpell);
}
// ---------- Choix de voie ----------
async choosePrimaryPath(characterId: string, path: DaoPath): Promise<PlayerDaoPath> {
// Verifier qu'aucune voie primaire n'existe deja
const existing = await this.daoPathRepo.findOne({
where: { characterId, isPrimary: true },
});
if (existing) {
throw new BadRequestException(
`Voie primaire deja choisie : ${existing.path}`,
);
}
// Creer les 3 voies, marquer celle choisie comme primaire
const paths: PlayerDaoPath[] = [];
for (const p of ['ecoute', 'resonance', 'harmonie'] as DaoPath[]) {
let daoPath = await this.daoPathRepo.findOne({
where: { characterId, path: p },
});
if (!daoPath) {
daoPath = this.daoPathRepo.create({
characterId,
path: p,
isPrimary: p === path,
pathPoints: p === path ? 1 : 0, // premier point gratuit sur la voie principale
pathLevel: 0,
});
} else {
daoPath.isPrimary = p === path;
}
paths.push(await this.daoPathRepo.save(daoPath));
}
// Debloquer automatiquement le sort de niveau 1 de la voie choisie
const starterSpell = await this.spellRepo.findOne({
where: { path, pathLevel: 1 },
});
if (starterSpell) {
const alreadyUnlocked = await this.playerSpellRepo.findOne({
where: { characterId, spellId: starterSpell.id },
});
if (!alreadyUnlocked) {
await this.playerSpellRepo.save(
this.playerSpellRepo.create({ characterId, spellId: starterSpell.id }),
);
}
}
return paths.find((p) => p.isPrimary)!;
}
// ---------- Cast en combat ----------
/**
* Resout un sort pendant le combat tour par tour.
* Ne modifie PAS la session directement — retourne les effets a appliquer.
*/
async cast(
session: CombatSession,
spellId: string,
casterStats: { intelligence: number; force: number; hpMax: number },
): Promise<CastResult> {
// Verifier que le sort est debloque
const playerSpell = await this.playerSpellRepo.findOne({
where: { characterId: session.characterId, spellId },
relations: ['spell'],
});
if (!playerSpell) {
throw new BadRequestException('Sort non debloque');
}
const spell = playerSpell.spell;
// Verifier mana
if (session.playerMana < spell.manaCost) {
throw new BadRequestException(
`Mana insuffisant (${session.playerMana}/${spell.manaCost})`,
);
}
// Verifier cooldown
const cd = session.spellCooldowns[spellId] ?? 0;
if (cd > 0) {
throw new BadRequestException(
`Sort en cooldown (${cd} tour${cd > 1 ? 's' : ''} restant${cd > 1 ? 's' : ''})`,
);
}
const effects = spell.effects as SpellEffect[];
const events: TurnLogEntry[] = [];
let totalDamage = 0;
let totalHeal = 0;
const buffsApplied: Buff[] = [];
const debuffsApplied: Debuff[] = [];
let purgedBuffs = 0;
let purgedDebuffs = 0;
for (const effect of effects) {
switch (effect.type) {
case 'damage': {
const stat = effect.ratioStat === 'force'
? casterStats.force
: casterStats.intelligence;
const damage = Math.floor(stat * (effect.ratio ?? 1));
totalDamage += damage;
events.push({
round: session.round,
actor: session.playerName,
action: spell.name,
detail: effect.log
.replace('{caster}', session.playerName)
.replace('{target}', session.monsterName)
.replace('{damage}', String(damage)),
hpAfter: {
player: session.playerHp,
monster: Math.max(0, session.monsterHp - damage),
},
});
break;
}
case 'heal': {
// value = % hpMax, ratio = multiplicateur d'int
const fromRatio = Math.floor(
casterStats.intelligence * (effect.ratio ?? 0),
);
const fromPercent = Math.floor(
casterStats.hpMax * ((effect.value ?? 0) / 100),
);
const heal = fromRatio + fromPercent;
totalHeal += heal;
events.push({
round: session.round,
actor: session.playerName,
action: spell.name,
detail: effect.log
.replace('{caster}', session.playerName)
.replace('{target}', session.playerName)
.replace('{heal}', String(heal)),
hpAfter: {
player: Math.min(session.playerHpMax, session.playerHp + heal),
monster: session.monsterHp,
},
});
break;
}
case 'buff': {
const buff: Buff = {
id: `${spell.id}-${effect.stat}-${session.round}`,
name: spell.name,
stat: effect.stat!,
value: effect.value ?? 0,
isPercent: effect.isPercent ?? true,
remainingTurns: effect.duration ?? 1,
sourceSpellId: spell.id,
};
buffsApplied.push(buff);
events.push({
round: session.round,
actor: session.playerName,
action: spell.name,
detail: effect.log
.replace('{caster}', session.playerName)
.replace('{target}', session.playerName),
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
break;
}
case 'debuff': {
const debuff: Debuff = {
id: `${spell.id}-${effect.stat}-${session.round}`,
name: spell.name,
stat: effect.stat!,
value: effect.value ?? 0,
isPercent: effect.isPercent ?? true,
remainingTurns: effect.duration ?? 1,
sourceSpellId: spell.id,
};
debuffsApplied.push(debuff);
events.push({
round: session.round,
actor: session.playerName,
action: spell.name,
detail: effect.log
.replace('{caster}', session.playerName)
.replace('{target}', session.monsterName),
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
break;
}
case 'purge': {
if (effect.stat === 'buff') {
// Purge buffs ennemis
purgedBuffs += effect.value ?? 1;
} else {
// Purge debuffs allies
purgedDebuffs += effect.value ?? 1;
}
events.push({
round: session.round,
actor: session.playerName,
action: spell.name,
detail: effect.log
.replace('{caster}', session.playerName)
.replace('{target}', session.monsterName),
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
break;
}
case 'special': {
// Les effets speciaux sont resolus par le TurnManager (Phase C)
// Ici on les signale dans le log
events.push({
round: session.round,
actor: session.playerName,
action: spell.name,
detail: effect.log
.replace('{caster}', session.playerName)
.replace('{target}', session.monsterName),
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
break;
}
}
}
return {
success: true,
manaCost: spell.manaCost,
events,
damageDealt: totalDamage,
healDone: totalHeal,
buffsApplied,
debuffsApplied,
purgedBuffs,
purgedDebuffs,
};
}
// ---------- Utilitaires combat ----------
/** Regen mana en debut de tour */
regenMana(currentMana: number, maxMana: number): number {
return Math.min(maxMana, currentMana + MANA_REGEN_PER_TURN);
}
/** Tick buffs/debuffs en fin de tour — decremente et retire les expires */
tickBuffs(buffs: Buff[]): Buff[] {
return buffs
.map((b) => ({ ...b, remainingTurns: b.remainingTurns - 1 }))
.filter((b) => b.remainingTurns > 0);
}
tickDebuffs(debuffs: Debuff[]): Debuff[] {
return debuffs
.map((d) => ({ ...d, remainingTurns: d.remainingTurns - 1 }))
.filter((d) => d.remainingTurns > 0);
}
/** Calcule le modificateur total d'un stat depuis les buffs actifs */
getBuffModifier(buffs: Buff[], stat: string): number {
return buffs
.filter((b) => b.stat === stat)
.reduce((acc, b) => acc + (b.isPercent ? b.value : 0), 0);
}
/** Verifie si un debuff specifique est actif */
hasDebuff(debuffs: Debuff[], stat: string): boolean {
return debuffs.some((d) => d.stat === stat);
}
/** Calcule la mana max d'un personnage */
computeMaxMana(intelligence: number): number {
return 50 + intelligence * 2;
}
}

View File

@@ -0,0 +1,117 @@
import {
Controller,
Post,
Get,
Body,
Param,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Request } from 'express';
import { TurnCombatService } from './turn-combat.service';
import { SpellSystem } from './spell.system';
import { StartTurnCombatDto } from './dto/start-turn-combat.dto';
import { TurnActionDto } from './dto/turn-action.dto';
import { ChooseDaoPathDto } from './dto/choose-dao-path.dto';
import { UnlockSpellDto } from './dto/unlock-spell.dto';
import { AuthGuard } from '../../auth/guards/auth.guard';
import { User } from '../../user/user.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from '../../character/entities/character.entity';
@Controller('combat/turn')
@UseGuards(AuthGuard)
export class TurnCombatController {
constructor(
private readonly turnCombatService: TurnCombatService,
private readonly spellSystem: SpellSystem,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
) {}
// ---------- Combat tour par tour ----------
@Post('start')
@HttpCode(HttpStatus.OK)
startCombat(
@Body() dto: StartTurnCombatDto,
@Req() req: Request & { user: User },
) {
return this.turnCombatService.startSession(dto, req.user);
}
@Post('action')
@HttpCode(HttpStatus.OK)
submitAction(
@Body() dto: TurnActionDto,
@Req() req: Request & { user: User },
) {
return this.turnCombatService.submitAction(
dto.sessionId,
{ type: dto.type, spellId: dto.spellId, itemId: dto.itemId },
req.user.id,
);
}
@Get('session/:sessionId')
getSession(
@Param('sessionId') sessionId: string,
@Req() req: Request & { user: User },
) {
return this.turnCombatService.getSession(sessionId, req.user.id);
}
// ---------- Dao & Sorts ----------
@Get('spells')
getAllSpells() {
return this.spellSystem.getAllSpells();
}
@Get('spells/unlocked')
async getUnlockedSpells(@Req() req: Request & { user: User }) {
const character = await this.getCharacter(req.user.id);
return this.spellSystem.getUnlockedSpells(character.id);
}
@Post('spells/unlock')
@HttpCode(HttpStatus.OK)
async unlockSpell(
@Body() dto: UnlockSpellDto,
@Req() req: Request & { user: User },
) {
const character = await this.getCharacter(req.user.id);
return this.spellSystem.unlockSpell(character.id, dto.spellId);
}
@Get('dao')
async getDaoPaths(@Req() req: Request & { user: User }) {
const character = await this.getCharacter(req.user.id);
return this.spellSystem.getDaoPaths(character.id);
}
@Post('dao/choose')
@HttpCode(HttpStatus.OK)
async chooseDaoPath(
@Body() dto: ChooseDaoPathDto,
@Req() req: Request & { user: User },
) {
const character = await this.getCharacter(req.user.id);
return this.spellSystem.choosePrimaryPath(character.id, dto.path);
}
// ---------- Helper ----------
private async getCharacter(userId: string): Promise<Character> {
const character = await this.characterRepo.findOne({
where: { userId },
});
if (!character) {
throw new Error('Aucun personnage trouve');
}
return character;
}
}

View File

@@ -0,0 +1,935 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Character } from '../../character/entities/character.entity';
import { MonsterService } from '../../monster/monster.service';
import { ItemService } from '../../item/item.service';
import { SpellSystem } from './spell.system';
import {
CombatSession,
TurnAction,
TurnResult,
TurnLogEntry,
MonsterAiProfile,
MANA_REGEN_PER_TURN,
FLEE_BASE_CHANCE,
FLEE_AGILITY_BONUS,
SESSION_TTL_MS,
} from './types';
import {
calcPlayerDamage,
calcMonsterDamage,
rollCrit,
rollDodge,
applyXpGain,
xpRequiredForLevel,
CombatantStats,
} from '../combat.engine';
import { CombatLog } from '../combat-log.entity';
import { StartTurnCombatDto } from './dto/start-turn-combat.dto';
import { User } from '../../user/user.entity';
import { v4 as uuidv4 } from 'uuid';
import { createCompanion, resolveCompanionTurn, companionAbsorbAttack } from './companion-ai';
const MAX_ROUNDS = 30;
const COMBAT_ENDURANCE_COST = 5;
const VICTORY_HP_REGEN_RATIO = 0.1;
const DEFEAT_ENDURANCE_PENALTY = 25;
const DEFEAT_HP_RATIO = 0.2;
const DEFEAT_GOLD_LOSS_RATIO = 0.05;
@Injectable()
export class TurnCombatService {
private readonly sessions = new Map<string, CombatSession>();
private cleanupTimer: ReturnType<typeof setInterval>;
constructor(
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
@InjectRepository(CombatLog)
private readonly combatLogRepo: Repository<CombatLog>,
private readonly monsterService: MonsterService,
private readonly itemService: ItemService,
private readonly spellSystem: SpellSystem,
private readonly eventEmitter: EventEmitter2,
private readonly dataSource: DataSource,
) {
this.cleanupTimer = setInterval(() => this.cleanupExpired(), 60_000);
}
onModuleDestroy() {
clearInterval(this.cleanupTimer);
}
// ========== START ==========
async startSession(dto: StartTurnCombatDto, user: User): Promise<TurnResult> {
const character = await this.characterRepo.findOne({
where: { userId: user.id },
});
if (!character) throw new BadRequestException('Aucun personnage trouve');
const elapsed = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
const recharge = Math.floor(elapsed / 3);
const enduranceCurrent = Math.min(
character.enduranceSaved + recharge,
character.enduranceMax,
);
if (enduranceCurrent < COMBAT_ENDURANCE_COST) {
throw new BadRequestException(
`Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST})`,
);
}
if (character.hpCurrent <= 0) {
throw new BadRequestException('Personnage KO — recuperez vos PV');
}
// Session active?
for (const [, sess] of this.sessions) {
if (sess.playerId === user.id && sess.status !== 'finished') {
throw new BadRequestException(
'Combat en cours — terminez-le avant d\'en commencer un nouveau',
);
}
}
const monster = await this.monsterService.findOne(dto.monsterId);
// Equipement
const equipped = await this.itemService.getEquippedItems(character.id);
const FB = 2;
const weaponAttack = equipped.weapon
? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FB
: 0;
const armorDefense = equipped.armor
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FB
: 0;
const iF = (equipped.weapon?.item.forceBonus ?? 0) + (equipped.armor?.item.forceBonus ?? 0);
const iA = (equipped.weapon?.item.agiliteBonus ?? 0) + (equipped.armor?.item.agiliteBonus ?? 0);
const iI = (equipped.weapon?.item.intelligenceBonus ?? 0) + (equipped.armor?.item.intelligenceBonus ?? 0);
const iC = (equipped.weapon?.item.chanceBonus ?? 0) + (equipped.armor?.item.chanceBonus ?? 0);
const pInt = character.intelligence + iI;
const manaMax = this.spellSystem.computeMaxMana(pInt);
// Debiter endurance immediatement
character.enduranceSaved = enduranceCurrent - COMBAT_ENDURANCE_COST;
character.lastEnduranceTs = new Date();
await this.characterRepo.save(character);
const session: CombatSession = {
id: uuidv4(),
playerId: user.id,
characterId: character.id,
playerName: character.name,
playerHp: character.hpCurrent,
playerHpMax: character.hpMax,
playerMana: Math.min(character.manaCurrent, manaMax),
playerManaMax: manaMax,
playerForce: character.force + iF,
playerAgilite: character.agilite + iA,
playerIntelligence: pInt,
playerChance: character.chance + iC,
playerAttack: weaponAttack,
playerDefense: armorDefense,
attackType: dto.attackType,
monsterName: monster.name,
monsterId: monster.id,
monsterHp: monster.hp,
monsterHpMax: monster.hp,
monsterAttack: monster.attack,
monsterDefense: monster.defense,
monsterAiProfile: (monster.aiProfile ?? 'aggressive') as MonsterAiProfile,
monsterGuardActive: false,
monsterLastAction: 'none',
isBoss: monster.isBoss ?? false,
bossPhase: 1,
xpReward: monster.xpReward,
goldMin: monster.goldMin,
goldMax: monster.goldMax,
companion: dto.companion
? createCompanion(
dto.companion,
character.hpMax,
pInt,
character.force + iF,
)
: null,
activeBuffs: [],
activeDebuffs: [],
monsterBuffs: [],
monsterDebuffs: [],
spellCooldowns: {},
round: 1,
log: [],
status: 'awaiting_player',
createdAt: Date.now(),
};
this.sessions.set(session.id, session);
return this.buildTurnResult(session);
}
// ========== ACTION ==========
async submitAction(
sessionId: string,
action: TurnAction,
userId: string,
): Promise<TurnResult> {
const session = this.sessions.get(sessionId);
if (!session) throw new BadRequestException('Session introuvable ou expiree');
if (session.playerId !== userId) throw new BadRequestException('Session invalide');
if (session.status !== 'awaiting_player') {
throw new BadRequestException('Pas votre tour');
}
session.status = 'resolving';
// Regen mana debut de tour
session.playerMana = this.spellSystem.regenMana(
session.playerMana,
session.playerManaMax,
);
const events: TurnLogEntry[] = [];
// --- INITIATIVE ---
// Joueur plus rapide si agilite >= monstre attack/2 (approximation simple)
// En pratique: joueur joue d'abord sauf si monstre est chaotique et roll < 30%
const playerFirst = this.resolveInitiative(session);
if (playerFirst) {
// Joueur → Compagnon → Monstre
const fled = await this.resolvePlayerAction(session, action, events);
if (fled) {
session.log.push(...events);
return this.buildTurnResult(session);
}
if (session.monsterHp <= 0) {
return this.finishCombat(session, events, 'player');
}
// Tour compagnon
this.doCompanionTurn(session, events);
if (session.monsterHp <= 0) {
return this.finishCombat(session, events, 'player');
}
// Tour monstre
this.resolveMonsterTurn(session, events);
if (session.playerHp <= 0) {
return this.finishCombat(session, events, 'monster');
}
} else {
// Monstre → Joueur → Compagnon
events.push({
round: session.round,
actor: session.monsterName,
action: 'Initiative',
detail: `${session.monsterName} est plus rapide !`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
this.resolveMonsterTurn(session, events);
if (session.playerHp <= 0) {
return this.finishCombat(session, events, 'monster');
}
const fled2 = await this.resolvePlayerAction(session, action, events);
if (fled2) {
session.log.push(...events);
return this.buildTurnResult(session);
}
if (session.monsterHp <= 0) {
return this.finishCombat(session, events, 'player');
}
// Tour compagnon
this.doCompanionTurn(session, events);
if (session.monsterHp <= 0) {
return this.finishCombat(session, events, 'player');
}
}
// Fin de tour: tick buffs/debuffs
session.activeBuffs = this.spellSystem.tickBuffs(session.activeBuffs);
session.activeDebuffs = this.spellSystem.tickDebuffs(session.activeDebuffs);
session.monsterBuffs = this.spellSystem.tickBuffs(session.monsterBuffs);
session.monsterDebuffs = this.spellSystem.tickDebuffs(session.monsterDebuffs);
// Tick cooldowns
for (const spellId of Object.keys(session.spellCooldowns)) {
session.spellCooldowns[spellId]--;
if (session.spellCooldowns[spellId] <= 0) {
delete session.spellCooldowns[spellId];
}
}
// Tick regen buffs
this.tickRegenBuffs(session, events);
// Round max
session.round++;
if (session.round > MAX_ROUNDS) {
return this.finishCombat(session, events, 'monster');
}
session.status = 'awaiting_player';
session.log.push(...events);
return this.buildTurnResult(session);
}
// ========== COMPANION TURN ==========
private doCompanionTurn(session: CombatSession, events: TurnLogEntry[]) {
if (!session.companion || session.companion.hpCurrent <= 0) return;
const result = resolveCompanionTurn(session);
events.push(...result.events);
}
// ========== INITIATIVE ==========
private resolveInitiative(session: CombatSession): boolean {
// Joueur joue en premier par defaut (avantage narratif)
// Chaotique: 30% chance de voler l'initiative
if (session.monsterAiProfile === 'chaotic' && Math.random() < 0.3) {
return false;
}
// Agressif: 15% chance si monstre attack > playerDefense * 2
if (
session.monsterAiProfile === 'aggressive' &&
session.monsterAttack > session.playerDefense * 2 &&
Math.random() < 0.15
) {
return false;
}
return true;
}
// ========== PLAYER ACTION ==========
/** Returns true if player fled */
private async resolvePlayerAction(
session: CombatSession,
action: TurnAction,
events: TurnLogEntry[],
): Promise<boolean> {
switch (action.type) {
case 'attack':
this.resolvePlayerAttack(session, events);
return false;
case 'spell':
await this.resolvePlayerSpell(session, action.spellId!, events);
return false;
case 'flee':
if (this.resolvePlayerFlee(session, events)) {
session.status = 'finished';
return true;
}
return false;
case 'item':
events.push({
round: session.round,
actor: session.playerName,
action: 'Item',
detail: `${session.playerName} fouille son sac... (items bientot disponibles)`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
return false;
}
}
// ========== PLAYER ATTACK ==========
private resolvePlayerAttack(session: CombatSession, events: TurnLogEntry[]) {
const stats = this.buildPlayerStats(session);
const baseDamage = calcPlayerDamage(stats, session.monsterDefense);
const isCrit = rollCrit(session.playerChance);
let damage = isCrit ? Math.floor(baseDamage * 1.5) : baseDamage;
// Buff damage
const dmgBuff = this.spellSystem.getBuffModifier(session.activeBuffs, 'damage');
if (dmgBuff > 0) damage = Math.floor(damage * (1 + dmgBuff / 100));
// Monster damage reduction (buffs)
const monsterReduction = this.spellSystem.getBuffModifier(
session.monsterBuffs,
'damage_reduction',
);
if (monsterReduction > 0) {
damage = Math.floor(damage * (1 - monsterReduction / 100));
}
// Monster guard (defensive AI)
if (session.monsterGuardActive) {
damage = Math.floor(damage * 0.5);
}
damage = Math.max(1, damage);
session.monsterHp = Math.max(0, session.monsterHp - damage);
const critText = isCrit ? ' (CRITIQUE !)' : '';
const guardText = session.monsterGuardActive ? ' [garde]' : '';
events.push({
round: session.round,
actor: session.playerName,
action: 'Attaque',
detail: `${session.playerName} attaque ${session.monsterName} pour ${damage} degats${critText}${guardText}`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
}
// ========== PLAYER SPELL ==========
private async resolvePlayerSpell(
session: CombatSession,
spellId: string,
events: TurnLogEntry[],
) {
const result = await this.spellSystem.cast(session, spellId, {
intelligence: session.playerIntelligence,
force: session.playerForce,
hpMax: session.playerHpMax,
});
session.playerMana -= result.manaCost;
session.monsterHp = Math.max(0, session.monsterHp - result.damageDealt);
session.playerHp = Math.min(
session.playerHpMax,
session.playerHp + result.healDone,
);
session.activeBuffs.push(...result.buffsApplied);
session.monsterDebuffs.push(...result.debuffsApplied);
if (result.purgedBuffs > 0) {
session.monsterBuffs = session.monsterBuffs.slice(result.purgedBuffs);
}
if (result.purgedDebuffs > 0) {
session.activeDebuffs = session.activeDebuffs.slice(result.purgedDebuffs);
}
const spell = (
await this.spellSystem.getUnlockedSpells(session.characterId)
).find((s) => s.id === spellId);
if (spell) {
session.spellCooldowns[spellId] = spell.cooldown;
}
events.push(...result.events);
}
// ========== PLAYER FLEE ==========
private resolvePlayerFlee(
session: CombatSession,
events: TurnLogEntry[],
): boolean {
if (session.isBoss) {
events.push({
round: session.round,
actor: session.playerName,
action: 'Fuite',
detail: `${session.playerName} tente de fuir... impossible face a un boss !`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
return false;
}
const chance = FLEE_BASE_CHANCE + session.playerAgilite * FLEE_AGILITY_BONUS;
const success = Math.random() < chance;
events.push({
round: session.round,
actor: session.playerName,
action: 'Fuite',
detail: success
? `${session.playerName} prend la fuite !`
: `${session.playerName} tente de fuir... echec !`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
return success;
}
// ========== MONSTER TURN (AI Profiles) ==========
private resolveMonsterTurn(session: CombatSession, events: TurnLogEntry[]) {
// Stun
if (this.spellSystem.hasDebuff(session.monsterDebuffs, 'stun')) {
events.push({
round: session.round,
actor: session.monsterName,
action: 'Etourdi',
detail: `${session.monsterName} est etourdi et ne peut pas agir !`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
session.monsterLastAction = 'none';
return;
}
// Confusion
const isConfused = this.spellSystem.hasDebuff(session.monsterDebuffs, 'precision');
if (isConfused && Math.random() < 0.3) {
events.push({
round: session.round,
actor: session.monsterName,
action: 'Confusion',
detail: `${session.monsterName} est confus et rate son attaque !`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
session.monsterLastAction = 'none';
return;
}
// AI dispatch
switch (session.monsterAiProfile) {
case 'defensive':
this.monsterAiDefensive(session, events);
break;
case 'chaotic':
this.monsterAiChaotic(session, events);
break;
case 'aggressive':
case 'boss':
default:
this.monsterAiAggressive(session, events);
break;
}
}
// --- Aggressive: toujours attaque, rage si HP bas ---
private monsterAiAggressive(session: CombatSession, events: TurnLogEntry[]) {
session.monsterGuardActive = false;
const hpRatio = session.monsterHp / session.monsterHpMax;
const rageMultiplier = hpRatio < 0.3 ? 1.3 : 1.0;
this.monsterBasicAttack(session, events, rageMultiplier);
session.monsterLastAction = 'attack';
if (rageMultiplier > 1) {
events.push({
round: session.round,
actor: session.monsterName,
action: 'Rage',
detail: `${session.monsterName} est en rage ! (+30% degats)`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
}
}
// --- Defensive: alterne attaque/garde, carapace si HP bas ---
private monsterAiDefensive(session: CombatSession, events: TurnLogEntry[]) {
const hpRatio = session.monsterHp / session.monsterHpMax;
// Carapace: HP < 40%, cooldown implicit (via guard state)
if (hpRatio < 0.4 && !session.monsterGuardActive && session.monsterLastAction !== 'guard') {
session.monsterGuardActive = true;
session.monsterLastAction = 'guard';
events.push({
round: session.round,
actor: session.monsterName,
action: 'Carapace',
detail: `${session.monsterName} se replie dans sa carapace ! (degats reduits de 80%)`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
// Temporary stronger guard buff
session.monsterBuffs.push({
id: `guard-${session.round}`,
name: 'Carapace',
stat: 'damage_reduction',
value: 80,
isPercent: true,
remainingTurns: 1,
sourceSpellId: 'monster-carapace',
});
return;
}
// Normal: alternate attack/guard
if (session.monsterLastAction === 'attack' || session.monsterLastAction === 'none') {
session.monsterGuardActive = true;
session.monsterLastAction = 'guard';
events.push({
round: session.round,
actor: session.monsterName,
action: 'Garde',
detail: `${session.monsterName} se met en garde. (degats recus -50%)`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
} else {
session.monsterGuardActive = false;
this.monsterBasicAttack(session, events, 1.0);
session.monsterLastAction = 'attack';
}
}
// --- Chaotic: random weighted ---
private monsterAiChaotic(session: CombatSession, events: TurnLogEntry[]) {
session.monsterGuardActive = false;
const roll = Math.random();
if (roll < 0.4) {
// 40% — attaque normale
this.monsterBasicAttack(session, events, 1.0);
session.monsterLastAction = 'attack';
} else if (roll < 0.7) {
// 30% — attaque aleatoire (peut cibler compagnon plus tard)
this.monsterBasicAttack(session, events, 0.8 + Math.random() * 0.6);
session.monsterLastAction = 'attack';
} else if (roll < 0.9) {
// 20% — debuff poison
const poisonDmg = Math.max(1, Math.floor(session.playerHpMax * 0.05));
session.activeDebuffs.push({
id: `poison-${session.round}`,
name: 'Poison',
stat: 'poison',
value: poisonDmg,
isPercent: false,
remainingTurns: 3,
sourceSpellId: 'monster-poison',
});
events.push({
round: session.round,
actor: session.monsterName,
action: 'Poison',
detail: `${session.monsterName} empoisonne ${session.playerName} ! (${poisonDmg} degats/tour, 3 tours)`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
session.monsterLastAction = 'attack';
} else {
// 10% — rate son tour
events.push({
round: session.round,
actor: session.monsterName,
action: 'Hesitation',
detail: `${session.monsterName} hesite et perd son tour !`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
session.monsterLastAction = 'none';
}
}
// --- Basic monster attack ---
private monsterBasicAttack(
session: CombatSession,
events: TurnLogEntry[],
multiplier: number,
) {
// Esquive joueur
const isDodged = rollDodge(session.playerChance);
if (isDodged) {
events.push({
round: session.round,
actor: session.monsterName,
action: 'Attaque',
detail: `${session.playerName} esquive l'attaque de ${session.monsterName} !`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
this.checkRiposte(session, events);
return;
}
let damage = calcMonsterDamage(
this.buildMonsterStats(session),
session.playerDefense,
);
damage = Math.floor(damage * multiplier);
// Companion taunt — redirect to companion
if (companionAbsorbAttack(session, damage, events)) {
return;
}
// Player damage reduction buffs
const dmgReduction = this.spellSystem.getBuffModifier(
session.activeBuffs,
'damage_reduction',
);
if (dmgReduction > 0) {
damage = Math.floor(damage * (1 - dmgReduction / 100));
}
// Shield
const shieldIdx = session.activeBuffs.findIndex((b) => b.stat === 'shield');
if (shieldIdx >= 0) {
session.activeBuffs.splice(shieldIdx, 1);
events.push({
round: session.round,
actor: session.monsterName,
action: 'Attaque',
detail: `Le bouclier de ${session.playerName} absorbe l'attaque !`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
this.checkRiposte(session, events);
return;
}
damage = Math.max(1, damage);
session.playerHp = Math.max(0, session.playerHp - damage);
events.push({
round: session.round,
actor: session.monsterName,
action: 'Attaque',
detail: `${session.monsterName} attaque ${session.playerName} pour ${damage} degats.`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
this.checkRiposte(session, events);
}
// ========== RIPOSTE ==========
private checkRiposte(session: CombatSession, events: TurnLogEntry[]) {
const idx = session.activeBuffs.findIndex((b) => b.stat === 'riposte');
if (idx < 0) return;
const riposte = session.activeBuffs[idx];
const damage = Math.floor(session.playerForce * riposte.value);
session.monsterHp = Math.max(0, session.monsterHp - damage);
session.activeBuffs.splice(idx, 1);
events.push({
round: session.round,
actor: session.playerName,
action: 'Contre-Courant',
detail: `${session.playerName} riposte pour ${damage} degats !`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
}
// ========== REGEN TICK ==========
private tickRegenBuffs(session: CombatSession, events: TurnLogEntry[]) {
// HP regen
const regenBuff = session.activeBuffs.find((b) => b.stat === 'regen');
if (regenBuff) {
const amount = Math.floor(session.playerHpMax * (regenBuff.value / 100));
session.playerHp = Math.min(session.playerHpMax, session.playerHp + amount);
events.push({
round: session.round,
actor: session.playerName,
action: 'Regen',
detail: `${session.playerName} regenere ${amount} HP.`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
}
// Poison tick
const poison = session.activeDebuffs.find((d) => d.stat === 'poison');
if (poison) {
const damage = poison.value;
session.playerHp = Math.max(0, session.playerHp - damage);
events.push({
round: session.round,
actor: session.playerName,
action: 'Poison',
detail: `${session.playerName} subit ${damage} degats de poison.`,
hpAfter: { player: session.playerHp, monster: session.monsterHp },
});
}
}
// ========== FINISH COMBAT + PERSIST ==========
private async finishCombat(
session: CombatSession,
events: TurnLogEntry[],
winner: 'player' | 'monster',
): Promise<TurnResult> {
session.monsterHp = Math.max(0, session.monsterHp);
session.playerHp = Math.max(0, session.playerHp);
session.status = 'finished';
session.log.push(...events);
// Persist character state
const result = await this.dataSource.transaction(async (manager) => {
const character = await manager
.getRepository(Character)
.createQueryBuilder('c')
.setLock('pessimistic_write')
.where('c.id = :id', { id: session.characterId })
.getOne();
if (!character) return null;
let rewards: TurnResult['rewards'];
if (winner === 'player') {
const xpEarned = session.xpReward;
const goldEarned =
session.goldMin +
Math.floor(Math.random() * (session.goldMax - session.goldMin + 1));
const levelUp = applyXpGain(character.level, character.xp, xpEarned);
character.xp = levelUp.newXp;
character.level = levelUp.newLevel;
character.statPoints = (character.statPoints ?? 0) + levelUp.statPointsGained;
character.gold += goldEarned;
character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + goldEarned;
character.hpCurrent = Math.min(
character.hpMax,
session.playerHp + Math.floor(character.hpMax * VICTORY_HP_REGEN_RATIO),
);
character.manaCurrent = session.playerMana;
rewards = {
xp: xpEarned,
gold: goldEarned,
levelUp: levelUp.levelsGained > 0,
newLevel: levelUp.newLevel,
statPointsGained: levelUp.statPointsGained,
};
// Combat log
await manager.save(
this.combatLogRepo.create({
characterId: character.id,
monsterId: session.monsterId,
winner: 'player',
totalRounds: session.round,
roundsData: session.log,
xpEarned,
goldEarned,
levelUp: levelUp.levelsGained > 0,
lootMaterialId: null,
lootQuantity: 0,
}),
);
} else {
// Defaite
const elapsed = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
const recharge = Math.floor(elapsed / 3);
const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
character.enduranceSaved = Math.max(0, enduranceCurrent - DEFEAT_ENDURANCE_PENALTY);
character.lastEnduranceTs = new Date();
character.hpCurrent = Math.max(1, Math.floor(character.hpMax * DEFEAT_HP_RATIO));
const goldLost = Math.floor(character.gold * DEFEAT_GOLD_LOSS_RATIO);
character.gold = Math.max(0, character.gold - goldLost);
character.manaCurrent = session.playerMana;
await manager.save(
this.combatLogRepo.create({
characterId: character.id,
monsterId: session.monsterId,
winner: 'monster',
totalRounds: session.round,
roundsData: session.log,
xpEarned: 0,
goldEarned: 0,
levelUp: false,
lootMaterialId: null,
lootQuantity: 0,
}),
);
}
await manager.save(character);
return rewards;
});
// Events post-transaction
if (winner === 'player') {
const cid = session.characterId;
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'combat_wins', increment: 1 });
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: 1 });
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: 1 });
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_monster', targetId: session.monsterId, increment: 1 });
}
return this.buildTurnResult(session, winner, result ?? undefined);
}
// ========== GET SESSION ==========
getSession(sessionId: string, userId: string): TurnResult | null {
const session = this.sessions.get(sessionId);
if (!session || session.playerId !== userId) return null;
return this.buildTurnResult(session);
}
// ========== HELPERS ==========
private buildPlayerStats(session: CombatSession): CombatantStats {
return {
name: session.playerName,
hpCurrent: session.playerHp,
hpMax: session.playerHpMax,
force: session.playerForce,
agilite: session.playerAgilite,
intelligence: session.playerIntelligence,
chance: session.playerChance,
attack: session.playerAttack,
defense: session.playerDefense,
attackType: session.attackType,
};
}
private buildMonsterStats(session: CombatSession): CombatantStats {
return {
name: session.monsterName,
hpCurrent: session.monsterHp,
hpMax: session.monsterHpMax,
force: 0,
agilite: 0,
intelligence: 0,
chance: 0,
attack: session.monsterAttack,
defense: session.monsterDefense,
attackType: 'melee',
};
}
private buildTurnResult(
session: CombatSession,
winner?: 'player' | 'monster',
rewards?: TurnResult['rewards'],
): TurnResult {
return {
sessionId: session.id,
round: session.round,
playerName: session.playerName,
monsterName: session.monsterName,
events: session.log.slice(-15),
playerHp: session.playerHp,
playerHpMax: session.playerHpMax,
playerMana: session.playerMana,
playerManaMax: session.playerManaMax,
monsterHp: session.monsterHp,
monsterHpMax: session.monsterHpMax,
companion: session.companion
? {
name: session.companion.name,
type: session.companion.type,
hpCurrent: session.companion.hpCurrent,
hpMax: session.companion.hpMax,
manaCurrent: session.companion.manaCurrent,
manaMax: session.companion.manaMax,
activeBuffs: session.companion.activeBuffs,
activeDebuffs: session.companion.activeDebuffs,
}
: null,
activeBuffs: session.activeBuffs,
activeDebuffs: session.activeDebuffs,
monsterBuffs: session.monsterBuffs,
monsterDebuffs: session.monsterDebuffs,
spellCooldowns: session.spellCooldowns,
bossPhase: session.bossPhase,
status: session.status,
...(winner && { winner }),
...(rewards && { rewards }),
};
}
private cleanupExpired() {
const now = Date.now();
for (const [id, session] of this.sessions) {
if (now - session.createdAt > SESSION_TTL_MS) {
this.sessions.delete(id);
}
}
}
}

181
src/combat/turn/types.ts Normal file
View File

@@ -0,0 +1,181 @@
// ---------- Dao & Sorts ----------
export type DaoPath = 'ecoute' | 'resonance' | 'harmonie';
export type SpellTargetType = 'enemy' | 'self' | 'ally' | 'all_enemies' | 'all_allies';
export interface SpellDefinition {
id: string;
name: string;
path: DaoPath;
pathLevel: number; // niveau requis dans la voie (1-5)
manaCost: number;
cooldown: number; // en tours
targetType: SpellTargetType;
description: string;
}
export interface SpellEffect {
type: 'damage' | 'heal' | 'buff' | 'debuff' | 'purge' | 'special';
stat?: string; // stat concernee (force, defense, precision...)
value?: number; // valeur absolue ou ratio selon le contexte
ratio?: number; // multiplicateur de stat (ex: Int * 2)
ratioStat?: string; // stat source du ratio
isPercent?: boolean; // true = valeur en %, false = flat
duration?: number; // en tours (pour buff/debuff)
log: string; // template de texte combat
}
// ---------- Buffs / Debuffs ----------
export interface Buff {
id: string;
name: string;
stat: string;
value: number; // +/- en % ou flat selon le type
isPercent: boolean;
remainingTurns: number;
sourceSpellId: string;
}
export interface Debuff {
id: string;
name: string;
stat: string;
value: number;
isPercent: boolean;
remainingTurns: number;
sourceSpellId: string;
}
// ---------- Actions ----------
export type TurnActionType = 'attack' | 'spell' | 'item' | 'flee';
export interface TurnAction {
type: TurnActionType;
spellId?: string;
itemId?: string;
}
// ---------- Session de combat ----------
export type CombatSessionStatus = 'awaiting_player' | 'resolving' | 'finished';
export interface CompanionState {
name: string;
type: 'mira' | 'vell';
hpCurrent: number;
hpMax: number;
manaCurrent: number;
manaMax: number;
force: number;
agilite: number;
intelligence: number;
chance: number;
activeBuffs: Buff[];
activeDebuffs: Debuff[];
}
export interface CombatSession {
id: string;
playerId: string;
characterId: string;
playerName: string;
playerHp: number;
playerHpMax: number;
playerMana: number;
playerManaMax: number;
playerForce: number;
playerAgilite: number;
playerIntelligence: number;
playerChance: number;
playerAttack: number;
playerDefense: number;
attackType: import('../../monster/monster.entity').AttackType;
monsterName: string;
monsterId: string;
monsterHp: number;
monsterHpMax: number;
monsterAttack: number;
monsterDefense: number;
monsterAiProfile: MonsterAiProfile;
monsterGuardActive: boolean; // defensive AI — alternating guard
monsterLastAction: 'attack' | 'guard' | 'none';
isBoss: boolean;
bossPhase: number;
xpReward: number;
goldMin: number;
goldMax: number;
companion: CompanionState | null;
activeBuffs: Buff[];
activeDebuffs: Debuff[];
monsterBuffs: Buff[];
monsterDebuffs: Debuff[];
spellCooldowns: Record<string, number>; // spellId -> tours restants
round: number;
log: TurnLogEntry[];
status: CombatSessionStatus;
createdAt: number;
}
export interface TurnLogEntry {
round: number;
actor: string;
action: string;
detail: string;
hpAfter: { player: number; monster: number; companion?: number };
}
// ---------- Monster AI ----------
export type MonsterAiProfile = 'aggressive' | 'defensive' | 'chaotic' | 'boss';
// ---------- Turn resolution result (retourne au client) ----------
export interface TurnResult {
sessionId: string;
round: number;
playerName: string;
monsterName: string;
events: TurnLogEntry[];
playerHp: number;
playerHpMax: number;
playerMana: number;
playerManaMax: number;
monsterHp: number;
monsterHpMax: number;
companion?: {
name: string;
type: 'mira' | 'vell';
hpCurrent: number;
hpMax: number;
manaCurrent: number;
manaMax: number;
activeBuffs: Buff[];
activeDebuffs: Debuff[];
} | null;
activeBuffs: Buff[];
activeDebuffs: Debuff[];
monsterBuffs: Buff[];
monsterDebuffs: Debuff[];
spellCooldowns: Record<string, number>;
bossPhase: number;
status: CombatSessionStatus;
winner?: 'player' | 'monster';
/** Rewards populated when status === 'finished' && winner === 'player' */
rewards?: {
xp: number;
gold: number;
levelUp: boolean;
newLevel: number;
statPointsGained: number;
};
}
export const MANA_REGEN_PER_TURN = 5;
export const BASE_MANA = 50;
export const MANA_PER_INTELLIGENCE = 2;
export const FLEE_BASE_CHANCE = 0.5;
export const FLEE_AGILITY_BONUS = 0.005;
export const SESSION_TTL_MS = 10 * 60 * 1000; // 10 min

View File

@@ -3,10 +3,14 @@ import { PlayerQuestArc } from '../quest/player-quest-arc.entity';
import { QuestArc } from '../quest/quest-arc.entity';
// Zone unlock chain: each zone requires completing the previous zone's arc
// marais → always open
// egouts → requires "Les Marais du Têtard" arc completed
// desert → requires the egouts arc completed
const ZONE_ORDER = ['marais', 'egouts', 'desert'];
// marais → always open (L'Étang — niv 1-5)
// egouts → requires "Les Marais du Têtard" arc (L'Étang profond — niv 6-10)
// desert → requires egouts arc (L'Étang Brisé — niv 11-15)
// ruisseau_miroir → requires desert arc (Ruisseau Miroir — niv 16-18)
// marais_murmures → requires ruisseau_miroir arc (Marais des Murmures — niv 19-21)
// torrent_brise → requires marais_murmures arc (Torrent Brisé — niv 22-24)
// source_courant → requires torrent_brise arc (Source du Courant — niv 25+)
const ZONE_ORDER = ['marais', 'egouts', 'desert', 'ruisseau_miroir', 'marais_murmures', 'torrent_brise', 'source_courant'];
export async function getUnlockedZones(
characterId: string,

View File

@@ -24,6 +24,10 @@ import { Quest } from '../quest/quest.entity';
import { QuestArc } from '../quest/quest-arc.entity';
import { PlayerQuest } from '../quest/player-quest.entity';
import { PlayerQuestArc } from '../quest/player-quest-arc.entity';
import { Spell } from '../combat/turn/spell.entity';
import { PlayerSpell } from '../combat/turn/player-spell.entity';
import { PlayerDaoPath } from '../combat/turn/player-dao-path.entity';
import { Npc } from '../npc/npc.entity';
// DataSource pour le CLI TypeORM (migrations manuelles)
export const AppDataSource = new DataSource({
@@ -54,6 +58,10 @@ export const AppDataSource = new DataSource({
QuestArc,
PlayerQuest,
PlayerQuestArc,
Spell,
PlayerSpell,
PlayerDaoPath,
Npc,
],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false,

View File

@@ -0,0 +1,147 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class TurnCombatSystem1743004800000 implements MigrationInterface {
name = 'TurnCombatSystem1743004800000';
public async up(queryRunner: QueryRunner): Promise<void> {
// --- Mana sur characters ---
await queryRunner.query(`
ALTER TABLE \`characters\`
ADD COLUMN \`mana_current\` INT NOT NULL DEFAULT 50 AFTER \`hp_max\`,
ADD COLUMN \`mana_max\` INT NOT NULL DEFAULT 50 AFTER \`mana_current\`
`);
// --- AI profile sur monsters ---
await queryRunner.query(`
ALTER TABLE \`monsters\`
ADD COLUMN \`ai_profile\` VARCHAR(20) NOT NULL DEFAULT 'aggressive' AFTER \`zone\`,
ADD COLUMN \`is_boss\` TINYINT(1) NOT NULL DEFAULT 0 AFTER \`ai_profile\`
`);
// --- Table des sorts ---
await queryRunner.query(`
CREATE TABLE \`spells\` (
\`id\` VARCHAR(36) NOT NULL,
\`name\` VARCHAR(100) NOT NULL,
\`path\` VARCHAR(20) NOT NULL,
\`path_level\` INT NOT NULL,
\`mana_cost\` INT NOT NULL,
\`cooldown\` INT NOT NULL,
\`target_type\` VARCHAR(20) NOT NULL,
\`description\` TEXT NOT NULL,
\`effects\` JSON NOT NULL,
\`unlock_cost\` INT NOT NULL DEFAULT 0,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB
`);
// --- Sorts debloques par joueur ---
await queryRunner.query(`
CREATE TABLE \`player_spells\` (
\`id\` VARCHAR(36) NOT NULL,
\`character_id\` VARCHAR(36) NOT NULL,
\`spell_id\` VARCHAR(36) NOT NULL,
\`unlocked_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (\`id\`),
UNIQUE INDEX \`IDX_player_spells_char_spell\` (\`character_id\`, \`spell_id\`),
INDEX \`IDX_player_spells_character\` (\`character_id\`),
CONSTRAINT \`FK_player_spells_character\` FOREIGN KEY (\`character_id\`)
REFERENCES \`characters\`(\`id\`) ON DELETE CASCADE,
CONSTRAINT \`FK_player_spells_spell\` FOREIGN KEY (\`spell_id\`)
REFERENCES \`spells\`(\`id\`) ON DELETE CASCADE
) ENGINE=InnoDB
`);
// --- Progression dans les voies du Dao ---
await queryRunner.query(`
CREATE TABLE \`player_dao_paths\` (
\`id\` VARCHAR(36) NOT NULL,
\`character_id\` VARCHAR(36) NOT NULL,
\`path\` VARCHAR(20) NOT NULL,
\`is_primary\` TINYINT(1) NOT NULL DEFAULT 0,
\`path_points\` INT NOT NULL DEFAULT 0,
\`path_level\` INT NOT NULL DEFAULT 0,
PRIMARY KEY (\`id\`),
UNIQUE INDEX \`IDX_player_dao_char_path\` (\`character_id\`, \`path\`),
INDEX \`IDX_player_dao_character\` (\`character_id\`),
CONSTRAINT \`FK_player_dao_character\` FOREIGN KEY (\`character_id\`)
REFERENCES \`characters\`(\`id\`) ON DELETE CASCADE
) ENGINE=InnoDB
`);
// --- Seed des 15 sorts ---
await queryRunner.query(`
INSERT INTO \`spells\` (\`id\`, \`name\`, \`path\`, \`path_level\`, \`mana_cost\`, \`cooldown\`, \`target_type\`, \`description\`, \`effects\`, \`unlock_cost\`) VALUES
-- Ecoute
(UUID(), 'Perception du Flux', 'ecoute', 1, 10, 3, 'enemy',
'Revele les faiblesses de l''ennemi. Buff +20% degats pendant 2 tours.',
'[{"type":"buff","stat":"damage","value":20,"isPercent":true,"duration":2,"log":"{caster} percoit les failles de {target} !"}]', 0),
(UUID(), 'Chant d''Eveil', 'ecoute', 2, 20, 2, 'enemy',
'Degats magiques + debuff Confusion (-30% precision, 2 tours).',
'[{"type":"damage","ratio":2,"ratioStat":"intelligence","log":"{caster} entonne le Chant d''Eveil — {damage} degats !"},{"type":"debuff","stat":"precision","value":30,"isPercent":true,"duration":2,"log":"{target} est Confus !"}]', 3),
(UUID(), 'Ancrage Memoriel', 'ecoute', 3, 15, 4, 'self',
'Annule le prochain debuff ou purifie un debuff actif.',
'[{"type":"purge","stat":"debuff","value":1,"log":"{caster} ancre sa memoire — debuff annule !"}]', 6),
(UUID(), 'Murmure du Courant', 'ecoute', 4, 25, 3, 'enemy',
'Degats magiques (Int x2.5) + drain mana si ennemi caster.',
'[{"type":"damage","ratio":2.5,"ratioStat":"intelligence","log":"{caster} murmure au Courant — {damage} degats !"},{"type":"special","stat":"mana_drain","value":15,"log":"Le Courant aspire l''energie de {target} !"}]', 10),
(UUID(), 'Chant de l''Oubli', 'ecoute', 5, 35, 5, 'enemy',
'Reset cooldowns ennemis + degats (Int x3). Boss : -1 buff au lieu du reset.',
'[{"type":"damage","ratio":3,"ratioStat":"intelligence","log":"{caster} libere le Chant de l''Oubli — {damage} degats !"},{"type":"special","stat":"cooldown_reset","value":0,"log":"Les capacites de {target} sont perturbees !"}]', 15),
-- Resonance
(UUID(), 'Onde de Choc', 'resonance', 1, 15, 2, 'all_enemies',
'Degats physiques AoE (Force x1.5) a tous les ennemis.',
'[{"type":"damage","ratio":1.5,"ratioStat":"force","log":"{caster} declenche une Onde de Choc — {damage} degats !"}]', 0),
(UUID(), 'Bouclier de Flux', 'resonance', 2, 20, 4, 'self',
'Reduit les degats recus de 40% pendant 2 tours.',
'[{"type":"buff","stat":"damage_reduction","value":40,"isPercent":true,"duration":2,"log":"{caster} erige un Bouclier de Flux !"}]', 3),
(UUID(), 'Contre-Courant', 'resonance', 3, 15, 3, 'self',
'Riposte automatique au prochain coup recu (Force x2).',
'[{"type":"buff","stat":"riposte","value":2,"isPercent":false,"duration":1,"log":"{caster} se prepare a la riposte !"}]', 6),
(UUID(), 'Ancre de Pierre', 'resonance', 4, 25, 4, 'self',
'Taunt + boost defense 50% pendant 2 tours.',
'[{"type":"buff","stat":"taunt","value":1,"isPercent":false,"duration":2,"log":"{caster} s''ancre dans la pierre !"},{"type":"buff","stat":"defense","value":50,"isPercent":true,"duration":2,"log":"Defense renforcee !"}]', 10),
(UUID(), 'Fracture Sismique', 'resonance', 5, 40, 5, 'enemy',
'Degats massifs (Force x3.5) + Stun 1 tour.',
'[{"type":"damage","ratio":3.5,"ratioStat":"force","log":"{caster} fracture le sol — {damage} degats !"},{"type":"debuff","stat":"stun","value":1,"isPercent":false,"duration":1,"log":"{target} est etourdi !"}]', 15),
-- Harmonie
(UUID(), 'Chant Apaisant', 'harmonie', 1, 15, 2, 'ally',
'Soin (Int x2 + 10% hpMax).',
'[{"type":"heal","ratio":2,"ratioStat":"intelligence","value":10,"log":"{caster} entonne un chant apaisant — {target} recupere {heal} HP !"}]', 0),
(UUID(), 'Dissolution', 'harmonie', 2, 20, 3, 'enemy',
'Retire tous les buffs d''un ennemi.',
'[{"type":"purge","stat":"buff","value":99,"log":"{caster} dissout les protections de {target} !"}]', 3),
(UUID(), 'Onde de Serenite', 'harmonie', 3, 25, 4, 'all_allies',
'Buff defense +25% + regen 5% hpMax/tour (3 tours) a toute l''equipe.',
'[{"type":"buff","stat":"defense","value":25,"isPercent":true,"duration":3,"log":"Une onde de serenite enveloppe l''equipe !"},{"type":"buff","stat":"regen","value":5,"isPercent":true,"duration":3,"log":"Regeneration active !"}]', 6),
(UUID(), 'Lien du Courant', 'harmonie', 4, 20, 3, 'ally',
'Transfere 30% des degats du joueur au compagnon (ou inverse) pendant 3 tours.',
'[{"type":"buff","stat":"damage_link","value":30,"isPercent":true,"duration":3,"log":"{caster} tisse un lien de Courant avec {target} !"}]', 10),
(UUID(), 'Symphonie Restauratrice', 'harmonie', 5, 45, 6, 'all_allies',
'Full heal equipe + purge tous debuffs + bouclier 1 coup.',
'[{"type":"heal","ratio":0,"ratioStat":"intelligence","value":100,"log":"La Symphonie Restauratrice guerit toute l''equipe !"},{"type":"purge","stat":"debuff","value":99,"log":"Tous les debuffs sont purges !"},{"type":"buff","stat":"shield","value":1,"isPercent":false,"duration":1,"log":"Un bouclier protege chacun du prochain coup !"}]', 15)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS \`player_spells\``);
await queryRunner.query(`DROP TABLE IF EXISTS \`player_dao_paths\``);
await queryRunner.query(`DROP TABLE IF EXISTS \`spells\``);
await queryRunner.query(`ALTER TABLE \`monsters\` DROP COLUMN \`is_boss\`, DROP COLUMN \`ai_profile\``);
await queryRunner.query(`ALTER TABLE \`characters\` DROP COLUMN \`mana_max\`, DROP COLUMN \`mana_current\``);
}
}

View File

@@ -0,0 +1,420 @@
import { DataSource } from 'typeorm';
import { QuestArc } from '../quest/quest-arc.entity';
import { Quest } from '../quest/quest.entity';
/**
* Seed Phase 3 — L'Odyssée d'un têtard
*
* Chaque quête raconte une scène de l'histoire.
* Les story_event sont des moments narratifs purs (auto-complete).
* Les combats sont légers et ont du sens (1 boss, pas 10 mobs).
* Les textes (acceptText / completeText) sont les dialogues du PNJ.
*/
export async function seedOdysseeQuests(dataSource: DataSource) {
const arcRepo = dataSource.getRepository(QuestArc);
const questRepo = dataSource.getRepository(Quest);
const monsters = await dataSource.query('SELECT id, name FROM monsters');
const m = new Map<string, string>(monsters.map((r: any) => [r.name, r.id]));
const materials = await dataSource.query('SELECT id, name FROM materials');
const mat = new Map<string, string>(materials.map((r: any) => [r.name, r.id]));
let questsAdded = 0;
// ═══════════════════════════════════════════════════
// ARC 4 — LE RUISSEAU MIROIR (ch.9)
// "L'eau claire comme du verre montre ce qu'on ne veut pas voir."
// ═══════════════════════════════════════════════════
let arc4 = await arcRepo.findOne({ where: { name: 'Le Ruisseau Miroir' } });
if (!arc4) {
arc4 = await arcRepo.save(arcRepo.create({
name: 'Le Ruisseau Miroir',
description: 'Gorn en parlait : un ruisseau qui montre ce qu\'on ne veut pas voir. Mira, Vell et toi devez l\'affronter ensemble.',
zone: 'ruisseau_miroir',
sortOrder: 4,
minLevel: 15,
}));
}
const arc4Quests = [
{
name: 'Le Serment des Trois',
description: 'Après la destruction de l\'étang, Mira et Vell vous rejoignent. Ensemble, vous faites un serment.',
acceptText: 'Mira tend sa nageoire : « Que le courant, même blessé, nous porte ensemble. »\nVell la touche, puis vous. Une onde frémit autour de vous, comme une promesse.\nSous une pierre fissurée, un objet luit : un fragment de coquille laissé par Gorn. Dessus, un mot : « Continue. »',
completeText: 'Le Serment des Trois est scellé. Votre chemin est tracé. L\'Odyssée commence vraiment.',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 400,
rewardGold: 200,
rewardTitle: null as string | null,
arcId: arc4.id,
arcOrder: 1,
zone: null as string | null,
minLevel: 15,
repeatable: false,
},
{
name: 'L\'Eau qui ne Ment Pas',
description: 'Le Ruisseau Miroir projette des doubles sombres de vous-même. Ils murmurent vos peurs.',
acceptText: 'Mira fronce les sourcils : « Le Ruisseau Miroir. Gorn en parlait. Il montre ce que l\'on ne veut pas voir. »\nVous plongez dans une eau si transparente que le ciel s\'y reflète sans déformation. Puis les reflets changent. Votre double vous fixe, le regard dur : « Tu crois comprendre le courant ? Tu n\'es qu\'un songeur. »',
completeText: 'Le Reflet s\'efface dans un éclat de cristal. Ses derniers mots résonnent encore... mais ils n\'ont plus de pouvoir sur vous.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Reflet Sombre') ?? null,
objectiveCount: 1,
rewardXp: 500,
rewardGold: 200,
rewardTitle: null,
arcId: arc4.id,
arcOrder: 2,
zone: 'ruisseau_miroir',
minLevel: 15,
repeatable: false,
},
{
name: 'L\'Épreuve de Vell',
description: 'Vell affronte son propre reflet. Il doit accepter que la force brute ne suffit pas.',
acceptText: 'Le double de Vell rit, moqueur : « Tu n\'as rien protégé. Gorn est parti, l\'étang est mort. À quoi sers-tu, si tu ne peux frapper assez fort ? »\nVell hurle, frappe l\'eau. Mais le doute s\'insinue dans ses membres. Il se fige.\nVous devez l\'aider — vaincre les Échos de Doute qui l\'emprisonnent.',
completeText: 'Mira chante une note pure. La torpeur cède. Vell redresse la tête, tremblant mais libre : « Ensemble, nous sommes plus que nos reflets. »',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Écho de Doute') ?? null,
objectiveCount: 2,
rewardXp: 700,
rewardGold: 300,
rewardTitle: null,
arcId: arc4.id,
arcOrder: 3,
zone: 'ruisseau_miroir',
minLevel: 16,
repeatable: false,
},
{
name: 'Le Chant de Mira',
description: 'Mira fait face à son reflet. Il lui pose la question qu\'elle évite depuis toujours.',
acceptText: 'Le reflet de Mira chante doucement. Une mélodie pure. Puis il cesse, la fixant :\n« Le chant est en toi. Pourquoi le caches-tu ? Peur de briser l\'harmonie ? Ou peur de ce que tu deviendrais ? »\nMira ferme les yeux. Quand elle les rouvre, elle chante. Fort. L\'eau vibre autour d\'elle.',
completeText: 'Le Ruisseau Miroir frémit sous le chant de Mira. Les reflets tremblent, se fissurent, et disparaissent.\nMira sourit, grave : « Le chant guérit. Mais il faut l\'entendre. Et l\'accepter. »',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 600,
rewardGold: 250,
rewardTitle: null,
arcId: arc4.id,
arcOrder: 4,
zone: null,
minLevel: 16,
repeatable: false,
},
{
name: 'Le Gardien du Passage',
description: 'Au cœur du Ruisseau, un dernier gardien bloque le chemin. Il ne vous laissera passer que si vous vous êtes acceptés.',
acceptText: 'Une forme massive se dresse dans l\'eau cristalline. Le Gardien du Reflet. Il est fait de tous les doutes que vous avez laissés derrière vous, condensés en une seule entité.\nIl ne parle pas. Il attend.',
completeText: 'Le Gardien s\'effrite comme du verre au soleil. Un éclat tombe dans l\'eau — un Éclat de Miroir qui chante doucement.\nVous l\'avez. Un Fragment du Chant Perdu.\nL\'eau s\'opacifie, l\'air se rafraîchit. Le Miroir est passé. Le courant vous appelle plus loin.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Gardien du Reflet') ?? null,
objectiveCount: 1,
rewardXp: 1200,
rewardGold: 500,
rewardTitle: 'Vainqueur du Miroir',
arcId: arc4.id,
arcOrder: 5,
zone: 'ruisseau_miroir',
minLevel: 17,
repeatable: false,
},
];
// ═══════════════════════════════════════════════════
// ARC 5 — LE MARAIS DES MURMURES (ch.10)
// "L'eau retient les souvenirs et les murmure."
// ═══════════════════════════════════════════════════
let arc5 = await arcRepo.findOne({ where: { name: 'Le Marais des Murmures' } });
if (!arc5) {
arc5 = await arcRepo.save(arcRepo.create({
name: 'Le Marais des Murmures',
description: 'Un marais oppressant où l\'eau chuchote des vérités anciennes. Une sage vous y attend.',
zone: 'marais_murmures',
sortOrder: 5,
minLevel: 18,
}));
}
const arc5Quests = [
{
name: 'Les Voix dans la Brume',
description: 'Le marais murmure. Des voix émergent du courant lui-même, portant des bribes de mots et de souvenirs.',
acceptText: 'Mira s\'arrête, l\'oreille tendue : « Entendez-vous… ces voix ? »\nVell fronce les sourcils : « C\'est le vent. Des sifflements, rien de plus. »\nMais vous, vous n\'écoutez pas avec vos oreilles. Vous percevez quelque chose de plus profond.\nLes spectres du marais errent entre les arbres morts. Dissipez-les pour avancer.',
completeText: 'Les spectres se dissipent en murmures. Leurs dernières paroles flottent dans l\'air comme un écho de quelque chose de très ancien.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Spectre de Brume') ?? null,
objectiveCount: 3,
rewardXp: 600,
rewardGold: 250,
rewardTitle: null as string | null,
arcId: arc5.id,
arcOrder: 1,
zone: 'marais_murmures',
minLevel: 18,
repeatable: false,
},
{
name: 'La Batracienne',
description: 'Dans une clairière aquatique, une forme ancienne vous attend. Elle savait que vous viendriez.',
acceptText: 'Dans une clairière, vous voyez une forme. Une batracienne ancienne, vêtue de lianes et d\'algues, aux yeux voilés mais brillants d\'une lumière verte.\n« Bienvenue, voyageurs. Le Marais vous attendait. »\nVous sentez une vibration dans l\'eau, comme une reconnaissance.\n« Tu portes le chant en germe, petit têtard. Mais avant la source, il faut comprendre le courant. Veux-tu entendre ? »',
completeText: 'Vous acquiescez. La Batracienne pose une patte sur votre front, et l\'eau se trouble autour de vous...',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 500,
rewardGold: 200,
rewardTitle: null,
arcId: arc5.id,
arcOrder: 2,
zone: null as string | null,
minLevel: 19,
repeatable: false,
},
{
name: 'La Vision',
description: 'La Batracienne vous montre le passé. Vous voyez l\'Hydre... telle qu\'elle était avant.',
acceptText: 'Vision.\nDes étangs anciens, des batraciens chantant sous une lune violette. L\'Hydre, paisible autrefois, gardienne des eaux profondes.\nPuis la rupture : un chant dévoyé, une note brisée, la douleur de l\'Hydre devenue chaos.\nVous rouvrez les yeux, bouleversé.\n« L\'Hydre était la clef… Elle est la mémoire déformée. Nous devons la guérir, pas la fuir. »',
completeText: 'La Batracienne hoche la tête : « Tu entends. Le chant te choisira, si tu choisis de l\'écouter. »\nDans l\'air, une note pure monte. Un fragment du chant, fragile mais réel. Vous l\'accueillez, le gravez en vous.',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 800,
rewardGold: 350,
rewardTitle: null,
arcId: arc5.id,
arcOrder: 3,
zone: null,
minLevel: 19,
repeatable: false,
},
{
name: 'La Mémoire de l\'Hydre',
description: 'Un écho de l\'Hydre hante le marais. Ce n\'est pas l\'Hydre elle-même — c\'est sa douleur, incarnée.',
acceptText: 'Vell murmure : « Ce que tu as vu, Tetardtek… qu\'est-ce que cela signifie pour nous ? »\n« L\'Hydre n\'est pas qu\'un monstre. Elle était la gardienne d\'un équilibre que le chant maintenait. Quand le chant a été brisé, elle a sombré. »\nMira : « Gorn aurait dit que toute chose brisée peut être transformée. »\nUne ombre se dresse devant vous — la Mémoire de l\'Hydre. Sa douleur faite chair.',
completeText: 'La Mémoire se dissipe en un soupir. Une fiole de brume reste — la Brume Condensée, dernier écho du chant de l\'Hydre dans ce marais.\nVell sourit légèrement : « Gorn aurait ajouté : "l\'eau qui dort peut devenir torrent." »\nVous riez doucement, unis par le souvenir.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Mémoire de l\'Hydre') ?? null,
objectiveCount: 1,
rewardXp: 1500,
rewardGold: 600,
rewardTitle: 'Porteur de Vérité',
arcId: arc5.id,
arcOrder: 4,
zone: 'marais_murmures',
minLevel: 20,
repeatable: false,
},
];
// ═══════════════════════════════════════════════════
// ARC 6 — LE TORRENT BRISÉ (ch.11)
// "La force brute ne passe pas."
// ═══════════════════════════════════════════════════
let arc6 = await arcRepo.findOne({ where: { name: 'Le Torrent Brisé' } });
if (!arc6) {
arc6 = await arcRepo.save(arcRepo.create({
name: 'Le Torrent Brisé',
description: 'Des eaux violentes, des rochers acérés. Vell doit apprendre sa leçon la plus dure.',
zone: 'torrent_brise',
sortOrder: 6,
minLevel: 21,
}));
}
const arc6Quests = [
{
name: 'Le Courant Furieux',
description: 'Le torrent gronde. Des élémentaux de remous protègent le passage.',
acceptText: 'Le paysage change, devenant plus escarpé, plus sauvage. Des rochers acérés fendent l\'onde, et l\'eau gronde avec violence.\nVell observe le flot tumultueux, son regard s\'allumant d\'un feu ancien : « Laissez-moi ouvrir la voie. »\nIl s\'élance, défiant les remous... mais le torrent ne se laisse pas dompter.',
completeText: 'Les élémentaux reculent, mais Vell halète. L\'eau résiste à chaque poussée. La force brute ne suffira pas ici.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Élémental de Remous') ?? null,
objectiveCount: 2,
rewardXp: 800,
rewardGold: 350,
rewardTitle: null as string | null,
arcId: arc6.id,
arcOrder: 1,
zone: 'torrent_brise',
minLevel: 21,
repeatable: false,
},
{
name: 'La Chute de Vell',
description: 'Vell s\'obstine et le torrent le projette contre un rocher. Dans l\'eau noire, il entend une voix.',
acceptText: 'Vell rugit, frappe l\'eau : « Je dois réussir ! Je dois être assez fort ! Sinon… sinon Gorn est parti pour rien. »\nMais le torrent, indifférent, le projette contre un rocher. Étourdi, il sombre.\nLà, dans la noirceur du courant, une voix : « Crois... ou coule. »\nVous devez le repérer et le tirer vers la rive.',
completeText: 'Mira tend la nageoire, chantant une note pure. La torpeur cède.\nVell haletait, brisé, non de blessures, mais d\'orgueil.\n« J\'ai échoué... encore. J\'ai laissé Gorn partir... sans rien apprendre. »\nMira s\'approche : « Tu as trop voulu porter seul. Le courant ne se force pas, Vell. Il se suit, en confiance. »',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Roc Vivant') ?? null,
objectiveCount: 1,
rewardXp: 1000,
rewardGold: 400,
rewardTitle: null,
arcId: arc6.id,
arcOrder: 2,
zone: 'torrent_brise',
minLevel: 22,
repeatable: false,
},
{
name: 'La Voie Étroite',
description: 'Mira perçoit un chemin invisible. Ensemble, vous pouvez traverser.',
acceptText: 'Mira lève les yeux. Elle perçoit une veine plus douce, une trajectoire étroite, où l\'eau glisse sans heurt.\n« Là. Nous passerons ensemble. »\nGuidés par elle, vous vous laissez porter. Tetardtek fredonne le fragment du chant. Mira lit les remous. Vell nage entre vous, apaisé.\nLe torrent, sévère mais pas cruel, vous accueille.',
completeText: 'Quand vous atteignez l\'autre rive, Vell regarde en arrière. Il comprend.\n« La vraie force... c\'est de ne pas lutter seul. C\'est de croire. Nous sommes un flot, pas des gouttes isolées. »\nUn fragment de confiance naît en lui, pur et solide.',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 600,
rewardGold: 300,
rewardTitle: null,
arcId: arc6.id,
arcOrder: 3,
zone: null as string | null,
minLevel: 22,
repeatable: false,
},
{
name: 'La Cascade Éveillée',
description: 'Une cascade souterraine bloque le passage. La force brute est inutile. Seule la résonance peut l\'ouvrir.',
acceptText: 'Vell s\'assit dans l\'eau, à l\'écoute. Il perçoit le rythme, la pulsation, la faille.\nUn souvenir de Gorn résonne : « Le roc le plus solide est celui qui a dansé avec l\'eau. »\nVell se laisse aller. En vibrant avec l\'onde, il amplifie le courant.\nMais la Cascade ne se laissera pas ouvrir sans combat.',
completeText: 'Les roches cèdent, l\'eau s\'ouvre. Vell a trouvé sa force : maîtrise, patience, résonance.\nIl n\'est plus une force brute. Il est un écho, une réponse.\nVous émergez transformés, épuisés mais éveillés. L\'eau vibre autour de vous, vous reconnaissant comme héritiers du chant.',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Cascade Éveillée') ?? null,
objectiveCount: 1,
rewardXp: 2000,
rewardGold: 800,
rewardTitle: 'Maître du Torrent',
arcId: arc6.id,
arcOrder: 4,
zone: 'torrent_brise',
minLevel: 23,
repeatable: false,
},
];
// ═══════════════════════════════════════════════════
// ARC 7 — LA SOURCE DU COURANT (ch.12)
// "Le lieu légendaire où le Chant est né."
// ═══════════════════════════════════════════════════
let arc7 = await arcRepo.findOne({ where: { name: 'La Source du Courant' } });
if (!arc7) {
arc7 = await arcRepo.save(arcRepo.create({
name: 'La Source du Courant',
description: 'Le courant vous guide vers des terres inconnues où la lumière devient plus claire, presque éthérée.',
zone: 'source_courant',
sortOrder: 7,
minLevel: 24,
}));
}
const arc7Quests = [
{
name: 'Les Gardiens Sacrés',
description: 'La Source est protégée. Des gardiens anciens testent ceux qui s\'approchent.',
acceptText: 'Le sol change, les pierres deviennent translucides, le courant chante. Vous approchez d\'un lieu légendaire.\nDevant vous, des formes lumineuses se matérialisent — les Gardiens de la Vasque. Ils ne sont pas hostiles. Ils évaluent.',
completeText: 'Les Gardiens s\'écartent. Vous avez été jugés dignes. La vasque de pierre apparaît, creusée par le temps, où l\'eau jaillit en fils de lumière.\nMira recule, les yeux écarquillés : « C\'est... magnifique. »',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Gardien de la Vasque') ?? null,
objectiveCount: 2,
rewardXp: 1200,
rewardGold: 500,
rewardTitle: null as string | null,
arcId: arc7.id,
arcOrder: 1,
zone: 'source_courant',
minLevel: 24,
repeatable: false,
},
{
name: 'Les Trois Visions',
description: 'La Source vous enveloppe. Chacun de vous reçoit une vision.',
acceptText: 'La Source vibre. Trois vagues s\'en détachent, vous enveloppant.\nVous voyez l\'étang renaître, inondé de chant. Gorn est là, devenu grenouille à la peau d\'argent, le regard paisible. Il murmure : « Le courant ne se possède pas. Il se transmet. Porte-le, et fais-le vivre. »\nMira flotte dans une eau sans fin, son chant se mêlant à celui de milliers de voix.\nVell se voit face à lui-même, plus grand, plus calme. Une force tranquille.',
completeText: 'Quand les vagues se dissipent, vous vous regardez. Transformés.\nDans la vasque, un filament de lumière violette s\'élève, spiralant dans l\'air, se divisant en trois. Il entre en vous, scellant votre lien au chant.\nVous comprenez, sans mots, que vous êtes désormais capables de cultiver le Dao du Courant.',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 1500,
rewardGold: 600,
rewardTitle: null,
arcId: arc7.id,
arcOrder: 2,
zone: null as string | null,
minLevel: 25,
repeatable: false,
},
{
name: 'L\'Esprit du Chant',
description: 'Un dernier esprit erre près de la vasque. Il porte le fragment final — celui que Gorn n\'a jamais pu atteindre.',
acceptText: 'Tetardtek tend une nageoire : l\'eau y répond, traçant un cercle lumineux.\nMira chante doucement : les remous s\'harmonisent autour d\'elle.\nVell ferme les yeux, et une onde de force pulse dans l\'eau.\nMais quelque chose résiste. L\'Esprit du Chant, dernier écho de la mélodie perdue, ne vous laissera partir qu\'avec sa bénédiction.',
completeText: 'L\'Esprit se dissipe en une note pure qui résonne longtemps dans la vasque. Le Fragment du Chant se dépose entre vos nageoires.\nVous fermez les yeux. Vous ressentez tout : la Source, l\'étang, l\'Hydre. Tout est relié.\n« Nous sommes prêts. »',
objectiveType: 'kill_monster',
objectiveTargetId: m.get('Esprit du Chant') ?? null,
objectiveCount: 1,
rewardXp: 2000,
rewardGold: 800,
rewardTitle: null,
arcId: arc7.id,
arcOrder: 3,
zone: 'source_courant',
minLevel: 25,
repeatable: false,
},
{
name: 'L\'Héritage du Courant',
description: 'Le Dao du Courant coule en vous. Le chant est presque complet. L\'étang vous attend.',
acceptText: 'L\'eau vibre, et le courant vous pousse doucement vers l\'aval.\nMira : « Ce n\'est pas fini. L\'Hydre souffre encore. Et maintenant, nous avons le pouvoir de la guérir. »\nVell, le regard posé vers l\'horizon : « Si le courant nous appelle, nous devons répondre. »\nLe retour commence. L\'Hydre attend. Et le Chant attend d\'être chanté.',
completeText: 'Vous portez le Dao du Courant. Les fragments du Chant vibrent en vous.\nL\'heure du retour a sonné. L\'étang, brisé, attend sa guérison.\nEt l\'Hydre aussi.\n\n— L\'Odyssée continue...',
objectiveType: 'story_event',
objectiveTargetId: null,
objectiveCount: 1,
rewardXp: 2500,
rewardGold: 1000,
rewardTitle: 'Héritier du Chant',
arcId: arc7.id,
arcOrder: 4,
zone: null,
minLevel: 25,
repeatable: false,
},
];
// ═══════════════════════════════════════════════════
// SEED
// ═══════════════════════════════════════════════════
const allQuests = [...arc4Quests, ...arc5Quests, ...arc6Quests, ...arc7Quests];
for (const q of allQuests) {
const existing = await questRepo.findOne({ where: { name: q.name } });
if (!existing) {
await questRepo.save(questRepo.create(q));
questsAdded++;
}
}
// Quêtes répétables (grind léger entre les arcs — optionnel, pas obligatoire pour l'histoire)
const dailyQuests = [
{ name: 'Éclats quotidiens', description: 'Récoltez des Éclats de Miroir dans le Ruisseau.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Éclat de Miroir'), objectiveCount: 3, rewardXp: 300, rewardGold: 150, zone: 'ruisseau_miroir', minLevel: 16 },
{ name: 'Brumes du jour', description: 'Récoltez de la Mousse Murmurante dans le Marais.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Mousse Murmurante'), objectiveCount: 2, rewardXp: 400, rewardGold: 200, zone: 'marais_murmures', minLevel: 19 },
{ name: 'Pierres du Torrent', description: 'Récoltez des Pierres de Torrent.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Pierre de Torrent'), objectiveCount: 2, rewardXp: 500, rewardGold: 250, zone: 'torrent_brise', minLevel: 22 },
];
for (const q of dailyQuests) {
const existing = await questRepo.findOne({ where: { name: q.name } });
if (!existing) {
await questRepo.save(questRepo.create({ ...q, rewardTitle: null, arcId: null, arcOrder: 0, repeatable: true, acceptText: null, completeText: null }));
questsAdded++;
}
}
console.log(`✅ Odyssée: ${questsAdded} quêtes (4 arcs narratifs + ${dailyQuests.length} répétables)`);
}

View File

@@ -0,0 +1,298 @@
import { DataSource } from 'typeorm';
/**
* Seed Phase 3 — L'Odyssée d'un têtard
* Zones 4-7 : Ruisseau Miroir, Marais des Murmures, Torrent Brisé, Source du Courant
* Monstres, items, matériaux, NPCs
*/
// ── MONSTRES ──
const MONSTERS = [
// Ruisseau Miroir (niv 13-15) — ennemis = reflets, illusions, créatures de cristal
// Commence pile après le Sphinx (niv 12-15) — le joueur est ~niv 13
{ name: 'Reflet Sombre', zone: 'ruisseau_miroir', minLevel: 12, maxLevel: 14, hp: 250, attack: 28, defense: 12, attackType: 'magic', xpReward: 95, goldMin: 55, goldMax: 130, dropMaterialId: null },
{ name: 'Gerris de Cristal', zone: 'ruisseau_miroir', minLevel: 13, maxLevel: 15, hp: 200, attack: 32, defense: 8, attackType: 'ranged', xpReward: 105, goldMin: 60, goldMax: 140, dropMaterialId: null },
{ name: 'Miroir Brisé', zone: 'ruisseau_miroir', minLevel: 13, maxLevel: 15, hp: 280, attack: 26, defense: 16, attackType: 'magic', xpReward: 115, goldMin: 65, goldMax: 150, dropMaterialId: null },
{ name: 'Écho de Doute', zone: 'ruisseau_miroir', minLevel: 14, maxLevel: 16, hp: 320, attack: 30, defense: 14, attackType: 'magic', xpReward: 130, goldMin: 70, goldMax: 165, dropMaterialId: null },
{ name: 'Gardien du Reflet', zone: 'ruisseau_miroir', minLevel: 14, maxLevel: 17, hp: 450, attack: 36, defense: 18, attackType: 'magic', xpReward: 180, goldMin: 90, goldMax: 220, dropMaterialId: null },
// Marais des Murmures (niv 16-18) — spectres, brume, mémoire
{ name: 'Spectre de Brume', zone: 'marais_murmures', minLevel: 15, maxLevel: 17, hp: 350, attack: 34, defense: 10, attackType: 'magic', xpReward: 140, goldMin: 75, goldMax: 180, dropMaterialId: null },
{ name: 'Crapaud Ancien', zone: 'marais_murmures', minLevel: 16, maxLevel: 18, hp: 400, attack: 30, defense: 18, attackType: 'melee', xpReward: 155, goldMin: 80, goldMax: 195, dropMaterialId: null },
{ name: 'Murmure Incarné', zone: 'marais_murmures', minLevel: 16, maxLevel: 18, hp: 380, attack: 38, defense: 12, attackType: 'magic', xpReward: 165, goldMin: 85, goldMax: 210, dropMaterialId: null },
{ name: 'Liane Étrangleuse', zone: 'marais_murmures', minLevel: 17, maxLevel: 19, hp: 440, attack: 32, defense: 20, attackType: 'melee', xpReward: 175, goldMin: 90, goldMax: 225, dropMaterialId: null },
{ name: 'Mémoire de l\'Hydre', zone: 'marais_murmures', minLevel: 17, maxLevel: 20, hp: 580, attack: 42, defense: 22, attackType: 'magic', xpReward: 240, goldMin: 120, goldMax: 300, dropMaterialId: null },
// Torrent Brisé (niv 19-21) — élémentaux d'eau, force brute, courant violent
{ name: 'Élémental de Remous', zone: 'torrent_brise', minLevel: 18, maxLevel: 20, hp: 480, attack: 40, defense: 16, attackType: 'melee', xpReward: 195, goldMin: 100, goldMax: 250, dropMaterialId: null },
{ name: 'Rapide d\'Écume', zone: 'torrent_brise', minLevel: 19, maxLevel: 21, hp: 380, attack: 46, defense: 10, attackType: 'ranged', xpReward: 210, goldMin: 105, goldMax: 265, dropMaterialId: null },
{ name: 'Roc Vivant', zone: 'torrent_brise', minLevel: 19, maxLevel: 21, hp: 600, attack: 36, defense: 28, attackType: 'melee', xpReward: 225, goldMin: 110, goldMax: 280, dropMaterialId: null },
{ name: 'Tourbillon Furieux', zone: 'torrent_brise', minLevel: 20, maxLevel: 22, hp: 520, attack: 44, defense: 18, attackType: 'magic', xpReward: 240, goldMin: 115, goldMax: 300, dropMaterialId: null },
{ name: 'Cascade Éveillée', zone: 'torrent_brise', minLevel: 20, maxLevel: 23, hp: 720, attack: 50, defense: 24, attackType: 'melee', xpReward: 320, goldMin: 150, goldMax: 400, dropMaterialId: null },
// Source du Courant (niv 22-25) — gardiens sacrés, lumière, épreuves
{ name: 'Gardien de la Vasque', zone: 'source_courant', minLevel: 21, maxLevel: 23, hp: 650, attack: 48, defense: 22, attackType: 'magic', xpReward: 280, goldMin: 140, goldMax: 350, dropMaterialId: null },
{ name: 'Onde Sentinelle', zone: 'source_courant', minLevel: 22, maxLevel: 24, hp: 550, attack: 52, defense: 18, attackType: 'ranged', xpReward: 300, goldMin: 150, goldMax: 380, dropMaterialId: null },
{ name: 'Esprit du Chant', zone: 'source_courant', minLevel: 22, maxLevel: 25, hp: 700, attack: 46, defense: 26, attackType: 'magic', xpReward: 330, goldMin: 160, goldMax: 420, dropMaterialId: null },
{ name: 'Prisme Ancien', zone: 'source_courant', minLevel: 23, maxLevel: 25, hp: 800, attack: 54, defense: 24, attackType: 'magic', xpReward: 360, goldMin: 180, goldMax: 460, dropMaterialId: null },
{ name: 'Avatar du Courant', zone: 'source_courant', minLevel: 23, maxLevel: 25, hp: 1000, attack: 60, defense: 30, attackType: 'magic', xpReward: 500, goldMin: 250, goldMax: 600, dropMaterialId: null },
];
// ── MATÉRIAUX ──
const MATERIALS = [
// Ruisseau Miroir
{ name: 'Éclat de Miroir', rarity: 'rare', description: 'Fragment de verre cristallin tombé du Ruisseau. Reflète une lumière étrange.' },
{ name: 'Larme de Cristal', rarity: 'rare', description: 'Goutte d\'eau solidifiée. On dit qu\'elle contient un souvenir.' },
{ name: 'Essence de Reflet', rarity: 'epic', description: 'L\'âme d\'un reflet vaincu. Vibre doucement entre les doigts.' },
// Marais des Murmures
{ name: 'Mousse Murmurante', rarity: 'rare', description: 'Mousse qui chuchote quand on l\'approche de l\'oreille.' },
{ name: 'Racine de Mémoire', rarity: 'rare', description: 'Racine noueuse imprégnée des souvenirs du marais.' },
{ name: 'Brume Condensée', rarity: 'epic', description: 'Fiole de brume pure. Les murmures y sont encore audibles.' },
// Torrent Brisé
{ name: 'Pierre de Torrent', rarity: 'rare', description: 'Roche polie par des siècles de courant furieux.' },
{ name: 'Écume Solidifiée', rarity: 'rare', description: 'Écume blanche durcie par la force du torrent.' },
{ name: 'Cœur de Cascade', rarity: 'epic', description: 'Noyau cristallin arraché au cœur de la cascade éveillée.' },
// Source du Courant
{ name: 'Eau de la Source', rarity: 'epic', description: 'Eau pure de la Source. Brille d\'une lumière douce.' },
{ name: 'Fragment du Chant', rarity: 'legendary', description: 'Morceau de la mélodie perdue. Vibre d\'une harmonie ancienne.' },
{ name: 'Filament Violet', rarity: 'legendary', description: 'Fil de lumière violette — le lien du Dao du Courant.' },
];
// ── ITEMS ──
const ITEMS = [
// Ruisseau Miroir (niv 16-18)
{ name: 'Lame du Reflet', type: 'weapon', rarity: 'epic', attackBonus: 30, defenseBonus: 0, buyPrice: 2000, minLevel: 13, zone: 'ruisseau_miroir', description: 'Une épée qui montre la vérité à celui qu\'elle frappe.' },
{ name: 'Bouclier Miroir', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 22, buyPrice: 1800, minLevel: 13, zone: 'ruisseau_miroir', description: 'Renvoie un reflet déformé des attaques.' },
{ name: 'Dague de Cristal', type: 'weapon', rarity: 'rare', attackBonus: 26, defenseBonus: 0, buyPrice: 1500, minLevel: 12, zone: 'ruisseau_miroir', description: 'Transparente et tranchante comme un reproche.' },
// Marais des Murmures (niv 19-21)
{ name: 'Bâton des Murmures', type: 'weapon', rarity: 'epic', attackBonus: 34, defenseBonus: 0, intelligenceBonus: 5, buyPrice: 2800, minLevel: 16, zone: 'marais_murmures', description: 'Les murmures du marais guident chaque coup.' },
{ name: 'Robe de Brume', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 26, buyPrice: 2500, minLevel: 16, zone: 'marais_murmures', description: 'Tissée de brume vivante. Dissimule et protège.' },
{ name: 'Amulette de la Batracienne', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 20, intelligenceBonus: 8, vitaliteBonus: 3, buyPrice: 3200, minLevel: 17, zone: 'marais_murmures', description: 'Don de la sage du marais. Pulse de lumière verte.' },
// Torrent Brisé (niv 22-24)
{ name: 'Masse du Torrent', type: 'weapon', rarity: 'epic', attackBonus: 40, defenseBonus: 0, forceBonus: 5, buyPrice: 3800, minLevel: 19, zone: 'torrent_brise', description: 'Lourde comme le courant. Chaque coup résonne.' },
{ name: 'Armure d\'Écume', type: 'armor', rarity: 'epic', attackBonus: 0, defenseBonus: 30, buyPrice: 3500, minLevel: 19, zone: 'torrent_brise', description: 'L\'écume durcie absorbe les chocs comme l\'eau absorbe la pierre.' },
{ name: 'Gantelet de Résonance', type: 'armor', rarity: 'epic', attackBonus: 5, defenseBonus: 24, forceBonus: 6, buyPrice: 4000, minLevel: 20, zone: 'torrent_brise', description: 'Amplifie la force par l\'harmonie. Forgé dans le torrent.' },
// Source du Courant (niv 25+)
{ name: 'Lame de la Source', type: 'weapon', rarity: 'legendary', attackBonus: 50, defenseBonus: 0, intelligenceBonus: 8, chanceBonus: 5, buyPrice: 6000, minLevel: 22, zone: 'source_courant', description: 'Forgée dans l\'eau de la Source. Le Chant résonne à chaque frappe.' },
{ name: 'Armure du Dao', type: 'armor', rarity: 'legendary', attackBonus: 0, defenseBonus: 38, vitaliteBonus: 10, buyPrice: 5500, minLevel: 22, zone: 'source_courant', description: 'Imprégnée du Courant lui-même. Protège le corps et l\'esprit.' },
{ name: 'Bâton de Gorn', type: 'weapon', rarity: 'legendary', attackBonus: 45, defenseBonus: 5, intelligenceBonus: 12, buyPrice: 7000, minLevel: 22, zone: 'source_courant', description: 'L\'héritage de l\'ancien. Brille d\'une lumière dorée quand le Chant résonne.' },
// Potions avancées
{ name: 'Élixir du Courant', type: 'consumable', rarity: 'epic', attackBonus: 0, defenseBonus: 0, forceBonus: 100, buyPrice: 80, minLevel: 13, zone: null, description: 'Restaure 100 points d\'endurance. L\'eau pure revitalise.' },
{ name: 'Potion de soin majeure', type: 'consumable', rarity: 'epic', attackBonus: 0, defenseBonus: 0, forceBonus: 0, buyPrice: 60, minLevel: 13, zone: null, description: 'Restaure 80% des PV.' },
];
// ── NPCs ──
const NPCS = [
// Village — PNJ permanents
{
name: 'Gorn',
role: 'mentor',
location: 'village_quests',
description: 'L\'ancien de l\'étang. Sage, grave, bienveillant. Porte le poids du savoir.',
lore: 'On dit que Gorn a effleuré la Pierre-Mémoire et n\'a jamais été le même depuis.',
spriteKey: 'npc_gorn',
minLevel: 1,
maxLevel: 15, // Disparaît après le sacrifice (ch.7)
dialogues: [
{ trigger: 'default', text: 'Bienvenue, jeune têtard. Le courant te porte ici pour une raison.', action: 'open_quests' },
{ trigger: 'level_3', text: 'Tu as grandi. Le courant murmure ton nom, l\'entends-tu ?', action: 'open_quests' },
{ trigger: 'level_5', text: 'Le Dao du Courant... peu en parlent, peu en savent la voie. Mais toi, tu commences à comprendre.', action: 'open_quests' },
{ trigger: 'level_10', text: 'Viens. Il est temps que je te montre la Pierre-Mémoire. Ce que peu ont vu.', action: 'open_quests' },
{ trigger: 'arc_completed:egouts', text: 'Tu descends de plus en plus profond. Bientôt, tu verras ce que moi-même j\'ai tenté d\'oublier.', action: 'open_quests' },
],
},
{
name: 'Pierre-Mémoire',
role: 'quest_giver',
location: 'village_quests',
description: 'Pierre ancienne gravée de symboles. Les échos de Gorn y résonnent encore.',
lore: 'La Pierre-Mémoire existe depuis avant l\'étang. Elle porte les souvenirs de ceux qui l\'ont touchée.',
spriteKey: 'npc_pierre_memoire',
minLevel: 16, // Apparaît quand Gorn disparaît
maxLevel: null,
dialogues: [
{ trigger: 'default', text: '« Le courant t\'attend. Va. » — les mots de Gorn résonnent dans la pierre.', action: 'open_quests' },
{ trigger: 'level_20', text: 'La pierre pulse. Des visions anciennes dansent sur sa surface — des batraciens chantant sous une lune violette.', action: 'open_quests' },
{ trigger: 'level_25', text: 'La Pierre-Mémoire brille intensément. Le Chant complet est presque là. « Continue. »', action: 'open_quests' },
],
},
{
name: 'Mira',
role: 'companion',
location: 'village_plaza',
description: 'Douce, intuitive, silencieusement puissante. Connectée au Courant sans le savoir.',
lore: 'Mira fredonne des mélodies aquatiques. L\'eau semble la porter, la guider, la compléter.',
spriteKey: 'npc_mira',
minLevel: 1,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Salut ! Tu as vu les nénuphars aujourd\'hui ? Ils brillent autrement...', action: 'heal' },
{ trigger: 'level_5', text: 'Je ne sais pas pourquoi, mais quand je nage, l\'eau me parle. Tu crois que c\'est normal ?', action: 'heal' },
{ trigger: 'level_10', text: 'Le chant guérit. Mais il faut l\'entendre. Et l\'accepter.', action: 'heal' },
{ trigger: 'arc_completed:desert', text: 'L\'étang souffre. Je le sens dans chaque remous. On doit faire quelque chose.', action: 'heal' },
{ trigger: 'level_16', text: 'Le Ruisseau Miroir m\'effraie. Il montre ce qu\'on ne veut pas voir... mais je viendrai avec toi.', action: 'heal' },
{ trigger: 'level_20', text: 'J\'ai accepté mon chant, Tetardtek. Et ensemble, nous sommes plus que nos reflets.', action: 'heal' },
{ trigger: 'level_25', text: 'La Source nous attend. Je sens le Chant, complet, qui vibre dans l\'eau. Allons-y.', action: 'heal' },
],
},
{
name: 'Vell',
role: 'rival',
location: 'village_arena',
description: 'Impétueux, fier, loyal. Confond d\'abord force et valeur.',
lore: 'Vell ne pense qu\'à défier ses compagnons en vitesse. Sa force physique est indéniable.',
spriteKey: 'npc_vell',
minLevel: 1,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Eh ! On fait la course ? Je te laisse même de l\'avance.', action: 'challenge' },
{ trigger: 'level_5', text: 'Tu t\'es laissé porter par quelque chose à la course. Ce n\'était pas de la vitesse... c\'était autre chose.', action: 'challenge' },
{ trigger: 'level_10', text: 'J\'étais fort, mais je n\'ai rien pu faire quand ça a compté. Je dois comprendre la vraie force.', action: 'challenge' },
{ trigger: 'arc_completed:desert', text: 'L\'étang est brisé. Si ma force ne peut pas protéger, à quoi sert-elle ? ...Je viens avec toi.', action: 'challenge' },
{ trigger: 'level_20', text: 'Le Torrent m\'a montré. La vraie force, c\'est de ne pas lutter seul.', action: 'challenge' },
{ trigger: 'level_25', text: 'Nous sommes un flot, pas des gouttes isolées. Allons restaurer le Chant.', action: 'challenge' },
],
},
// PNJ de zone
{
name: 'La Batracienne',
role: 'sage',
location: 'marais_murmures',
description: 'Sage ancienne vêtue de lianes et d\'algues. Ses yeux voilés voient plus loin que les nôtres.',
lore: 'Elle vit dans le Marais depuis des générations. On dit qu\'elle a vu le Chant originel.',
spriteKey: 'npc_batracienne',
minLevel: 19,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Bienvenue, voyageurs. Le Marais vous attendait.', action: 'open_quests' },
{ trigger: 'level_20', text: 'Tu portes le chant en germe, petit têtard. Mais avant la source, il faut comprendre le courant.', action: 'open_quests' },
{ trigger: 'level_22', text: 'L\'Hydre était la clef... Elle est la mémoire déformée. Vous devez la guérir, pas la fuir.', action: 'open_quests' },
],
},
// Marchands existants (formalisés comme PNJ)
{
name: 'Le Forgeron',
role: 'merchant',
location: 'village_forge',
description: 'Un vieux crapaud aux bras musclés. Son enclume résonne jour et nuit.',
lore: 'Il forge des armes depuis plus longtemps que quiconque se souvient.',
spriteKey: 'npc_forgeron',
minLevel: 1,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Apporte-moi des matériaux et je te forgerai quelque chose de solide.', action: 'open_forge' },
{ trigger: 'level_10', text: 'Tes armes grandissent avec toi. Bientôt tu auras besoin d\'acier des profondeurs.', action: 'open_forge' },
{ trigger: 'level_20', text: 'J\'ai entendu parler de matériaux dans le Torrent... si tu en rapportes, je pourrai forger du légendaire.', action: 'open_forge' },
],
},
{
name: 'Le Marchand',
role: 'merchant',
location: 'village_shop',
description: 'Un triton jovial qui a toujours ce qu\'il faut.',
lore: 'Personne ne sait d\'où il vient. Ses marchandises changent avec les zones que vous explorez.',
spriteKey: 'npc_marchand',
minLevel: 1,
maxLevel: null,
dialogues: [
{ trigger: 'default', text: 'Bienvenue ! J\'ai de belles trouvailles aujourd\'hui.', action: 'open_shop' },
{ trigger: 'level_15', text: 'Les temps sont durs depuis l\'Hydre. Mais j\'ai de l\'équipement qui pourrait t\'aider au-delà de l\'étang.', action: 'open_shop' },
{ trigger: 'level_25', text: 'La Source... tu y vas vraiment ? Tiens, prends ça. Tu en auras besoin.', action: 'open_shop' },
],
},
];
// ── SEED FUNCTION ──
export async function seedOdyssee(dataSource: DataSource) {
let monstersAdded = 0;
let itemsAdded = 0;
let materialsAdded = 0;
let npcsAdded = 0;
// --- Monstres ---
for (const m of MONSTERS) {
const existing = await dataSource.query('SELECT id FROM monsters WHERE name = ?', [m.name]);
if (existing.length === 0) {
await dataSource.query(
`INSERT INTO monsters (id, name, zone, min_level, max_level, hp, attack, defense, attack_type, xp_reward, gold_min, gold_max, drop_material_id)
VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[m.name, m.zone, m.minLevel, m.maxLevel, m.hp, m.attack, m.defense, m.attackType, m.xpReward, m.goldMin, m.goldMax, m.dropMaterialId],
);
monstersAdded++;
}
}
// --- Matériaux ---
const materialRepo = dataSource.getRepository('Material');
const materialIdMap: Record<string, string> = {};
for (const mat of MATERIALS) {
let existing = await materialRepo.findOne({ where: { name: mat.name } });
if (!existing) {
existing = await materialRepo.save(materialRepo.create(mat));
materialsAdded++;
}
materialIdMap[mat.name] = existing.id;
}
// Lier les drops aux monstres
const dropLinks: Record<string, string> = {
'Reflet Sombre': 'Éclat de Miroir',
'Miroir Brisé': 'Larme de Cristal',
'Gardien du Reflet': 'Essence de Reflet',
'Spectre de Brume': 'Mousse Murmurante',
'Murmure Incarné': 'Racine de Mémoire',
'Mémoire de l\'Hydre': 'Brume Condensée',
'Élémental de Remous': 'Pierre de Torrent',
'Roc Vivant': 'Écume Solidifiée',
'Cascade Éveillée': 'Cœur de Cascade',
'Gardien de la Vasque': 'Eau de la Source',
'Esprit du Chant': 'Fragment du Chant',
'Avatar du Courant': 'Filament Violet',
};
for (const [monsterName, materialName] of Object.entries(dropLinks)) {
const matId = materialIdMap[materialName];
if (matId) {
await dataSource.query(
'UPDATE monsters SET drop_material_id = ? WHERE name = ?',
[matId, monsterName],
);
}
}
// --- Items ---
const itemRepo = dataSource.getRepository('Item');
for (const item of ITEMS) {
const existing = await itemRepo.findOne({ where: { name: item.name } });
if (!existing) {
await itemRepo.save(itemRepo.create(item));
itemsAdded++;
}
}
// --- NPCs ---
const npcRepo = dataSource.getRepository('Npc');
for (const npc of NPCS) {
const existing = await npcRepo.findOne({ where: { name: npc.name } });
if (!existing) {
await npcRepo.save(npcRepo.create(npc));
npcsAdded++;
}
}
console.log(`✅ Odyssée seed: ${monstersAdded} monstres, ${materialsAdded} matériaux, ${itemsAdded} items, ${npcsAdded} NPCs`);
}

View File

@@ -0,0 +1,20 @@
import 'reflect-metadata';
import { AppDataSource } from './data-source';
import { seedOdyssee } from './odyssee-seed';
import { seedOdysseeQuests } from './odyssee-quests-seed';
async function seed() {
await AppDataSource.initialize();
console.log('DB connectée (MySQL)');
await seedOdyssee(AppDataSource);
await seedOdysseeQuests(AppDataSource);
await AppDataSource.destroy();
console.log('✅ Odyssée seed terminé');
}
seed().catch((err) => {
console.error('Seed échoué :', err);
process.exit(1);
});

View File

@@ -42,4 +42,10 @@ export class Monster {
@Column({ name: 'zone', type: 'varchar', length: 50, default: 'marais' })
zone: string;
@Column({ name: 'ai_profile', type: 'varchar', length: 20, default: 'aggressive' })
aiProfile: string;
@Column({ name: 'is_boss', default: false })
isBoss: boolean;
}

23
src/npc/npc.controller.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { NpcService } from './npc.service';
import { AuthGuard } from '../auth/guards/auth.guard';
@Controller('api/npcs')
@UseGuards(AuthGuard)
export class NpcController {
constructor(private readonly npcService: NpcService) {}
/** GET /api/npcs — tous les PNJ visibles pour le joueur */
@Get()
async getAll(@Req() req: any) {
const { characterId, level } = req.character;
return this.npcService.getVisibleNpcs(characterId, level);
}
/** GET /api/npcs?location=village_plaza — PNJ d'un emplacement */
@Get('location')
async getByLocation(@Req() req: any, @Query('location') location: string) {
const { characterId, level } = req.character;
return this.npcService.getNpcsByLocation(characterId, level, location);
}
}

51
src/npc/npc.entity.ts Normal file
View File

@@ -0,0 +1,51 @@
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
export type NpcRole = 'mentor' | 'companion' | 'merchant' | 'quest_giver' | 'sage' | 'rival';
@Entity('npcs')
export class Npc {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 100 })
name: string;
@Column({ type: 'varchar', length: 20 })
role: NpcRole;
/** Emplacement dans le village ou zone du monde */
@Column({ type: 'varchar', length: 50 })
location: string; // 'village_plaza' | 'village_arena' | 'village_quests' | 'village_forge' | 'village_shop' | zone name
@Column({ type: 'text', nullable: true })
description: string | null;
/** Lore courte affichée dans le hub */
@Column({ type: 'text', nullable: true })
lore: string | null;
/** Niveau minimum du joueur pour voir ce PNJ */
@Column({ name: 'min_level', default: 1 })
minLevel: number;
/** Niveau max de visibilité (null = toujours visible après minLevel) */
@Column({ name: 'max_level', type: 'int', nullable: true })
maxLevel: number | null;
/** Clé sprite pour le frontend */
@Column({ name: 'sprite_key', type: 'varchar', length: 50, nullable: true })
spriteKey: string | null;
/** Dialogues évolutifs — JSON array trié par priorité */
@Column({ type: 'json', nullable: true })
dialogues: NpcDialogue[] | null;
}
export interface NpcDialogue {
/** Condition de déclenchement */
trigger: string; // 'default' | 'level_5' | 'level_15' | 'arc_completed:desert' | 'story:gorn_sacrifice' | etc.
/** Texte affiché */
text: string;
/** Action proposée (optionnel) */
action?: string; // 'open_quests' | 'open_shop' | 'open_forge' | 'heal' | 'challenge'
}

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

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Npc } from './npc.entity';
import { NpcController } from './npc.controller';
import { NpcService } from './npc.service';
import { PlayerQuestArc } from '../quest/player-quest-arc.entity';
@Module({
imports: [TypeOrmModule.forFeature([Npc, PlayerQuestArc])],
controllers: [NpcController],
providers: [NpcService],
exports: [NpcService],
})
export class NpcModule {}

117
src/npc/npc.service.ts Normal file
View File

@@ -0,0 +1,117 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
import { Npc, NpcDialogue } from './npc.entity';
import { PlayerQuestArc } from '../quest/player-quest-arc.entity';
export interface NpcView {
id: string;
name: string;
role: string;
location: string;
description: string | null;
lore: string | null;
spriteKey: string | null;
dialogue: string;
action?: string;
}
@Injectable()
export class NpcService {
constructor(
@InjectRepository(Npc)
private readonly npcRepo: Repository<Npc>,
@InjectRepository(PlayerQuestArc)
private readonly playerArcRepo: Repository<PlayerQuestArc>,
) {}
/**
* Retourne les PNJ visibles pour un joueur donné, avec le bon dialogue résolu.
*/
async getVisibleNpcs(characterId: string, playerLevel: number): Promise<NpcView[]> {
// Tous les PNJ dont le joueur est dans la fourchette de niveaux
const npcs = await this.npcRepo.find({
where: {
minLevel: LessThanOrEqual(playerLevel),
},
order: { location: 'ASC', name: 'ASC' },
});
// Filtrer par maxLevel (null = pas de limite)
const visible = npcs.filter(
(npc) => npc.maxLevel === null || playerLevel <= npc.maxLevel,
);
// Arcs complétés par ce joueur (pour résoudre les dialogues conditionnels)
const completedArcs = await this.playerArcRepo.find({
where: { characterId, completed: true },
relations: ['questArc'],
});
const completedArcZones = new Set(
completedArcs.map((pa) => pa.questArc?.zone).filter((z): z is string => z != null),
);
return visible.map((npc) => {
const resolved = this.resolveDialogue(npc.dialogues, playerLevel, completedArcZones);
return {
id: npc.id,
name: npc.name,
role: npc.role,
location: npc.location,
description: npc.description,
lore: npc.lore,
spriteKey: npc.spriteKey,
dialogue: resolved.text,
action: resolved.action,
};
});
}
/**
* Retourne les PNJ d'un emplacement spécifique (ex: 'village_plaza')
*/
async getNpcsByLocation(characterId: string, playerLevel: number, location: string): Promise<NpcView[]> {
const all = await this.getVisibleNpcs(characterId, playerLevel);
return all.filter((npc) => npc.location === location);
}
/** Résout le dialogue le plus pertinent selon l'état du joueur */
private resolveDialogue(
dialogues: NpcDialogue[] | null,
playerLevel: number,
completedArcZones: Set<string>,
): { text: string; action?: string } {
if (!dialogues || dialogues.length === 0) {
return { text: '...' };
}
// Parcours par priorité (dernière condition valide gagne)
let best: NpcDialogue = dialogues[0];
for (const d of dialogues) {
if (d.trigger === 'default') {
// Default = fallback, déjà capturé
continue;
}
// Trigger par niveau : "level_15" → joueur >= 15
const levelMatch = d.trigger.match(/^level_(\d+)$/);
if (levelMatch && playerLevel >= parseInt(levelMatch[1], 10)) {
best = d;
continue;
}
// Trigger par arc complété : "arc_completed:desert"
const arcMatch = d.trigger.match(/^arc_completed:(.+)$/);
if (arcMatch && completedArcZones.has(arcMatch[1])) {
best = d;
continue;
}
// Trigger story (future — quand on aura un story tracker)
// Pour l'instant, les triggers story sont ignorés
}
return { text: best.text, action: best.action };
}
}

View File

@@ -19,9 +19,17 @@ export class Quest {
@Column('text')
description: string;
/** Texte narratif affiché quand le joueur accepte la quête (le PNJ parle) */
@Column({ name: 'accept_text', type: 'text', nullable: true })
acceptText: string | null;
/** Texte narratif affiché quand la quête est complétée (conclusion de la scène) */
@Column({ name: 'complete_text', type: 'text', nullable: true })
completeText: string | null;
// Objectif
@Column({ name: 'objective_type', length: 30 })
objectiveType: string; // 'kill_monster' | 'kill_any' | 'gather_material' | 'craft_item' | 'forge_item'
objectiveType: string; // 'kill_monster' | 'kill_any' | 'gather_material' | 'craft_item' | 'forge_item' | 'story_event'
@Column({ name: 'objective_target_id', type: 'varchar', length: 255, nullable: true })
objectiveTargetId: string | null; // monster ID or material ID (null for kill_any)

View File

@@ -128,11 +128,15 @@ export class QuestService {
return this.playerQuestRepo.save(existing);
}
// story_event quests complete immediately — they're narrative moments, not grinds
const isStoryEvent = quest.objectiveType === 'story_event';
const pq = this.playerQuestRepo.create({
characterId,
questId,
progress: 0,
status: 'active',
progress: isStoryEvent ? 1 : 0,
status: isStoryEvent ? 'completed' : 'active',
completedAt: isStoryEvent ? new Date() : null,
});
return this.playerQuestRepo.save(pq);
}