Compare commits

...

17 Commits

Author SHA1 Message Date
08f5b0789f fix: NPC controller — charger character depuis req.user (pas req.character)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 38s
2026-04-28 18:58:45 +02:00
bab73ae341 fix: CI pm2 start-or-reload — crée le process s'il n'existe pas
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 39s
2026-04-28 18:41:27 +02:00
a3ee7e7bc1 fix: CI pm2 reload sous root au lieu de tetardtek-brain
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 22s
2026-04-28 18:34:20 +02:00
d996f5806d feat: Hub Village — page interactive avec 5 zones et PNJ
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 25s
- VillagePage: 5 zones (place, arène, quêtes, forge, échoppe) avec ambiance
- NPC cards: dialogue résolu par niveau/arc, actions directes (soins, navigation)
- Mira heal via POST /characters/rest + toast feedback
- Navigation actions → pages existantes (quêtes, boutique, forge, combat)
- NpcView type + npcApi endpoint frontend
- Route /village + icône Landmark dans la sidebar
2026-04-28 18:08:57 +02:00
cc7893ec8f fix: multi-combat n'émettait pas quest.progress kill_monster
Le combat x5 émettait kill_any mais pas kill_monster — les quêtes ciblant
un monstre spécifique ne progressaient pas en batch.
2026-04-28 17:47:18 +02:00
fd5e2f6425 feat: UI evolution — HudBar Tailwind + arcs collapsés intelligents
- HudBar: migration inline styles → Tailwind, breakpoint 480px ultra-compact mobile
- QuestPage: arcs fermés par défaut sauf quête active/à réclamer, barre progression par arc
- QuestPage: migration inline styles → Tailwind (QuestCard, ArcSection, ArcQuestRow)
2026-04-28 17:39:01 +02:00
4d82346af4 feat: quêtes transition Acte I→II + minLevel arc Ruisseau 13
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-25 01:36:03 +01:00
2001c867cb feat: écran choix voie du Dao — s'affiche avant le premier combat tactique
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-25 01:33:19 +01:00
cae0ef5d57 fix: titre onglet — nom du perso + TetaRdPG au lieu de 'frontend'
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
2026-03-25 01:24:46 +01:00
e8f108a7e8 design: maîtrise monstre — auto-combat déverrouillé par succès tactiques (N victoires)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
2026-03-25 01:22:28 +01:00
430fbb6e95 feat: guide — 4 nouvelles zones + onglet Dao du Courant (voies, combat tactique, compagnons)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
2026-03-25 01:19:22 +01:00
f44ce0531f fix: NpcController prefix — remove duplicate /api
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 36s
2026-03-25 01:08:09 +01:00
34d1711cee fix: remove unused imports TurnCombatPage (TurnSpell, Heart)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 38s
2026-03-25 01:02:51 +01:00
697fb67bbb fix: NpcModule import AuthModule — resolve UserRepository dependency
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 59s
2026-03-25 01:01:37 +01:00
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
46 changed files with 5069 additions and 163 deletions

View File

@@ -31,7 +31,9 @@ jobs:
- name: Restart pm2 - name: Restart pm2
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: | run: |
su - tetardtek-brain -c 'pm2 reload tetardpg-backend --update-env' pm2 describe tetardpg-backend >/dev/null 2>&1 \
&& pm2 reload tetardpg-backend --update-env \
|| (cd /var/www/tetardpg/backend && pm2 start dist/main.js --name tetardpg-backend && pm2 save)
# ── Frontend ───────────────────────────────────────────────────────────── # ── Frontend ─────────────────────────────────────────────────────────────
- name: Install & build frontend - name: Install & build frontend

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

@@ -0,0 +1,131 @@
# 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+ : combat tour par tour **obligatoire** tant que le monstre n'est pas maîtrisé
- Les items Acte I restent utilisables — les nouveaux types n'existent qu'en Acte II
### Maîtrise monstre — auto-combat progressif (décision 2026-03-25)
En Acte II, chaque nouveau monstre impose le combat tactique.
Après N victoires tactiques, le joueur débloque le combat auto pour ce monstre.
```
1ère rencontre → Combat tactique obligatoire
↓ (N victoires)
🏆 Succès "Maîtrise : <monstre>" débloqué
Combat auto (×1/×5/×10) déverrouillé pour CE monstre
```
**Implémentation :**
- Utiliser le système d'achievements existant (event-driven)
- Nouveau criteria_type : `monster_tactical_wins` (par monstre_id)
- Seuil de maîtrise : 3-5 victoires tactiques (à équilibrer)
- CombatService.startCombat() vérifie l'achievement avant d'autoriser l'auto en zone 4+
- Si pas maîtrisé → 403 "Ce monstre requiert le combat tactique"
- Le frontend grise le bouton auto et affiche la progression "2/5 victoires tactiques"
**Pourquoi c'est bon :**
- Force l'apprentissage des patterns ennemis (le tactique a du sens)
- Récompense la maîtrise (le grind redevient rapide une fois compris)
- Le joueur ne se lasse jamais : il alterne découverte (tactique) et farm (auto)
- Compatible avec le multi-combat existant (×5/×10 = auto uniquement)
- 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

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>TetaRdPG</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -7,12 +7,14 @@ import { LoginPage } from './pages/LoginPage';
import { AuthCallback } from './pages/AuthCallback'; import { AuthCallback } from './pages/AuthCallback';
import { DashboardPage } from './pages/DashboardPage'; import { DashboardPage } from './pages/DashboardPage';
import { CombatPage } from './pages/CombatPage'; import { CombatPage } from './pages/CombatPage';
import { TurnCombatPage } from './pages/TurnCombatPage';
import { InventoryPage } from './pages/InventoryPage'; import { InventoryPage } from './pages/InventoryPage';
import { CraftPage } from './pages/CraftPage'; import { CraftPage } from './pages/CraftPage';
import { ForgePage } from './pages/ForgePage'; import { ForgePage } from './pages/ForgePage';
import { QuestPage } from './pages/QuestPage'; import { QuestPage } from './pages/QuestPage';
import { AchievementsPage } from './pages/AchievementsPage'; import { AchievementsPage } from './pages/AchievementsPage';
import { ShopPage } from './pages/ShopPage'; import { ShopPage } from './pages/ShopPage';
import { VillagePage } from './pages/VillagePage';
import { GuidePage } from './pages/GuidePage'; import { GuidePage } from './pages/GuidePage';
import { NotFoundPage } from './pages/NotFoundPage'; import { NotFoundPage } from './pages/NotFoundPage';
@@ -36,8 +38,10 @@ function AppRoutes() {
<Route path="/auth/callback" element={<AuthCallback />} /> <Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/guide" element={<GuidePage />} /> <Route path="/guide" element={<GuidePage />} />
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} /> <Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
<Route path="/village" element={<ProtectedLayout><VillagePage /></ProtectedLayout>} />
<Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} /> <Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} />
<Route path="/combat" element={<ProtectedLayout><CombatPage /></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="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} /> <Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
<Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} /> <Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />

View File

@@ -2,6 +2,7 @@ import { api } from './client';
import type { import type {
User, Character, Monster, CombatLog, User, Character, Monster, CombatLog,
CharacterItem, CharacterMaterial, Recipe, CraftJob, Item, CharacterItem, CharacterMaterial, Recipe, CraftJob, Item,
TurnResult, TurnSpell, DaoPathProgress, NpcView,
} from './types'; } from './types';
// Auth // Auth
@@ -31,6 +32,23 @@ export const combatApi = {
history: () => api.get<CombatLog[]>('/combat/history'), 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 // Items
export const itemApi = { export const itemApi = {
catalogue: () => api.get<Item[]>('/items'), catalogue: () => api.get<Item[]>('/items'),
@@ -63,6 +81,11 @@ export const questApi = {
arcs: () => api.get<any[]>('/quests/arcs'), arcs: () => api.get<any[]>('/quests/arcs'),
}; };
// NPCs
export const npcApi = {
all: () => api.get<NpcView[]>('/npcs'),
};
// Forge // Forge
export const forgeApi = { export const forgeApi = {
upgrade: (charItemId: string) => upgrade: (charItemId: string) =>

View File

@@ -113,6 +113,85 @@ export interface CombatLog {
monster: { id: string; name: string; minLevel: number; maxLevel: number }; 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 type Rarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface Item { export interface Item {
@@ -168,3 +247,15 @@ export interface CraftJob {
collected: boolean; collected: boolean;
status: 'pending' | 'ready'; status: 'pending' | 'ready';
} }
export interface NpcView {
id: string;
name: string;
role: string;
location: string;
description: string | null;
lore: string | null;
spriteKey: string | null;
dialogue: string;
action?: string;
}

View File

@@ -15,7 +15,6 @@ function RegenTimer({ endurance, enduranceMax, lastEnduranceTs }: { endurance: n
if (endurance >= enduranceMax) return null; if (endurance >= enduranceMax) return null;
// Regen = 1pt every 3min = 180s
const elapsedMs = now - new Date(lastEnduranceTs).getTime(); const elapsedMs = now - new Date(lastEnduranceTs).getTime();
const elapsedInCycle = elapsedMs % (3 * 60 * 1000); const elapsedInCycle = elapsedMs % (3 * 60 * 1000);
const remainingMs = 3 * 60 * 1000 - elapsedInCycle; const remainingMs = 3 * 60 * 1000 - elapsedInCycle;
@@ -24,8 +23,8 @@ function RegenTimer({ endurance, enduranceMax, lastEnduranceTs }: { endurance: n
const sec = remainingSec % 60; const sec = remainingSec % 60;
return ( return (
<span style={{ fontSize: 9, color: '#5ba4f5' }}> <span className="hud-regen text-[9px] text-rpg-blue inline-flex items-center gap-0.5">
<Clock size={8} style={{ display: 'inline', marginRight: 2 }} /> <Clock size={8} className="inline" />
+1 dans {min}:{sec.toString().padStart(2, '0')} +1 dans {min}:{sec.toString().padStart(2, '0')}
</span> </span>
); );
@@ -35,9 +34,13 @@ export function HudBar() {
const { data: char } = useQuery({ const { data: char } = useQuery({
queryKey: ['character'], queryKey: ['character'],
queryFn: characterApi.me, queryFn: characterApi.me,
refetchInterval: 30_000, // refresh every 30s for endurance updates refetchInterval: 30_000,
}); });
useEffect(() => {
document.title = char?.name ? `${char.name} — TetaRdPG` : 'TetaRdPG';
}, [char?.name]);
const { data: activeQuests } = useQuery({ const { data: activeQuests } = useQuery({
queryKey: ['questsActive'], queryKey: ['questsActive'],
queryFn: questApi.active, queryFn: questApi.active,
@@ -52,41 +55,31 @@ export function HudBar() {
const questReady = activeQuests?.filter((pq: any) => pq.status === 'completed').length ?? 0; const questReady = activeQuests?.filter((pq: any) => pq.status === 'completed').length ?? 0;
return ( return (
<div className="hud-bar" style={{ <div className="hud-bar bg-[#111620] border-b border-[#1e2535] px-4 py-1 flex items-center gap-4 text-[11px] text-rpg-muted flex-wrap">
background: '#111620',
borderBottom: '1px solid #1e2535',
padding: '4px 1rem',
display: 'flex',
alignItems: 'center',
gap: 16,
fontSize: 11,
color: '#6b7a99',
flexWrap: 'wrap',
}}>
{/* Name + Level */} {/* Name + Level */}
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}> <Link to="/dashboard" className="no-underline flex items-center gap-1.5">
<span style={{ fontSize: 14 }}>🐸</span> <span className="text-sm">🐸</span>
<span style={{ fontWeight: 700, color: '#dce4f0', fontSize: 12 }}>{char.name}</span> <span className="font-bold text-rpg-text text-xs">{char.name}</span>
<span style={{ color: '#6b7a99' }}>Niv.{char.level}</span> <span className="hud-label text-rpg-muted">Niv.{char.level}</span>
</Link> </Link>
<span style={{ color: '#2a3448' }}>|</span> <span className="hud-sep text-[#2a3448]">|</span>
{/* HP */} {/* HP */}
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}> <Link to="/dashboard" className="no-underline flex items-center gap-1">
<Heart size={10} color="#e84040" /> <Heart size={10} className="text-rpg-red" />
<span style={{ color: char.hpCurrent < char.hpMax ? '#e84040' : '#6b7a99' }}> <span className={char.hpCurrent < char.hpMax ? 'text-rpg-red' : 'text-rpg-muted'}>
{char.hpCurrent}/{char.hpMax} {char.hpCurrent}<span className="hud-label">/{char.hpMax}</span>
</span> </span>
</Link> </Link>
<span style={{ color: '#2a3448' }}>|</span> <span className="hud-sep text-[#2a3448]">|</span>
{/* Endurance + timer */} {/* Endurance + timer */}
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}> <Link to="/dashboard" className="no-underline flex items-center gap-1">
<Zap size={10} color="#5ba4f5" /> <Zap size={10} className="text-rpg-blue" />
<span style={{ color: endurance < 5 ? '#e84040' : '#6b7a99' }}> <span className={endurance < 5 ? 'text-rpg-red' : 'text-rpg-muted'}>
{endurance}/{char.enduranceMax} {endurance}<span className="hud-label">/{char.enduranceMax}</span>
</span> </span>
{char.lastEnduranceTs && ( {char.lastEnduranceTs && (
<RegenTimer <RegenTimer
@@ -97,30 +90,30 @@ export function HudBar() {
)} )}
</Link> </Link>
<span style={{ color: '#2a3448' }}>|</span> <span className="hud-sep text-[#2a3448]">|</span>
{/* XP */} {/* XP */}
<Link to="/dashboard" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}> <Link to="/dashboard" className="no-underline flex items-center gap-1">
<Star size={10} color="#a78bfa" /> <Star size={10} className="text-rpg-purple" />
<span>{char.xp}/{xpNext}</span> <span>{char.xp}<span className="hud-label">/{xpNext}</span></span>
</Link> </Link>
<span style={{ color: '#2a3448' }}>|</span> <span className="hud-sep text-[#2a3448]">|</span>
{/* Gold */} {/* Gold */}
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <span className="flex items-center gap-1">
<Coins size={10} color="#f4c94e" /> <Coins size={10} className="text-rpg-gold" />
<span>{char.gold}</span> <span>{char.gold}</span>
</span> </span>
<span style={{ color: '#2a3448' }}>|</span> <span className="hud-sep text-[#2a3448]">|</span>
{/* Quests */} {/* Quests */}
<Link to="/quests" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}> <Link to="/quests" className="no-underline flex items-center gap-1">
<Scroll size={10} color={questReady > 0 ? '#f4c94e' : '#6b7a99'} /> <Scroll size={10} className={questReady > 0 ? 'text-rpg-gold' : 'text-rpg-muted'} />
<span>{questCount} quête{questCount !== 1 ? 's' : ''}</span> <span className="hud-label">{questCount} quête{questCount !== 1 ? 's' : ''}</span>
{questReady > 0 && ( {questReady > 0 && (
<span style={{ color: '#f4c94e', fontWeight: 700 }}>({questReady} prête{questReady > 1 ? 's' : ''} !)</span> <span className="text-rpg-gold font-bold">({questReady} prête{questReady > 1 ? 's' : ''} !)</span>
)} )}
</Link> </Link>
</div> </div>

View File

@@ -1,12 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy, ShoppingBag, BookOpen } from 'lucide-react'; import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy, ShoppingBag, BookOpen, Landmark } from 'lucide-react';
import { HudBar } from './HudBar'; import { HudBar } from './HudBar';
import { GuideDrawer } from './GuideDrawer'; import { GuideDrawer } from './GuideDrawer';
const NAV = [ const NAV = [
{ to: '/dashboard', icon: User, label: 'Personnage' }, { to: '/dashboard', icon: User, label: 'Personnage' },
{ to: '/village', icon: Landmark, label: 'Village' },
{ to: '/quests', icon: Scroll, label: 'Quêtes' }, { to: '/quests', icon: Scroll, label: 'Quêtes' },
{ to: '/combat', icon: Swords, label: 'Combat' }, { to: '/combat', icon: Swords, label: 'Combat' },
{ to: '/inventory', icon: Package, label: 'Inventaire' }, { to: '/inventory', icon: Package, label: 'Inventaire' },

View File

@@ -19,6 +19,10 @@ export const ZONE_INFO: Record<string, { name: string; emoji: string; color: str
marais: { name: 'Les Marais', emoji: '🌿', color: '#3ddc84' }, marais: { name: 'Les Marais', emoji: '🌿', color: '#3ddc84' },
egouts: { name: 'Les Égouts', emoji: '🕳️', color: '#5ba4f5' }, egouts: { name: 'Les Égouts', emoji: '🕳️', color: '#5ba4f5' },
desert: { name: 'Le Désert', emoji: '🏜️', color: '#f4c94e' }, 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> = { export const STAT_LABELS: Record<string, string> = {

View File

@@ -137,6 +137,7 @@ body {
/* HudBar compact */ /* HudBar compact */
.hud-bar { font-size: 10px; gap: 6px; padding: 4px 8px; flex-wrap: wrap; } .hud-bar { font-size: 10px; gap: 6px; padding: 4px 8px; flex-wrap: wrap; }
.hud-regen { display: none; }
/* Guide drawer full width mobile */ /* Guide drawer full width mobile */
.guide-drawer { width: 100% !important; } .guide-drawer { width: 100% !important; }
@@ -147,3 +148,10 @@ body {
/* Header compact */ /* Header compact */
.header-username { display: none; } .header-username { display: none; }
} }
/* ── Ultra-compact mobile (petit écran) ── */
@media (max-width: 480px) {
.hud-bar { gap: 4px; padding: 3px 6px; font-size: 9px; }
.hud-sep { display: none; }
.hud-label { display: none; }
}

View File

@@ -3,7 +3,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { combatApi, characterApi } from '../api/endpoints'; import { combatApi, characterApi } from '../api/endpoints';
import type { Monster, CombatResult, MultiCombatResult } from '../api/types'; 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 { COMBAT_COST, REST_COST, ATTACK_TYPES, ZONE_INFO } from '../constants';
import { MonsterCard } from '../components/MonsterCard'; import { MonsterCard } from '../components/MonsterCard';
import { CombatLogView, MultiCombatView, HistoryEntry } from '../components/CombatViews'; import { CombatLogView, MultiCombatView, HistoryEntry } from '../components/CombatViews';
@@ -85,7 +86,12 @@ export function CombatPage() {
return ( return (
<div> <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' }}> <div className="grid-2" style={{ marginBottom: '1rem' }}>
{/* Choix monstre par zone */} {/* Choix monstre par zone */}

View File

@@ -7,14 +7,21 @@ import { RARITY_COLORS, FORGE_TABLE, ZONE_INFO } from '../constants';
import { RarityBadge } from '../components/RarityBadge'; import { RarityBadge } from '../components/RarityBadge';
const ZONES = [ const ZONES = [
{ id: 'marais', ...ZONE_INFO.marais, desc: 'Zone de départ. Monstres niv. 1-9. Terre de boue et de brume.' }, // Acte I — L'Étang
{ id: 'marais', ...ZONE_INFO.marais, desc: 'Zone de départ. Monstres niv. 1-5. Terre de boue et de brume.' },
{ id: 'egouts', ...ZONE_INFO.egouts, desc: 'Sous-terrain infesté. Monstres niv. 4-10. Rats, slimes et croco.' }, { id: 'egouts', ...ZONE_INFO.egouts, desc: 'Sous-terrain infesté. Monstres niv. 4-10. Rats, slimes et croco.' },
{ id: 'desert', ...ZONE_INFO.desert, desc: 'Sable brûlant. Monstres niv. 8-15. Scorpions, momies et le Sphinx.' }, { id: 'desert', ...ZONE_INFO.desert, desc: 'Sable brûlant. Monstres niv. 8-15. Scorpions, momies et le Sphinx.' },
// Acte II — L'Odyssée (débloqué après le Serment des Trois)
{ id: 'ruisseau_miroir', ...ZONE_INFO.ruisseau_miroir, desc: 'Eau cristalline qui reflète vos peurs. Monstres niv. 12-17. Combat tactique.' },
{ id: 'marais_murmures', ...ZONE_INFO.marais_murmures, desc: 'Marais hanté de murmures anciens. Monstres niv. 15-20. La Batracienne vous attend.' },
{ id: 'torrent_brise', ...ZONE_INFO.torrent_brise, desc: 'Eaux violentes où la force ne suffit pas. Monstres niv. 18-23. Apprenez la résonance.' },
{ id: 'source_courant', ...ZONE_INFO.source_courant, desc: 'Lieu légendaire où le Chant est né. Monstres niv. 21-25. Le Dao du Courant.' },
]; ];
const TABS = [ const TABS = [
{ id: 'start', label: 'Démarrer', icon: BookOpen }, { id: 'start', label: 'Démarrer', icon: BookOpen },
{ id: 'zones', label: 'Zones', icon: MapIcon }, { id: 'zones', label: 'Zones', icon: MapIcon },
{ id: 'dao', label: 'Dao', icon: Gamepad2 },
{ id: 'bestiary', label: 'Bestiaire', icon: Swords }, { id: 'bestiary', label: 'Bestiaire', icon: Swords },
{ id: 'items', label: 'Équipement', icon: Shield }, { id: 'items', label: 'Équipement', icon: Shield },
{ id: 'craft', label: 'Artisanat', icon: Hammer }, { id: 'craft', label: 'Artisanat', icon: Hammer },
@@ -105,6 +112,66 @@ function ZonesTab() {
); );
} }
// ── Tab: Dao du Courant ──
function DaoTab() {
return (
<div>
<h3 style={{ color: '#d4af37', margin: '0 0 1rem', fontSize: 18 }}>Le Dao du Courant</h3>
<div className="card" style={{ marginBottom: '1rem' }}>
<p style={{ color: '#dce4f0', fontSize: 13, lineHeight: 1.6, margin: 0 }}>
Après avoir complété les 3 arcs de l'Acte I et prêté <strong style={{ color: '#f4c94e' }}>Le Serment des Trois</strong>,
le jeu se transforme. Le combat devient <strong>tactique tour par tour</strong> avec sorts, compagnons et stratégie.
</p>
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Les 3 voies</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{[
{ name: 'Écoute', color: '#88c8e8', desc: 'Contrôle et perception. Révèle faiblesses, chant offensif, ancrage.', archetype: 'Le stratège' },
{ name: 'Résonance', color: '#f4c94e', desc: 'Force amplifiée. Onde de choc, bouclier, contre-attaque, stun.', archetype: 'Le protecteur' },
{ name: 'Harmonie', color: '#3ddc84', desc: 'Support et guérison. Heal, purge, buff équipe, symphonie ultime.', archetype: 'L\'harmoniste' },
].map(v => (
<div key={v.name} className="card" style={{ padding: '0.75rem', borderLeft: `3px solid ${v.color}` }}>
<div style={{ fontSize: 14, fontWeight: 700, color: v.color }}>{v.name}</div>
<div style={{ fontSize: 10, color: '#6b7a99', marginBottom: 6 }}>{v.archetype}</div>
<div style={{ fontSize: 11, color: '#9ca3af' }}>{v.desc}</div>
</div>
))}
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Combat tactique</h4>
<div className="card" style={{ fontSize: 12, lineHeight: 1.8, color: '#9ca3af' }}>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#dce4f0' }}>Tour par tour</strong> — Chaque tour : [Attaque] [Sorts] [Items] [Fuir]. Fini l'auto-attaque.</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#dce4f0' }}>Mana</strong> — Les sorts consomment du Mana (base 50 + Intelligence ×2). Régénération : +5/tour.</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#dce4f0' }}>Buffs & Debuffs</strong> — Bouclier, poison, confusion, regen... la stratégie compte.</p>
<p style={{ margin: 0 }}><strong style={{ color: '#dce4f0' }}>Grind rapide</strong> — Les zones 1-3 gardent le combat simple (×1/×5/×10) pour farmer.</p>
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Compagnons</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div className="card" style={{ padding: '0.75rem', borderLeft: '3px solid #88c8e8' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#88c8e8' }}>Mira</div>
<div style={{ fontSize: 11, color: '#9ca3af' }}>Heal si HP bas, buff défensif, purge debuffs. Elle chante pour vous protéger.</div>
</div>
<div className="card" style={{ padding: '0.75rem', borderLeft: '3px solid #f4c94e' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#f4c94e' }}>Vell</div>
<div style={{ fontSize: 11, color: '#9ca3af' }}>Tank et protège. Taunt, contre-attaque, onde de choc. Sa force est devenue sagesse.</div>
</div>
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Ce qui change à l'Acte II</h4>
<div className="card" style={{ fontSize: 12, lineHeight: 1.8, color: '#9ca3af' }}>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#f4c94e' }}>Combat</strong> — Auto → Tour par tour stratégique</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#f4c94e' }}>Sorts</strong> — 15 sorts (5 par voie), débloqués avec des points de voie</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#f4c94e' }}>Compagnons</strong> — Mira et Vell combattent à vos côtés (IA auto)</p>
<p style={{ margin: 0 }}><strong style={{ color: '#f4c94e' }}>L'histoire</strong> — Chaque zone raconte un chapitre de l'Odyssée</p>
</div>
</div>
);
}
// ── Tab: Bestiaire ── // ── Tab: Bestiaire ──
function BestiaryTab({ monsters, materials }: { monsters: (Monster & { zone: string })[]; materials: any[] }) { function BestiaryTab({ monsters, materials }: { monsters: (Monster & { zone: string })[]; materials: any[] }) {
@@ -483,6 +550,7 @@ export function GuidePage() {
{/* Tab content */} {/* Tab content */}
{tab === 'start' && <StartTab />} {tab === 'start' && <StartTab />}
{tab === 'zones' && <ZonesTab />} {tab === 'zones' && <ZonesTab />}
{tab === 'dao' && <DaoTab />}
{tab === 'bestiary' && <BestiaryTab monsters={filteredMonsters} materials={materials} />} {tab === 'bestiary' && <BestiaryTab monsters={filteredMonsters} materials={materials} />}
{tab === 'items' && <ItemsTab items={filteredItems} />} {tab === 'items' && <ItemsTab items={filteredItems} />}
{tab === 'craft' && <CraftTab recipes={filteredRecipes} materials={materials} />} {tab === 'craft' && <CraftTab recipes={filteredRecipes} materials={materials} />}

View File

@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { questApi } from '../api/endpoints'; import { questApi } from '../api/endpoints';
import { Scroll, CheckCircle, Circle, Trophy, ChevronDown, ChevronRight, Star, Coins, Swords, Lock } from 'lucide-react'; import { Scroll, CheckCircle, Circle, Trophy, ChevronDown, ChevronRight, Star, Coins, Swords, Lock } from 'lucide-react';
import { useState } from 'react'; import { useState, useMemo } from 'react';
const OBJ_LABELS: Record<string, string> = { const OBJ_LABELS: Record<string, string> = {
kill_monster: 'Tuer', kill_monster: 'Tuer',
@@ -11,14 +11,9 @@ const OBJ_LABELS: Record<string, string> = {
forge_item: 'Forger', forge_item: 'Forger',
}; };
function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'completed' }) { function useInvalidateQuests() {
const qc = useQueryClient(); const qc = useQueryClient();
const quest = mode === 'active' ? pq.quest : pq; return () => {
const progress = mode === 'active' ? pq.progress : 0;
const status = mode === 'active' ? pq.status : 'available';
const pct = Math.min(100, Math.floor((progress / quest.objectiveCount) * 100));
const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ['quests'] }); qc.invalidateQueries({ queryKey: ['quests'] });
qc.invalidateQueries({ queryKey: ['questsActive'] }); qc.invalidateQueries({ queryKey: ['questsActive'] });
qc.invalidateQueries({ queryKey: ['questsAvailable'] }); qc.invalidateQueries({ queryKey: ['questsAvailable'] });
@@ -26,6 +21,14 @@ function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'comp
qc.invalidateQueries({ queryKey: ['questArcs'] }); qc.invalidateQueries({ queryKey: ['questArcs'] });
qc.invalidateQueries({ queryKey: ['character'] }); qc.invalidateQueries({ queryKey: ['character'] });
}; };
}
function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'completed' }) {
const invalidateAll = useInvalidateQuests();
const quest = mode === 'active' ? pq.quest : pq;
const progress = mode === 'active' ? pq.progress : 0;
const status = mode === 'active' ? pq.status : 'available';
const pct = Math.min(100, Math.floor((progress / quest.objectiveCount) * 100));
const acceptMut = useMutation({ const acceptMut = useMutation({
mutationFn: () => questApi.accept(quest.id), mutationFn: () => questApi.accept(quest.id),
@@ -46,72 +49,57 @@ function QuestCard({ pq, mode }: { pq: any; mode: 'active' | 'available' | 'comp
const isClaimed = status === 'claimed'; const isClaimed = status === 'claimed';
return ( return (
<div className={`card ${isCompleted ? 'card-gold' : ''}`} style={{ padding: '0.75rem 1rem' }}> <div className={`card ${isCompleted ? 'card-gold' : ''} py-3 px-4`}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 }}> <div className="flex justify-between items-start mb-1">
<div style={{ flex: 1 }}> <div className="flex-1">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div className="flex items-center gap-1.5">
{isClaimed ? <CheckCircle size={14} color="#3ddc84" /> : isCompleted ? <Trophy size={14} color="#f4c94e" /> : <Circle size={13} color="#6b7a99" />} {isClaimed ? <CheckCircle size={14} className="text-rpg-green" /> : isCompleted ? <Trophy size={14} className="text-rpg-gold" /> : <Circle size={13} className="text-rpg-muted" />}
<span style={{ fontWeight: 700, fontSize: 13, color: isCompleted ? '#f4c94e' : '#dce4f0' }}>{quest.name}</span> <span className={`font-bold text-[13px] ${isCompleted ? 'text-rpg-gold' : 'text-rpg-text'}`}>{quest.name}</span>
{quest.repeatable && <span style={{ fontSize: 9, color: '#5ba4f5', background: '#1a2540', padding: '1px 5px', borderRadius: 4 }}>répétable</span>} {quest.repeatable && <span className="text-[9px] text-rpg-blue bg-[#1a2540] px-1.5 py-px rounded">répétable</span>}
</div> </div>
<p style={{ margin: '4px 0 0', fontSize: 11, color: '#6b7a99' }}>{quest.description}</p> <p className="mt-1 mb-0 text-[11px] text-rpg-muted">{quest.description}</p>
</div> </div>
</div> </div>
{/* Objectif */} {/* Objectif */}
<div style={{ fontSize: 11, color: '#9ca3af', margin: '6px 0 4px' }}> <div className="text-[11px] text-[#9ca3af] mt-1.5 mb-1">
{OBJ_LABELS[quest.objectiveType] ?? quest.objectiveType} {mode === 'active' ? `${progress}/${quest.objectiveCount}` : `×${quest.objectiveCount}`} {OBJ_LABELS[quest.objectiveType] ?? quest.objectiveType} {mode === 'active' ? `${progress}/${quest.objectiveCount}` : `×${quest.objectiveCount}`}
</div> </div>
{/* Progress bar (active quests only) */} {/* Progress bar (active quests only) */}
{mode === 'active' && ( {mode === 'active' && (
<div style={{ background: '#1e2535', borderRadius: 4, height: 6, marginBottom: 6, overflow: 'hidden' }}> <div className="bar-track mb-1.5" style={{ height: 6 }}>
<div style={{ width: `${pct}%`, height: '100%', background: isCompleted ? '#f4c94e' : '#5ba4f5', borderRadius: 4, transition: 'width 0.3s' }} /> <div className={isCompleted ? 'bar-fill-xp' : 'bar-fill-end'} style={{ width: `${pct}%` }} />
</div> </div>
)} )}
{/* Rewards */} {/* Rewards */}
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: '#6b7a99', marginBottom: 6 }}> <div className="flex gap-3 text-[11px] text-rpg-muted mb-1.5">
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Star size={10} color="#a78bfa" /> {quest.rewardXp} XP</span> <span className="flex items-center gap-1"><Star size={10} className="text-rpg-purple" /> {quest.rewardXp} XP</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Coins size={10} color="#f4c94e" /> {quest.rewardGold} or</span> <span className="flex items-center gap-1"><Coins size={10} className="text-rpg-gold" /> {quest.rewardGold} or</span>
{quest.rewardTitle && <span style={{ color: '#f4c94e' }}>🏅 {quest.rewardTitle}</span>} {quest.rewardTitle && <span className="text-rpg-gold">🏅 {quest.rewardTitle}</span>}
{quest.minLevel > 1 && <span>Niv. {quest.minLevel}+</span>} {quest.minLevel > 1 && <span>Niv. {quest.minLevel}+</span>}
</div> </div>
{/* Actions */} {/* Actions */}
{mode === 'available' && ( {mode === 'available' && (
<button <button className="btn btn-ghost text-[11px] py-1 px-3" disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
className="btn btn-ghost"
style={{ fontSize: 11, padding: '0.25rem 0.75rem' }}
disabled={acceptMut.isPending}
onClick={() => acceptMut.mutate()}
>
{acceptMut.isPending ? 'Acceptation…' : '+ Accepter'} {acceptMut.isPending ? 'Acceptation…' : '+ Accepter'}
</button> </button>
)} )}
{mode === 'active' && isCompleted && ( {mode === 'active' && isCompleted && (
<button <button className="btn btn-gold text-[11px] py-1 px-3" disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
className="btn btn-gold"
style={{ fontSize: 11, padding: '0.25rem 0.75rem' }}
disabled={claimMut.isPending}
onClick={() => claimMut.mutate()}
>
{claimMut.isPending ? 'Réclamation…' : '🎁 Réclamer la récompense'} {claimMut.isPending ? 'Réclamation…' : '🎁 Réclamer la récompense'}
</button> </button>
)} )}
{mode === 'active' && !isCompleted && ( {mode === 'active' && !isCompleted && (
<button <button className="btn btn-ghost text-[10px] py-0.5 px-2 text-rpg-muted" disabled={abandonMut.isPending} onClick={() => abandonMut.mutate()}>
className="btn btn-ghost"
style={{ fontSize: 10, padding: '0.2rem 0.5rem', color: '#6b7a99' }}
disabled={abandonMut.isPending}
onClick={() => abandonMut.mutate()}
>
{abandonMut.isPending ? '…' : '✕ Abandonner'} {abandonMut.isPending ? '…' : '✕ Abandonner'}
</button> </button>
)} )}
{acceptMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(acceptMut.error as Error).message}</p>} {acceptMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(acceptMut.error as Error).message}</p>}
{claimMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(claimMut.error as Error).message}</p>} {claimMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(claimMut.error as Error).message}</p>}
{abandonMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 4 }}>{(abandonMut.error as Error).message}</p>} {abandonMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(abandonMut.error as Error).message}</p>}
</div> </div>
); );
} }
@@ -137,68 +125,83 @@ function ArcQuestRow({ q }: { q: any }) {
}); });
return ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, padding: '4px 0', borderBottom: '1px solid #1a2030' }}> <div className="flex items-center gap-2 text-xs py-1 border-b border-[#1a2030]">
{q.playerStatus === 'claimed' {q.playerStatus === 'claimed'
? <CheckCircle size={12} color="#3ddc84" /> ? <CheckCircle size={12} className="text-rpg-green shrink-0" />
: q.playerStatus === 'completed' : q.playerStatus === 'completed'
? <Trophy size={12} color="#f4c94e" /> ? <Trophy size={12} className="text-rpg-gold shrink-0" />
: q.playerStatus === 'active' : q.playerStatus === 'active'
? <Swords size={12} color="#5ba4f5" /> ? <Swords size={12} className="text-rpg-blue shrink-0" />
: <Circle size={11} color="#3a4560" /> : <Circle size={11} className="text-[#3a4560] shrink-0" />
} }
<div style={{ flex: 1 }}> <div className="flex-1 min-w-0">
<span style={{ <span className={
color: q.playerStatus === 'claimed' ? '#3ddc84' : q.playerStatus === 'active' ? '#dce4f0' : '#6b7a99', q.playerStatus === 'claimed' ? 'text-rpg-green' : q.playerStatus === 'active' ? 'text-rpg-text' : 'text-rpg-muted'
}}>{q.name}</span> }>{q.name}</span>
{q.playerStatus === 'active' && ( {q.playerStatus === 'active' && (
<span style={{ fontSize: 10, color: '#5ba4f5', marginLeft: 6 }}>{q.progress}/{q.objectiveCount}</span> <span className="text-[10px] text-rpg-blue ml-1.5">{q.progress}/{q.objectiveCount}</span>
)} )}
</div> </div>
<span style={{ fontSize: 10, color: '#6b7a99' }}>{q.rewardXp} XP</span> <span className="text-[10px] text-rpg-muted">{q.rewardXp} XP</span>
{q.minLevel > 1 && !q.levelOk && <span style={{ fontSize: 9, color: '#e84040' }}>Niv.{q.minLevel}</span>} {q.minLevel > 1 && !q.levelOk && <span className="text-[9px] text-rpg-red">Niv.{q.minLevel}</span>}
{/* Actions */} {/* Actions */}
{q.canAccept && ( {q.canAccept && (
<button className="btn btn-ghost" style={{ fontSize: 10, padding: '0.1rem 0.4rem' }} <button className="btn btn-ghost text-[10px] py-px px-1.5" disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
{acceptMut.isPending ? '...' : '+ Accepter'} {acceptMut.isPending ? '...' : '+ Accepter'}
</button> </button>
)} )}
{q.playerStatus === 'completed' && ( {q.playerStatus === 'completed' && (
<button className="btn btn-gold" style={{ fontSize: 10, padding: '0.1rem 0.4rem' }} <button className="btn btn-gold text-[10px] py-px px-1.5" disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
{claimMut.isPending ? '...' : '🎁 Réclamer'} {claimMut.isPending ? '...' : '🎁 Réclamer'}
</button> </button>
)} )}
{acceptMut.isError && <span style={{ color: '#e84040', fontSize: 9 }}>{(acceptMut.error as Error).message}</span>} {acceptMut.isError && <span className="text-rpg-red text-[9px]">{(acceptMut.error as Error).message}</span>}
</div> </div>
); );
} }
function ArcSection({ arc }: { arc: any }) { /** Détermine si un arc doit être ouvert par défaut */
const [open, setOpen] = useState(true); function shouldArcBeOpen(arc: any): boolean {
if (!arc.zoneUnlocked) return false;
if (arc.completed) return false;
// Ouvert si au moins une quête est active ou prête à réclamer
return arc.quests.some((q: any) => q.playerStatus === 'active' || q.playerStatus === 'completed');
}
function ArcSection({ arc, defaultOpen }: { arc: any; defaultOpen: boolean }) {
const [open, setOpen] = useState(defaultOpen);
const { completed, total } = arc.progress; const { completed, total } = arc.progress;
const locked = !arc.zoneUnlocked; const locked = !arc.zoneUnlocked;
const pct = total > 0 ? Math.floor((completed / total) * 100) : 0;
return ( return (
<div className={`card ${locked ? '' : arc.completed ? '' : 'card-gold'}`} style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', opacity: locked ? 0.4 : 1 }}> <div className={`card ${locked ? '' : arc.completed ? '' : 'card-gold'} py-3 px-4 mb-2 ${locked ? 'opacity-40' : ''}`}>
<div <div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: open && !locked ? 8 : 0 }} className={`flex items-center gap-2 cursor-pointer ${open && !locked ? 'mb-2' : ''}`}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
> >
{locked ? <Lock size={14} color="#6b7a99" /> : open ? <ChevronDown size={14} color="#6b7a99" /> : <ChevronRight size={14} color="#6b7a99" />} {locked ? <Lock size={14} className="text-rpg-muted shrink-0" /> : open ? <ChevronDown size={14} className="text-rpg-muted shrink-0" /> : <ChevronRight size={14} className="text-rpg-muted shrink-0" />}
<Scroll size={14} color={arc.completed ? '#3ddc84' : locked ? '#6b7a99' : '#f4c94e'} /> <Scroll size={14} className={`shrink-0 ${arc.completed ? 'text-rpg-green' : locked ? 'text-rpg-muted' : 'text-rpg-gold'}`} />
<span style={{ fontWeight: 700, fontSize: 14, color: arc.completed ? '#3ddc84' : locked ? '#6b7a99' : '#f4c94e', flex: 1 }}> <span className={`font-bold text-sm flex-1 ${arc.completed ? 'text-rpg-green' : locked ? 'text-rpg-muted' : 'text-rpg-gold'}`}>
{arc.name} {arc.name}
</span> </span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{completed}/{total}</span> <span className="text-[11px] text-rpg-muted">{completed}/{total}</span>
{arc.completed && <CheckCircle size={14} color="#3ddc84" />} {arc.completed && <CheckCircle size={14} className="text-rpg-green shrink-0" />}
{locked && <span style={{ fontSize: 10, color: '#6b7a99' }}>🔒 Complétez l'arc précédent</span>} {locked && <span className="text-[10px] text-rpg-muted">🔒 Complétez l'arc précédent</span>}
</div> </div>
{/* Progress bar */}
{!locked && (
<div className="bar-track mb-2" style={{ height: 4 }}>
<div className={arc.completed ? 'bar-fill-hp' : 'bar-fill-xp'} style={{ width: `${pct}%`, background: arc.completed ? '#3ddc84' : undefined }} />
</div>
)}
{open && !locked && ( {open && !locked && (
<> <>
<p style={{ fontSize: 11, color: '#6b7a99', margin: '0 0 8px', paddingLeft: 28 }}>{arc.description}</p> <p className="text-[11px] text-rpg-muted mb-2 pl-7">{arc.description}</p>
<div style={{ display: 'flex', flexDirection: 'column', paddingLeft: 12 }}> <div className="flex flex-col pl-3">
{arc.quests.map((q: any) => <ArcQuestRow key={q.id} q={q} />)} {arc.quests.map((q: any) => <ArcQuestRow key={q.id} q={q} />)}
</div> </div>
</> </>
@@ -213,12 +216,21 @@ export function QuestPage() {
const { data: arcs } = useQuery({ queryKey: ['questArcs'], queryFn: questApi.arcs }); const { data: arcs } = useQuery({ queryKey: ['questArcs'], queryFn: questApi.arcs });
const [showAllCombat, setShowAllCombat] = useState(false); const [showAllCombat, setShowAllCombat] = useState(false);
if (loadActive || loadAvail) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement…</div>; // Pré-calculer quels arcs sont ouverts par défaut (stable entre renders)
const arcDefaultOpen = useMemo(() => {
if (!arcs) return {};
const map: Record<string, boolean> = {};
for (const arc of arcs) {
map[arc.id] = shouldArcBeOpen(arc);
}
return map;
}, [arcs]);
if (loadActive || loadAvail) return <div className="p-8 text-rpg-muted">Chargement…</div>;
const isCraftQuest = (q: any) => ['forge_item', 'craft_item'].includes(q.objectiveType ?? q.quest?.objectiveType); const isCraftQuest = (q: any) => ['forge_item', 'craft_item'].includes(q.objectiveType ?? q.quest?.objectiveType);
const isCombatQuest = (q: any) => !isCraftQuest(q); const isCombatQuest = (q: any) => !isCraftQuest(q);
// Split by category
const activeAll = active ?? []; const activeAll = active ?? [];
const activeCombat = activeAll.filter((pq: any) => !pq.quest.repeatable && isCombatQuest(pq)); const activeCombat = activeAll.filter((pq: any) => !pq.quest.repeatable && isCombatQuest(pq));
const activeCraft = activeAll.filter((pq: any) => !pq.quest.repeatable && isCraftQuest(pq)); const activeCraft = activeAll.filter((pq: any) => !pq.quest.repeatable && isCraftQuest(pq));
@@ -233,37 +245,36 @@ export function QuestPage() {
return ( return (
<div> <div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>📜 Quêtes</h2> <h2 className="mb-4 text-rpg-gold text-xl font-bold">📜 Quêtes</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> <div className="grid-2">
{/* Active combat quests */} {/* Active combat quests */}
<div> <div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}> <p className="mb-2 text-xs font-bold text-rpg-muted uppercase">
Quêtes actives ({activeCombat.length}/3) Quêtes actives ({activeCombat.length}/3)
</p> </p>
{activeCombat.length > 0 ? ( {activeCombat.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div className="flex flex-col gap-1.5">
{activeCombat.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)} {activeCombat.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
</div> </div>
) : ( ) : (
<div className="card" style={{ padding: '1.5rem', textAlign: 'center', color: '#6b7a99', fontSize: 13 }}> <div className="card py-6 text-center text-rpg-muted text-[13px]">
Aucune quête active — acceptez-en à droite Aucune quête active — acceptez-en à droite
</div> </div>
)} )}
</div> </div>
{/* Available combat quests (staggered) */} {/* Available combat quests */}
<div> <div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}> <p className="mb-2 text-xs font-bold text-rpg-muted uppercase">
Quêtes de combat Quêtes de combat
</p> </p>
{shownCombat.length > 0 ? ( {shownCombat.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div className="flex flex-col gap-1.5">
{shownCombat.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)} {shownCombat.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
{hiddenCount > 0 && ( {hiddenCount > 0 && (
<button <button
className="btn btn-ghost" className="btn btn-ghost w-full text-[11px] py-1 mt-0.5"
style={{ width: '100%', fontSize: 11, padding: '0.3rem', marginTop: 2 }}
onClick={() => setShowAllCombat(!showAllCombat)} onClick={() => setShowAllCombat(!showAllCombat)}
> >
{showAllCombat ? 'Réduire' : `Voir tout (+${hiddenCount} quête${hiddenCount > 1 ? 's' : ''})`} {showAllCombat ? 'Réduire' : `Voir tout (+${hiddenCount} quête${hiddenCount > 1 ? 's' : ''})`}
@@ -271,32 +282,28 @@ export function QuestPage() {
)} )}
</div> </div>
) : ( ) : (
<div className="card" style={{ padding: '1.5rem', textAlign: 'center', color: '#6b7a99', fontSize: 13 }}> <div className="card py-6 text-center text-rpg-muted text-[13px]">
Toutes les quêtes de combat sont complétées Toutes les quêtes de combat sont complétées
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* Métiers (craft/forge — hors pool, comme les dailies) */} {/* Métiers */}
{(activeCraft.length > 0 || availableCraft.length > 0) && ( {(activeCraft.length > 0 || availableCraft.length > 0) && (
<div style={{ marginTop: '1.5rem' }}> <div className="mt-6">
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}> <p className="mb-2 text-xs font-bold text-rpg-muted uppercase">🔨 Métiers</p>
🔨 Métiers <div className="grid-2-cards">
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
{activeCraft.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)} {activeCraft.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
{availableCraft.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)} {availableCraft.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
</div> </div>
</div> </div>
)} )}
{/* Tâches quotidiennes (répétables — toujours en fond) */} {/* Tâches quotidiennes */}
<div style={{ marginTop: '1.5rem' }}> <div className="mt-6">
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}> <p className="mb-2 text-xs font-bold text-rpg-muted uppercase">🔄 Tâches quotidiennes</p>
🔄 Tâches quotidiennes <div className="grid-2-cards">
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
{activeDaily.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)} {activeDaily.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
{availableDaily.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)} {availableDaily.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
</div> </div>
@@ -304,11 +311,11 @@ export function QuestPage() {
{/* Arcs narratifs */} {/* Arcs narratifs */}
{arcs && arcs.length > 0 && ( {arcs && arcs.length > 0 && (
<div style={{ marginTop: '1.5rem' }}> <div className="mt-6">
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}> <p className="mb-2 text-xs font-bold text-rpg-muted uppercase">📖 Arcs narratifs</p>
📖 Arcs narratifs {arcs.map((arc: any) => (
</p> <ArcSection key={arc.id} arc={arc} defaultOpen={arcDefaultOpen[arc.id] ?? false} />
{arcs.map((arc: any) => <ArcSection key={arc.id} arc={arc} />)} ))}
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,485 @@
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, TurnBuff } from '../api/types';
import { Swords, Sparkles, PackageOpen, ArrowLeft, Zap, 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 { data: daoPaths } = useQuery({ queryKey: ['daoPaths'], queryFn: turnCombatApi.dao });
const hasDaoPath = daoPaths && daoPaths.length > 0 && daoPaths.some((p: any) => p.isPrimary || p.is_primary);
const chooseDaoMut = useMutation({
mutationFn: (path: string) => turnCombatApi.chooseDaoPath(path),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['daoPaths'] });
qc.invalidateQueries({ queryKey: ['turnSpells'] });
toast.success('Voie du Dao choisie !');
},
onError: (err: Error) => toast.error(err.message),
});
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: CHOOSE DAO PATH ==========
if (!hasDaoPath) {
const paths = [
{ id: 'ecoute', name: 'Écoute', color: '#88c8e8', icon: '👁️', archetype: 'Le stratège',
desc: 'Perception du flux, chant offensif, ancrage mémoriel. Tu deviens ce que Gorn t\'a appris : observer, comprendre.',
spell: 'Perception du Flux (révèle faiblesses, +20% dégâts)' },
{ id: 'resonance', name: 'Résonance', color: '#f4c94e', icon: '💪', archetype: 'Le protecteur',
desc: 'Onde de choc, bouclier, contre-attaque. Tu deviens ce que Vell a appris : la vraie force protège.',
spell: 'Onde de Choc (dégâts AoE, Force ×1.5)' },
{ id: 'harmonie', name: 'Harmonie', color: '#3ddc84', icon: '🎵', archetype: 'L\'harmoniste',
desc: 'Chant apaisant, purge, soin d\'équipe. Tu deviens ce que Mira est : le chant qui guérit.',
spell: 'Chant Apaisant (soin Int ×2 + 10% HP max)' },
];
return (
<div>
<h2 style={{ margin: '0 0 0.5rem', color: '#d4af37', fontSize: 20 }}>Le Dao du Courant s'éveille</h2>
<p style={{ color: '#9ca3af', fontSize: 13, margin: '0 0 1.5rem', lineHeight: 1.6 }}>
Gorn est parti. Le Serment est prêté. Le courant coule en toi.<br />
<strong style={{ color: '#dce4f0' }}>Quelle voie du Dao vas-tu suivre ?</strong>
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
{paths.map(p => (
<button
key={p.id}
onClick={() => chooseDaoMut.mutate(p.id)}
disabled={chooseDaoMut.isPending}
className="card"
style={{
padding: '1rem', cursor: 'pointer', border: '2px solid transparent',
textAlign: 'left', transition: 'border-color 0.2s',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = p.color)}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'transparent')}
>
<div style={{ fontSize: 28, marginBottom: 8 }}>{p.icon}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: p.color }}>{p.name}</div>
<div style={{ fontSize: 11, color: '#6b7a99', marginBottom: 8 }}>{p.archetype}</div>
<div style={{ fontSize: 12, color: '#9ca3af', lineHeight: 1.5, marginBottom: 10 }}>{p.desc}</div>
<div style={{ fontSize: 11, color: '#dce4f0', padding: '6px 8px', background: '#1e2535', borderRadius: 6 }}>
✨ Sort offert : {p.spell}
</div>
</button>
))}
</div>
<p style={{ color: '#6b7a99', fontSize: 11, marginTop: 12, textAlign: 'center' }}>
Tu pourras explorer les autres voies plus tard — ta voie principale progresse plus vite.
</p>
</div>
);
}
// ========== 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

@@ -0,0 +1,275 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { npcApi, characterApi } from '../api/endpoints';
import type { NpcView } from '../api/types';
import { Landmark, Heart, Zap, ArrowRight } from 'lucide-react';
import { REST_COST } from '../constants';
// ── Constants ──
const ROLE_EMOJI: Record<string, string> = {
mentor: '🧙',
companion: '💚',
merchant: '🛍️',
quest_giver: '📜',
sage: '🔮',
rival: '⚔️',
};
const ROLE_LABELS: Record<string, string> = {
mentor: 'Mentor',
companion: 'Compagnon',
merchant: 'Marchand',
quest_giver: 'Quêtes',
sage: 'Sage',
rival: 'Rival',
};
const ROLE_COLORS: Record<string, string> = {
mentor: '#f4c94e',
companion: '#3ddc84',
merchant: '#5ba4f5',
quest_giver: '#a78bfa',
sage: '#a78bfa',
rival: '#e84040',
};
const ACTION_LABELS: Record<string, { label: string; emoji: string }> = {
heal: { label: 'Soins', emoji: '🩹' },
open_quests: { label: 'Voir les quêtes', emoji: '📜' },
open_shop: { label: 'Voir la boutique', emoji: '🛍️' },
open_forge: { label: 'Voir la forge', emoji: '🔨' },
challenge: { label: 'Défier', emoji: '⚔️' },
};
const ACTION_ROUTES: Record<string, string> = {
open_quests: '/quests',
open_shop: '/shop',
open_forge: '/forge',
challenge: '/combat',
};
const VILLAGE_LOCATIONS: Record<string, { name: string; emoji: string; atmosphere: string }> = {
village_plaza: {
name: 'Place du Village',
emoji: '🌸',
atmosphere: 'Les nénuphars flottent doucement sous la lumière filtrée. Un air familier résonne.',
},
village_arena: {
name: 'Arène',
emoji: '🏟️',
atmosphere: 'L\'écho des combats passés résonne sur les pierres mouillées.',
},
village_quests: {
name: 'Source aux Quêtes',
emoji: '📜',
atmosphere: 'Le murmure du savoir coule comme un ruisseau entre les rochers moussus.',
},
village_forge: {
name: 'La Forge',
emoji: '🔨',
atmosphere: 'Des étincelles dansent et le métal chante sous les coups du marteau.',
},
village_shop: {
name: 'L\'Échoppe',
emoji: '🏪',
atmosphere: 'Des marchandises exotiques s\'étalent sur des feuilles de nénuphar géantes.',
},
};
const LOCATION_ORDER = ['village_plaza', 'village_arena', 'village_quests', 'village_forge', 'village_shop'];
// ── Components ──
function NpcCard({ npc, character }: { npc: NpcView; character: any }) {
const navigate = useNavigate();
const qc = useQueryClient();
const roleColor = ROLE_COLORS[npc.role] ?? '#6b7a99';
const roleEmoji = ROLE_EMOJI[npc.role] ?? '🐸';
const roleLabel = ROLE_LABELS[npc.role] ?? npc.role;
const healMut = useMutation({
mutationFn: () => characterApi.rest(),
onSuccess: (data) => {
toast.success(`${npc.name} vous soigne ! +${data.healed} PV`);
qc.invalidateQueries({ queryKey: ['character'] });
},
onError: (err: Error) => toast.error(err.message),
});
const endurance = character?.enduranceCurrent ?? 0;
const needsHeal = character && character.hpCurrent < character.hpMax;
const canHeal = needsHeal && endurance >= REST_COST;
const handleAction = () => {
if (!npc.action) return;
if (npc.action === 'heal') {
healMut.mutate();
return;
}
const route = ACTION_ROUTES[npc.action];
if (route) navigate(route);
};
const actionInfo = npc.action ? ACTION_LABELS[npc.action] : null;
return (
<div className="card flex-1 min-w-[280px] py-4 px-5">
{/* Header */}
<div className="flex items-start gap-3 mb-3">
<span className="text-[32px] leading-none">{roleEmoji}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-rpg-text">{npc.name}</span>
<span
className="text-[9px] font-bold px-1.5 py-px rounded uppercase"
style={{ background: roleColor + '22', color: roleColor }}
>
{roleLabel}
</span>
</div>
{npc.lore && (
<p className="mt-1 mb-0 text-[11px] text-rpg-muted italic line-clamp-2">{npc.lore}</p>
)}
</div>
</div>
{/* Dialogue bubble */}
<div
className="rounded-md px-3 py-2.5 mb-3 text-xs text-rpg-text leading-relaxed"
style={{
background: '#111620',
borderLeft: `3px solid ${roleColor}`,
}}
>
« {npc.dialogue} »
</div>
{/* Action */}
{actionInfo && (
<div>
{npc.action === 'heal' ? (
<div>
<button
className={`btn ${canHeal ? 'btn-gold' : 'btn-ghost'} w-full text-xs py-1.5 flex items-center justify-center gap-2`}
disabled={!canHeal || healMut.isPending}
onClick={handleAction}
>
{healMut.isPending ? (
'Soins en cours…'
) : (
<>
<Heart size={12} />
{actionInfo.label}
<span className="text-[10px] opacity-70 flex items-center gap-0.5">
({REST_COST} <Zap size={9} />)
</span>
</>
)}
</button>
{!needsHeal && (
<p className="text-[10px] text-rpg-green text-center mt-1.5">Vos PV sont au maximum !</p>
)}
{needsHeal && !canHeal && (
<p className="text-[10px] text-rpg-red text-center mt-1.5">Endurance insuffisante</p>
)}
</div>
) : (
<button
className="btn btn-ghost w-full text-xs py-1.5 flex items-center justify-center gap-2"
onClick={handleAction}
>
{actionInfo.emoji} {actionInfo.label}
<ArrowRight size={12} className="opacity-50" />
</button>
)}
</div>
)}
</div>
);
}
function VillageLocation({ locationKey, npcs, character }: {
locationKey: string;
npcs: NpcView[];
character: any;
}) {
const loc = VILLAGE_LOCATIONS[locationKey];
if (!loc || npcs.length === 0) return null;
return (
<div className="mb-6">
{/* Location header */}
<div className="mb-3">
<h3 className="text-sm font-bold text-rpg-text flex items-center gap-2 mb-1">
<span className="text-base">{loc.emoji}</span>
{loc.name}
</h3>
<p className="text-[11px] text-rpg-muted italic pl-7">{loc.atmosphere}</p>
</div>
{/* NPC cards */}
<div className="flex gap-3 flex-wrap">
{npcs.map((npc) => (
<NpcCard key={npc.id} npc={npc} character={character} />
))}
</div>
</div>
);
}
export function VillagePage() {
const { data: npcs, isLoading } = useQuery({
queryKey: ['npcs'],
queryFn: npcApi.all,
});
const { data: character } = useQuery({
queryKey: ['character'],
queryFn: characterApi.me,
});
if (isLoading) return <div className="p-8 text-rpg-muted">Chargement du village</div>;
// Group NPCs by location
const byLocation = new Map<string, NpcView[]>();
for (const npc of (npcs ?? [])) {
const list = byLocation.get(npc.location) ?? [];
list.push(npc);
byLocation.set(npc.location, list);
}
return (
<div>
{/* Village banner */}
<div className="card card-gold mb-6 py-4 px-5">
<div className="flex items-center gap-3 mb-2">
<Landmark size={20} className="text-rpg-gold" />
<h2 className="text-lg font-bold text-rpg-gold m-0">Le Village</h2>
</div>
<p className="text-xs text-rpg-muted m-0">
L'étang murmure doucement. Les grenouilles vaquent à leurs occupations.
Un lieu de repos, de rencontres et de préparation avant la prochaine aventure.
</p>
</div>
{/* Location sections */}
{LOCATION_ORDER.map((locKey) => (
<VillageLocation
key={locKey}
locationKey={locKey}
npcs={byLocation.get(locKey) ?? []}
character={character}
/>
))}
{/* Empty state */}
{(!npcs || npcs.length === 0) && (
<div className="card py-8 text-center text-rpg-muted text-sm">
Le village semble désert Revenez plus tard.
</div>
)}
</div>
);
}

View File

@@ -10,6 +10,7 @@
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts", "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: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: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", "typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",

View File

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

View File

@@ -57,6 +57,13 @@ export class Character {
@Column({ name: 'hp_max', default: 100 }) @Column({ name: 'hp_max', default: 100 })
hpMax: number; 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) // Endurance — lazy calculation (pas de timer actif)
@Column({ name: 'endurance_saved', default: 100 }) @Column({ name: 'endurance_saved', default: 100 })
enduranceSaved: number; enduranceSaved: number;

View File

@@ -9,17 +9,23 @@ import { AuthModule } from '../auth/auth.module';
import { ItemModule } from '../item/item.module'; import { ItemModule } from '../item/item.module';
import { MaterialModule } from '../material/material.module'; import { MaterialModule } from '../material/material.module';
import { CommunityModule } from '../community/community.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({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Character, CombatLog]), TypeOrmModule.forFeature([Character, CombatLog, Spell, PlayerSpell, PlayerDaoPath]),
MonsterModule, MonsterModule,
AuthModule, AuthModule,
ItemModule, ItemModule,
MaterialModule, MaterialModule,
CommunityModule, CommunityModule,
], ],
controllers: [CombatController], controllers: [CombatController, TurnCombatController],
providers: [CombatService], providers: [CombatService, SpellSystem, TurnCombatService],
}) })
export class CombatModule {} export class CombatModule {}

View File

@@ -467,6 +467,7 @@ export class CombatService {
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: txResult.totals.wins }); this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: txResult.totals.wins });
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_gold_earned', increment: txResult.totals.gold }); this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_gold_earned', increment: txResult.totals.gold });
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: txResult.totals.wins, zone: monster.zone }); this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: txResult.totals.wins, zone: monster.zone });
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_monster', targetId: monster.id, increment: txResult.totals.wins, zone: monster.zone });
for (const matId of txResult.lootedMaterialIds) { for (const matId of txResult.lootedMaterialIds) {
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: matId, increment: 1 }); this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: matId, increment: 1 });
} }

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'; import { QuestArc } from '../quest/quest-arc.entity';
// Zone unlock chain: each zone requires completing the previous zone's arc // Zone unlock chain: each zone requires completing the previous zone's arc
// marais → always open // marais → always open (L'Étang — niv 1-5)
// egouts → requires "Les Marais du Têtard" arc completed // egouts → requires "Les Marais du Têtard" arc (L'Étang profond — niv 6-10)
// desert → requires the egouts arc completed // desert → requires egouts arc (L'Étang Brisé — niv 11-15)
const ZONE_ORDER = ['marais', 'egouts', 'desert']; // 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( export async function getUnlockedZones(
characterId: string, characterId: string,

View File

@@ -24,6 +24,10 @@ import { Quest } from '../quest/quest.entity';
import { QuestArc } from '../quest/quest-arc.entity'; import { QuestArc } from '../quest/quest-arc.entity';
import { PlayerQuest } from '../quest/player-quest.entity'; import { PlayerQuest } from '../quest/player-quest.entity';
import { PlayerQuestArc } from '../quest/player-quest-arc.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) // DataSource pour le CLI TypeORM (migrations manuelles)
export const AppDataSource = new DataSource({ export const AppDataSource = new DataSource({
@@ -54,6 +58,10 @@ export const AppDataSource = new DataSource({
QuestArc, QuestArc,
PlayerQuest, PlayerQuest,
PlayerQuestArc, PlayerQuestArc,
Spell,
PlayerSpell,
PlayerDaoPath,
Npc,
], ],
migrations: [__dirname + '/migrations/*{.ts,.js}'], migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false, 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,436 @@
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: 13,
}));
}
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: 13,
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: 13,
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 de transition — découverte des premiers monstres (bridge Acte I → Acte II)
const transitionQuests = [
{ name: 'Au-delà de l\'Étang', description: 'L\'eau change. Des reflets étranges dansent à la surface du Ruisseau. Affrontez votre premier Reflet Sombre.', objectiveType: 'kill_monster', objectiveTargetId: m.get('Reflet Sombre'), objectiveCount: 1, rewardXp: 400, rewardGold: 200, zone: 'ruisseau_miroir', minLevel: 13 },
{ name: 'Les Cristaux du Ruisseau', description: 'Des insectes de cristal patrouillent les rives. Éliminez 3 Gerris de Cristal.', objectiveType: 'kill_monster', objectiveTargetId: m.get('Gerris de Cristal'), objectiveCount: 3, rewardXp: 500, rewardGold: 250, zone: 'ruisseau_miroir', minLevel: 13 },
{ name: 'Explorateur du Miroir', description: 'Le Ruisseau regorge de créatures inconnues. Remportez 5 combats dans cette zone.', objectiveType: 'kill_any', objectiveTargetId: null, objectiveCount: 5, rewardXp: 600, rewardGold: 300, zone: 'ruisseau_miroir', minLevel: 13 },
{ name: 'Éclats de Vérité', description: 'Les créatures du Ruisseau laissent tomber des éclats brillants. Récoltez 3 Éclats de Miroir.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Éclat de Miroir'), objectiveCount: 3, rewardXp: 500, rewardGold: 250, zone: 'ruisseau_miroir', minLevel: 13 },
];
for (const q of transitionQuests) {
const existing = await questRepo.findOne({ where: { name: q.name } });
if (!existing) {
await questRepo.save(questRepo.create({ ...q, rewardTitle: null, arcId: null, arcOrder: 0, repeatable: false, acceptText: null, completeText: null }));
questsAdded++;
}
}
// Quêtes répétables (grind léger entre les arcs — optionnel)
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: 13 },
{ 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: 16 },
{ 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: 19 },
];
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 + ${transitionQuests.length} transition + ${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' }) @Column({ name: 'zone', type: 'varchar', length: 50, default: 'marais' })
zone: string; zone: string;
@Column({ name: 'ai_profile', type: 'varchar', length: 20, default: 'aggressive' })
aiProfile: string;
@Column({ name: 'is_boss', default: false })
isBoss: boolean;
} }

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

@@ -0,0 +1,36 @@
import { Controller, Get, Query, Req, UseGuards, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NpcService } from './npc.service';
import { AuthGuard } from '../auth/guards/auth.guard';
import { Character } from '../character/entities/character.entity';
@Controller('npcs')
@UseGuards(AuthGuard)
export class NpcController {
constructor(
private readonly npcService: NpcService,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
) {}
private async getCharacter(req: any) {
const character = await this.characterRepo.findOne({ where: { userId: req.user.id } });
if (!character) throw new BadRequestException('Aucun personnage trouvé');
return character;
}
/** GET /api/npcs — tous les PNJ visibles pour le joueur */
@Get()
async getAll(@Req() req: any) {
const char = await this.getCharacter(req);
return this.npcService.getVisibleNpcs(char.id, char.level);
}
/** GET /api/npcs/location?location=village_plaza — PNJ d'un emplacement */
@Get('location')
async getByLocation(@Req() req: any, @Query('location') location: string) {
const char = await this.getCharacter(req);
return this.npcService.getNpcsByLocation(char.id, char.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'
}

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

@@ -0,0 +1,16 @@
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';
import { Character } from '../character/entities/character.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([Npc, PlayerQuestArc, Character]), AuthModule],
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') @Column('text')
description: string; 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 // Objectif
@Column({ name: 'objective_type', length: 30 }) @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 }) @Column({ name: 'objective_target_id', type: 'varchar', length: 255, nullable: true })
objectiveTargetId: string | null; // monster ID or material ID (null for kill_any) 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); 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({ const pq = this.playerQuestRepo.create({
characterId, characterId,
questId, questId,
progress: 0, progress: isStoryEvent ? 1 : 0,
status: 'active', status: isStoryEvent ? 'completed' : 'active',
completedAt: isStoryEvent ? new Date() : null,
}); });
return this.playerQuestRepo.save(pq); return this.playerQuestRepo.save(pq);
} }