Compare commits

...

78 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
2c94e4f3aa refacto: migration Tailwind — composants (6 fichiers)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
- RarityBadge, RarityDot → Tailwind classes
- MonsterCard → flex/text-rpg-* classes
- CreateCharacter → full Tailwind (max-w, grid, gap)
- Onboarding → Tailwind + responsive grid-cols-1 mobile
- CombatViews (Log+Multi+History) → Tailwind
- NotFoundPage → full Tailwind
- Pattern posé : couleurs dynamiques en style, layout en classes
2026-03-24 23:54:06 +01:00
9eff6d541e refacto: découpage composants — 5 extractions
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
- MonsterCard, CombatViews (Log+Multi+History), CreateCharacter
- RarityBadge + RarityDot partagés (Guide, Drawer, pages)
- CombatPage 341→215 lignes (−37%)
- DashboardPage 368→307 lignes (−17%)
- 9 composants dans components/
2026-03-24 23:50:55 +01:00
71070b2e76 feat: mobile responsive — sidebar bottom nav + grids adaptatifs
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
- Sidebar → bottom nav fixe sur mobile (<768px)
- Classes CSS layout: .sidebar, .nav-item, .grid-2, .layout-*
- Grids 2col → 1col sur mobile (Dashboard, Combat, Forge)
- HudBar compact + wrapping sur mobile
- GuideDrawer full-width mobile
- Cards padding réduit mobile
- Header username masqué mobile
2026-03-24 23:36:45 +01:00
e769c27a42 feat: page 404 RPG + onboarding nouveau joueur
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
- 404: têtard perdu dans les marais, boutons retour jeu + guide
- Onboarding: 4 étapes guidées (quêtes, combat, craft, guide)
  - Visible niv 1-3, dismissable, grille 2×2 avec CTA par étape
- DashboardPage: STAT_LABELS importé depuis constants.ts
2026-03-24 23:17:09 +01:00
17c61a2bb8 refacto: constants.ts — source unique frontend
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 41s
- Centralise RARITY_COLORS, RARITY_LABELS, ZONE_INFO, STAT_LABELS
- Centralise COMBAT_COST, REST_COST, FORGE_*, ATTACK_TYPES
- Supprime 6 duplications dans CombatPage, GuidePage, ShopPage, ForgePage, InventoryPage
2026-03-24 22:30:59 +01:00
faf2a98227 feat: toast system — feedback visuel global (react-hot-toast)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 36s
- Toaster dark theme (bottom-right, 3s/4s)
- Combat: erreur cooldown/endurance en toast
- Craft: toast start + collect + erreurs
- Forge: toast succès/échec + erreurs
- Shop: toast achat + erreurs
- Inventaire: toast vente + erreurs
- Fix forge costs frontend (200/400/700)
2026-03-24 22:15:28 +01:00
0d917a8b39 feat: audit Phase 1 — P0/P1 quick wins
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
- Fix vitalité: HP initial = 100 + (vitalité-1)×10
- Arme de départ: Bâton de Roseau équipé à la création
- Rebalance forge: niv3 200, niv4 400, niv5 700 (−30%)
- Confirmation avant vente d'item (confirm dialog)
- Fix forge costs dupliqués (shop sellback + inventaire)
2026-03-24 21:53:45 +01:00
23843cb72c feat: guide — bouton Jouer (retour au jeu)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
2026-03-24 21:33:50 +01:00
dbdc02f4ab feat: guide drawer inline + hook partagé useGuideData
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
- GuideDrawer: panneau coulissant depuis la sidebar, recherche live
- useGuideData: hook unique — même cache React Query pour drawer + page
- Sidebar: BookOpen toggle le drawer (pas de navigation)
- Footer drawer: lien vers /guide complet
- GuidePage refactorisée sur useGuideData (zéro duplication)
2026-03-24 21:32:29 +01:00
84104cd96f feat: guide — barre de recherche live + lien sidebar (BookOpen, bas)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
2026-03-24 21:25:30 +01:00
823d7911f0 feat: page Guide publique — wiki joueur dynamique
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
- /guide accessible sans authentification
- 7 onglets : Démarrer, Zones, Bestiaire, Équipement, Artisanat, Forge, Boutique
- Données dynamiques (API publiques) — toujours à jour
- Endpoint /monsters/bestiary public (bestiaire complet toutes zones)
- Fix Item.type → inclut 'consumable'
2026-03-24 21:19:08 +01:00
4fc8be9ea0 feat: historique combat enrichi — loot affiché + 10 entrées
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
- Ajout loot_material_id + loot_quantity sur combat_logs
- Historique passe de 5 à 10 entrées
- Affichage loot (🎁×N) dans l'historique récent
- Fix scope variables multi-combat loot tracking
2026-03-24 21:08:49 +01:00
74938dd35f fix: cooldown serveur 2s/8s + loot dans transaction (élimine deadlock)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-24 21:04:11 +01:00
909b8da77f fix: multi-combat single transaction — élimine lock contention
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-24 20:51:31 +01:00
6ffc867ef7 fix: imports inutilisés frontend (useEffect, CombatResult)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-24 20:47:35 +01:00
efe4b4e372 feat: multi-combat ×5/×10 + cooldown anti-spam
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 30s
- Backend: startMultiCombat boucle séquentielle, arrêt sur défaite
- Frontend: cooldown 1.5s entre combats, boutons ×1/×5/×10
- Frontend: résumé multi-combat (wins/losses, XP/Or/loot totaux)
- Fix: lock contention par spam de clics résolu
2026-03-24 20:21:44 +01:00
ec6d91b0f9 fix: seed craft-drops — inclure matériaux/items/recettes Sprint 3 (migration MySQL)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
2026-03-24 20:10:44 +01:00
47c90e4d55 feat: craft/drops — 10 matériaux, 12 recettes, drop rate variable
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
- 10 matériaux Égouts/Désert (Poil de Rat → Œil du Sphinx)
- 12 items craftables dont 1 legendary (Sceptre Prophétique)
- 12 recettes cross-zone avec ingrédients cohérents
- 15 monstres mappés à leur drop (tous les Égouts/Désert)
- Drop rate variable par difficulté relative (25-80%)
- Quantité drop variable (1-3 selon boss/difficulté)
2026-03-24 20:07:18 +01:00
6938eedcda feat: titres sélectionnables + prix revente forge inclus
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
Dashboard: titre actif affiché "« Champion »" + sélecteur avec tous les
titres débloqués (achievements claimed avec rewardTitle).
Header: titre visible à côté du level.

Revente: prix inclut l'investissement forge (50% des coûts cumulés).
Épée +5 (investissement 1900 or) → revente base + 950 au lieu de base seul.
API client: ajout méthode PUT.
2026-03-24 19:31:02 +01:00
da8401dec2 fix: useState before conditional return — React hooks order
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
2026-03-24 19:23:46 +01:00
0c9839e1d8 feat: bouton Voir tout sur quêtes combat — plus de quêtes cachées derrière les grosses
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-24 19:21:24 +01:00
dd2a025c74 feat: quest page restructurée — combat/métiers/dailies/arcs séparés
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
Frontend: 4 sections distinctes sur la page quêtes:
  - Quêtes actives (3 slots combat uniquement)
  - Quêtes de combat (stagger: max 3 affichées, "+N après celles-ci")
  - 🔨 Métiers (forge/craft — hors pool, toujours disponibles)
  - 🔄 Dailies (répétables en fond)

Backend: craft/forge quests ne comptent plus dans le MAX_ACTIVE_QUESTS.
2026-03-24 19:11:29 +01:00
d77666c4cf feat: zone field sur Quest — filtre zone direct, plus besoin de passer par l'arc
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
2026-03-24 19:05:27 +01:00
287774ecd0 fix: kill_any quests respect zone — égouts ne complète plus les quêtes désert
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-24 19:02:43 +01:00
bf896a797f feat: vente items + stats combat avec équipement + forge visible
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
Inventaire: bouton Vendre sur items non équipés (40% du prix d'achat).
Stats forge visibles: "+5 ATK (3+2)" montre base + bonus forge.
Dashboard combat: attaque/défense calculés avec arme+armure+forge équipées.
10 side quests Égouts seedées (level 5-7).
2026-03-24 18:58:15 +01:00
9aadc326e1 feat: arc quests accept from arc panel + side quests only in available
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
Arc panel: boutons Accepter/Réclamer directement sur chaque quête d'arc,
progress affiché (3/5), arcs lockés avec 🔒 et opacity réduite.
Quêtes disponibles: seulement les secondaires (pas les arcs).
Quêtes d'arc abandonnées: ré-acceptables depuis le panel arc.
Zone locking respecté dans getArcs (zoneUnlocked flag).
2026-03-24 18:31:42 +01:00
810ad5ee64 fix: quests respect zone locking + level ordering fixes
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
Quêtes d'arcs filtrées par zones débloquées — pas de quête Égouts
visible tant que l'arc Marais n'est pas complété.
Gardien des marais: level 5→4. Dératisation: level 4→5.
Exterminateur: description corrigée (kill_any x30, pas "chaque espèce").
2026-03-24 18:27:36 +01:00
b414200544 fix: forge UI updates forgeLevel after success — prix/taux rafraîchis
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-24 18:16:58 +01:00
66df1013e5 fix: forge endpoint URL param + response field mapping
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
2026-03-24 18:14:23 +01:00
95fcf325dc fix: quest available filtering + 6 side quests level 2-4
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 36s
Fix: getAvailable filtre maintenant les quêtes active/completed (pas juste
claimed). Plus de doublons dailies, plus d'internal server error.

6 quêtes secondaires pour combler le gap level 2-5:
  Chasseur de champignons (lv2, 150 XP), La menace rampante (lv3, 180 XP),
  Guerrier éprouvé (lv2, 250 XP), Collecteur de trophées (lv3, 500 XP),
  Exterminateur (lv4, 400 XP), Première forge (lv2, 120 XP).
2026-03-24 18:08:49 +01:00
60d10a5423 feat: achievements zones — Maître des Marais, Seigneur des Égouts, Conquérant du Désert
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
2026-03-24 18:01:05 +01:00
cc3cbc1d2f fix: potion énergie affiche +30 endurance au lieu de +50% PV
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
2026-03-24 17:58:56 +01:00
d1609efaae feat: zone locking — progression par arcs narratifs + arcs Égouts/Désert
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
Zones verrouillées: marais toujours ouvert, égouts après arc Marais,
désert après arc Égouts. Filtrage backend sur monstres ET boutique.

Arc "Les Égouts de la Cité" (4 quêtes, lv4-7, boss Roi des Rats)
Arc "Les Sables Brûlants" (3 quêtes, lv8-12, boss Sphinx)

GET /api/monsters/zones — retourne les zones avec statut unlocked.
Combat page: monstres groupés par zone, zones lockées avec icône cadenas.
Boutique: items filtrés par zones débloquées (potions toujours visibles).
2026-03-24 17:57:23 +01:00
8cb5fcd5ba fix: endurance regen 6min→3min dans combat/forge/craft + potions d'énergie
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
Bug: combat/forge/craft calculaient la regen à 1pt/6min (ancien) alors que
character.service utilisait 1pt/3min (nouveau). Le joueur voyait 8 endurance
dans le HUD mais le backend refusait le combat avec 4.

Potions d'énergie: Potion (30 endurance, 20 or) + Grande (60 endurance, 45 or).
Consommable instantané via la boutique — le joueur peut acheter du temps de jeu.
2026-03-24 17:51:30 +01:00
1ffde61f97 feat: boutique + zones (égouts, désert) + 10 monstres + 14 items + potions
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
Shop module: GET /api/shop, POST /api/shop/buy/:id, POST /api/shop/sell/:id
Potions: achat instantané, heal 50% HP, pas d'inventaire.
Items: buyPrice + minLevel + zone ajoutés à l'entité.
12 équipements (4 par zone: marais/égouts/désert) + 2 potions.

Monstres: zone field ajouté, 10 nouveaux monstres:
  Égouts (lv4-10): Rat, Slime, Araignée, Crocodile, Roi des Rats
  Désert (lv8-15): Scorpion, Vautour, Momie, Ver des Sables, Sphinx

Frontend: page /shop groupée par zone, rarity colors, achat/vente.
Sidebar: icône ShoppingBag pour la boutique.
2026-03-24 17:46:21 +01:00
4d254692b0 feat: page Achievements + soins renommé + bouton soins en combat
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
Page /achievements : 20 succès groupés par catégorie (Combat, Progression,
Économie, Équipement), progress bars, paliers bronze/silver/gold,
bouton réclamer, compteur débloqués/total.

Renommage "repos" → "soins" partout (dashboard, budget, messages).
Bouton soins ajouté dans la page combat (accès rapide entre les fights).
Icône Trophy dans la sidebar pour les succès.
2026-03-24 17:36:20 +01:00
210f32b9cc fix: seed.ts migré PostgreSQL → MySQL (AppDataSource)
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
2026-03-24 17:26:02 +01:00
014ffdd789 refactor: types frontend alignés backend — zéro as any, monstres triés par level
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
types.ts: rewrite complet — Character, Monster, CombatResult, CombatLog
alignés sur les champs réels du backend. Plus de mapping approximatif.

CombatPage: réécriture propre — monstres triés par level (appropriés en
haut, trop forts en bas avec opacity + warning), historique avec vrais
noms de monstres et valeurs XP/or, level up affiché dans le résultat.

Cleanup: 0 occurrence de "as any" dans tout le frontend.
2026-03-24 17:24:59 +01:00
e3c870bb9f fix: combat page field mapping — monster levels, history names, XP/gold values
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
Monster: minLevel/maxLevel (backend) vs levelMin/levelMax (frontend type)
History: xpEarned/goldEarned + monster.name vs xpGained/goldGained/monsterName
Combat result: rewards.xp/gold vs xpGained/goldGained, level up display
2026-03-24 17:16:24 +01:00
eafac3d8c7 feat: endurance tickets — coûts visibles partout + budget dashboard
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 31s
Combat: coût 5 affiché, compteur "X combats possibles", bouton disabled
Forge: coût 10 + or affiché (baissé de 15 à 10), bouton disabled
Dashboard: indicateur budget "X combats · Y forges · Z repos"
Repos: coût 10 affiché, disabled si insuffisant
2026-03-24 17:09:06 +01:00
cfdc5c9b02 feat: HUD bar — stats persistantes sous le header
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
Barre compacte toujours visible : nom+level, HP, endurance+timer regen,
XP, or, quêtes actives (avec compteur "prêtes !").
Timer live : "+1 dans X:XX" quand endurance < max.
Auto-refresh 30s pour l'endurance, 60s pour les quêtes.
Chaque section cliquable vers la page correspondante.
2026-03-24 17:03:31 +01:00
9fac9e123b feat: repeatable quests hors pool 3 slots + section tâches quotidiennes
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
Répétables ne comptent plus dans le MAX_ACTIVE_QUESTS (3).
Frontend: section séparée "Tâches quotidiennes" en grille 3 colonnes,
quêtes narratives en haut avec les slots limités.
Prépare le terrain pour le hub village (forgeron, taverne, etc.).
2026-03-24 16:57:57 +01:00
af247a1c6b fix: quest progression (events after tx), abandon quest, endurance display
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
- Events (achievement/community/quest) émis APRÈS la transaction combat
  au lieu de dedans — corrige les quêtes qui ne progressaient pas
- POST /api/quests/abandon/:id — abandonner une quête active
- Frontend: bouton "Abandonner" sur les quêtes actives non complétées
- Fix endurance display (enduranceCurrent field mapping)
- Types Character mis à jour (xpToNextLevel, activeTitle, enduranceCurrent)
2026-03-24 16:52:48 +01:00
8038ca5d0a feat: quest page frontend — accept, progress, claim, arcs narratifs
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
Nouvelle page /quests avec icône Scroll dans la sidebar.
Layout: quêtes actives (gauche) + disponibles (droite) + arcs en bas.
Progress bars, boutons accepter/réclamer, badges répétable.
Arc section collapsible avec status par quête.
2026-03-24 16:40:04 +01:00
7651f3d8aa feat(sprint5): quest system + arcs + rebalance endurance/damage/xp
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
Quest system:
  4 entities (quest_arcs, quests, player_quests, player_quest_arcs)
  Arc "Les Marais du Têtard" (4 quêtes narratives)
  3 quêtes standalone répétables (chasse/forge/craft)
  5 achievements liés (quests_completed + quest_arc_completed)
  Event-driven: combat/forge/craft/loot émettent quest.progress
  API: available, active, completed, accept, claim, arcs

Rebalance:
  Endurance coût combat 10→5, regen 6min→3min (20/h), repos 20→10
  Dégâts joueur +3 base (plus de combats de 13 tours au level 1)
  Défaite endurance penalty 50→25
  XP monstres réduite (25→8 Têtard, 130→50 Golem) — quêtes = source principale
2026-03-24 16:34:37 +01:00
93b34b1f7b feat: stat distribution UI + rest button + xpToNextLevel from backend
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 34s
Dashboard: stat distributor with +/- buttons when statPoints > 0,
rest button (+50% HP, -20 endurance) when HP < max,
XP bar uses xpToNextLevel from backend instead of local formula.
API: distributeStats + rest endpoints added to client.
2026-03-24 16:09:55 +01:00
214045c7ce fix: level-up formula uses current level, add xpToNextLevel to API
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
XP threshold was computed on level+1 (target), making early levels too
steep (283 XP for level 2 instead of 100). Now uses current level:
level 1→2 = 100 XP, level 2→3 = 283 XP, level 10→11 = 3162 XP.

Added xpToNextLevel field to character and combat responses so the
frontend can display accurate progress bars.
2026-03-24 16:02:51 +01:00
6df11f2860 feat(sprint5): audit fixes — transactions, indexes, stat distribution, rest, forge cost
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 35s
P0 — Race conditions fixées avec pessimistic_write transactions :
  combat (double-spend endurance), forge (double upgrade),
  craft (consumeMaterials atomique), equip (item swap).
Forge : coût or (50-1000) + endurance (15) ajouté.
Combat : item stat bonuses (force/agilite/intelligence/chance) appliqués.

P1 — Features manquantes :
  POST /api/characters/stats — distribution stat points (avec lock).
  POST /api/characters/rest — repos auberge (+50% HP, -20 endurance).
  Vitalité : +10 HP max par point distribué.

P2 — Indexes DB ajoutés :
  character_id sur character_items, character_materials, combat_logs,
  craft_jobs, player_achievements, community_contributions.
  Composite (characterId, materialId) sur character_materials.
  period sur hall_of_fame. achievement_id sur player_achievements.

P3 — Cleanup : @nestjs/jwt et pg retirés de package.json.
2026-03-24 15:55:50 +01:00
708352be65 fix: remove api/ prefix from Sprint 4 controllers — global prefix already set
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-24 15:08:10 +01:00
ac88cbb5ab fix: rewardTitle explicit varchar type for MySQL
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 32s
2026-03-24 15:04:21 +01:00
37d5e628c0 fix: activeTitle explicit varchar type for MySQL
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 34s
2026-03-24 15:00:04 +01:00
8ee50805ea feat(sprint4): achievements, community goals, hall of fame, profile
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 37s
4 modules: achievement (15 succès, 5 catégories, 3 paliers), community
(objectifs collectifs + boosts globaux), halloffame (classement mensuel),
profile (titre actif + badges + % progression).

Event-driven: combat/forge/craft émettent des events via @nestjs/event-emitter.
Character entity: +activeTitle, +totalGoldEarned.
Seeds: 15 achievements + 3 community goals.
2026-03-24 14:51:53 +01:00
77052d9219 feat: add Twitch provider to login page — 4 providers like Origins
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-24 13:26:38 +01:00
8fbdcafa7b fix: use localStorage for PKCE verifier — survives cross-site redirects
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 33s
2026-03-24 13:19:19 +01:00
8c6777c980 feat: PKCE auth + CI/CD deploy
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 1m2s
- Frontend: PKCE flow (oauth.ts, AuthCallback code exchange, 401 interceptor)
- Backend: token introspection via SuperOAuth (no more JWT secret)
- User model: superOauthId (unified) replaces oauthId+provider
- Cookies httpOnly session + refresh token
- POST /auth/refresh endpoint
- Gitea CI workflow (vps-runner pattern)
- DB_SYNC env var for initial schema creation
2026-03-24 13:01:14 +01:00
c1bf793234 feat(sprint3): migrations Sprint3Economy + fix data-source migrations path
- Create src/database/migrations/1742169600000-Sprint3Economy.ts
  Tables : tetard_coins, tc_transactions, processed_events (IF NOT EXISTS)
- Fix migrations path : src/migrations/*.ts → __dirname/migrations/*{.ts,.js}
  Fonctionne en ts-node (src/) et après build (dist/)
2026-03-17 09:39:20 +01:00
28ac5ef139 fix(migrations): data-source.ts — 14 entities (11 ajoutées pour CLI TypeORM) 2026-03-17 07:42:56 +01:00
3ff5a8a84b fix(mysql): jsonb → json — type non supporté par MySQL 2026-03-17 07:38:18 +01:00
824ed41a14 fix(security): revert synchronize:true → prod-safe + .env.example placeholders 2026-03-17 07:26:53 +01:00
921873befd fix(deploy): synchronize:true pour premier deploy MySQL — à revert après 2026-03-17 07:21:06 +01:00
b506adf034 fix(infra): PostgreSQL → MySQL + tsconfig.build exclude frontend/ 2026-03-17 07:20:14 +01:00
165 changed files with 16845 additions and 451 deletions

19
.claude/settings.json Normal file
View File

@@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(npm *)",
"Bash(git *)",
"Bash(pm2 *)",
"Bash(curl *)",
"Bash(ls *)",
"Bash(cat *)",
"Bash(grep *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(node *)",
"Bash(npx *)",
"Write(*)"
]
}
}

View File

@@ -10,13 +10,12 @@ REDIS_URL=redis://localhost:6379
# Frontend CORS (virgule-séparé pour multi-origin) # Frontend CORS (virgule-séparé pour multi-origin)
FRONTEND_URL=http://localhost:5173 FRONTEND_URL=http://localhost:5173
# SuperOAuth — service externe d'authentification # SuperOAuth — service externe d'authentification (introspection, pas de secret JWT)
SUPER_OAUTH_URL=http://localhost:3000 SUPER_OAUTH_URL=http://localhost:3000
SUPER_OAUTH_JWT_SECRET=
# Cookie signing # Cookie signing
COOKIE_SECRET= COOKIE_SECRET=
# Twitch EventSub webhook # Twitch EventSub webhook
TWITCH_WEBHOOK_SECRET=<secret EventSub> TWITCH_WEBHOOK_SECRET=<secret EventSub Twitch>
TWITCH_CLIENT_ID=<app client id> TWITCH_CLIENT_ID=<app client id>

View File

@@ -0,0 +1,61 @@
name: CI/CD — Build & Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-deploy:
name: Build & Deploy
runs-on: vps-runner
steps:
- uses: actions/checkout@v4
# ── Backend ──────────────────────────────────────────────────────────────
- name: Install & build backend
run: |
npm ci
npm run build
- name: Deploy backend
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p /var/www/tetardpg/backend
rsync -a --delete dist/ /var/www/tetardpg/backend/dist/
rsync -a package.json package-lock.json /var/www/tetardpg/backend/
cd /var/www/tetardpg/backend && npm ci --omit=dev
- name: Restart pm2
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
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 ─────────────────────────────────────────────────────────────
- name: Install & build frontend
working-directory: frontend
env:
VITE_API_URL: https://tetardpg.tetardtek.com/api
VITE_OAUTH_URL: https://superoauth.tetardtek.com
VITE_OAUTH_CLIENT_ID: tetardpg
run: |
npm ci
npm run build
- name: Deploy frontend
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p /var/www/tetardpg/frontend/dist
rsync -a --delete frontend/dist/ /var/www/tetardpg/frontend/dist/
# ── Smoke test ───────────────────────────────────────────────────────────
- name: Smoke test API
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
sleep 3
curl -sf http://localhost:4000/api/health | grep -q '"ok"'
echo "✅ API health OK"

292
SPRINT4.md Normal file
View File

@@ -0,0 +1,292 @@
# TetaRdPG — Brief Sprint 4
> Statut : ⬜ À lancer
> Objectif : Succès individuels + Succès communautaires + Hall of Fame + Profil joueur enrichi
> Stack : NestJS · MySQL · TypeORM
> Prérequis : Sprint 3 livré ✅ (items, forge, craft, economy, twitch)
> Source design : `TetaRdPG/Sprint 4 _ Focus Succès & Hall of Fame.docx` + `Annexes/5. Système de succès.docx`
---
## Scope Sprint 4
### ✅ In scope
- Entité `achievements` — catalogue de succès avec critères de déblocage
- Entité `player_achievements` — suivi progression par joueur
- 5 catégories : Progression, Combat, Zones, Équipements, Économie
- Récompenses au déblocage : Or, XP bonus, titres honorifiques
- Entité `community_goals` — objectifs collectifs (monstres tués, TetardCoin cumulés)
- Barre de progression communautaire
- Récompenses communautaires : boosts globaux temporaires (XP/loot)
- Hall of Fame mensuel — classement contributeurs + badges
- Interface profil enrichi : badges, titres, % progression succès
- Seeds : 15 succès individuels + 3 objectifs communautaires
- API : voir section dédiée
### ❌ Out of scope
- Notifications Twitch temps réel (extension Twitch) — Sprint 5
- GIGABOSS communautaire (événement 72h) — Sprint événements
- Marché communautaire (échange joueurs) — Sprint économie avancée
- Guildes et alliances — Sprint social
- Boutique événementielle — Sprint économie avancée
- Frontend React complet
---
## Décisions de design (game-designer)
| Décision | Valeur | Justification |
|----------|--------|---------------|
| Tracking succès | Event-driven : chaque action (combat, craft, forge, level) émet un check | Pas de cron — cohérent avec le pattern lazy du projet |
| Catégories succès | 5 : progression, combat, zones, equipment, economy | GDD §5.1 |
| Paliers succès | 3 niveaux par succès (bronze/silver/gold) | Engagement long terme |
| Récompenses déblocage | Or + titre. Pas d'item pour éviter la complexité inventaire Sprint 4 | Simplification — items récompense = Sprint 5 |
| Titres joueur | 1 titre actif à la fois, affiché sur le profil | GDD §4 titres liés aux zones |
| Community goals | Reset mensuel, contribution individuelle trackée | GDD §5.2 |
| Hall of Fame | Classement mensuel, top 10, badges persistants | GDD §5.3 |
| Boost communautaire | Stocké en DB, appliqué comme multiplicateur dans combat/craft | Ex: +20% XP pendant 3j |
| Progression communautaire | Compteur global incrémenté à chaque action qualifiante | Pas de WebSocket — poll GET |
---
## Schéma DB
### `achievements`
```
id uuid PK
key varchar(50) UNIQUE -- 'combat_100', 'level_50', 'zone_marais_complete'
name varchar(100)
description text
category varchar(20) -- 'progression' | 'combat' | 'zones' | 'equipment' | 'economy'
tier varchar(10) -- 'bronze' | 'silver' | 'gold'
criteria_type varchar(30) -- 'combat_wins' | 'level_reached' | 'gold_accumulated' | ...
criteria_value int -- seuil à atteindre
reward_gold int default 0
reward_title varchar(100) NULL -- titre débloqué (nullable)
```
### `player_achievements`
```
id uuid PK
character_id uuid FK characters
achievement_id uuid FK achievements
progress int default 0 -- compteur courant
unlocked boolean default false
unlocked_at timestamp NULL
```
### `community_goals`
```
id uuid PK
name varchar(100)
description text
criteria_type varchar(30) -- 'total_monsters_killed' | 'total_tetardcoin' | ...
target_value bigint -- objectif collectif
current_value bigint default 0
reward_type varchar(30) -- 'xp_boost' | 'loot_boost'
reward_multiplier decimal(3,2) -- ex: 1.20 = +20%
reward_duration_hours int -- durée du boost
period_start date
period_end date
completed boolean default false
completed_at timestamp NULL
```
### `community_contributions`
```
id uuid PK
community_goal_id uuid FK community_goals
character_id uuid FK characters
contribution_value bigint default 0
```
### `hall_of_fame`
```
id uuid PK
character_id uuid FK characters
period varchar(7) -- '2026-04' format YYYY-MM
rank int
contribution_total bigint
badge varchar(50) -- 'top1_april_2026'
```
### `active_boosts` (communautaires)
```
id uuid PK
boost_type varchar(30) -- 'xp_boost' | 'loot_boost'
multiplier decimal(3,2)
expires_at timestamp
source_goal_id uuid FK community_goals
```
---
## Seeds
### Succès individuels (15)
| Key | Nom | Catégorie | Tier | Critère | Seuil | Récompense Or | Titre |
|-----|-----|-----------|------|---------|-------|---------------|-------|
| `combat_10` | Apprenti Guerrier | combat | bronze | combat_wins | 10 | 50 | — |
| `combat_100` | Guerrier Aguerri | combat | silver | combat_wins | 100 | 200 | Guerrier Aguerri |
| `combat_1000` | Légende du Combat | combat | gold | combat_wins | 1000 | 1000 | Légende |
| `level_10` | Aventurier | progression | bronze | level_reached | 10 | 100 | — |
| `level_50` | Héros | progression | silver | level_reached | 50 | 500 | Héros |
| `level_100` | Légende Vivante | progression | gold | level_reached | 100 | 2000 | Légende Vivante |
| `gold_1000` | Marchand | economy | bronze | gold_accumulated | 1000 | 100 | — |
| `gold_10000` | Négociant | economy | silver | gold_accumulated | 10000 | 500 | Négociant |
| `gold_100000` | Magnat | economy | gold | gold_accumulated | 100000 | 2000 | Magnat |
| `forge_5` | Apprenti Forgeron | equipment | bronze | forge_upgrades | 5 | 100 | — |
| `forge_25` | Maître Forgeron | equipment | silver | forge_upgrades | 25 | 500 | Maître Forgeron |
| `forge_100` | Forgeron Légendaire | equipment | gold | forge_upgrades | 100 | 2000 | Forgeron Légendaire |
| `craft_5` | Artisan Novice | equipment | bronze | craft_completed | 5 | 75 | — |
| `craft_25` | Artisan Confirmé | equipment | silver | craft_completed | 25 | 300 | Artisan |
| `craft_100` | Grand Artisan | equipment | gold | craft_completed | 100 | 1500 | Grand Artisan |
### Objectifs communautaires (3)
| Nom | Critère | Cible | Boost | Durée |
|-----|---------|-------|-------|-------|
| Chasse aux Monstres | total_monsters_killed | 10 000 | +20% XP | 72h |
| Trésor Communautaire | total_gold_earned | 1 000 000 | +15% loot | 48h |
| Fièvre de la Forge | total_forge_upgrades | 500 | +10% XP | 48h |
---
## API Sprint 4
```
# Succès individuels
GET /api/achievements → catalogue complet des succès
GET /api/achievements/me → progression du joueur (avec %)
POST /api/achievements/claim/:id → réclamer la récompense d'un succès débloqué
# Succès communautaires
GET /api/community/goals → objectifs en cours + barre progression
GET /api/community/goals/:id/top → top 10 contributeurs d'un objectif
GET /api/community/boosts → boosts actifs (multiplicateurs en cours)
# Hall of Fame
GET /api/halloffame/current → classement du mois en cours
GET /api/halloffame/:period → classement historique (ex: 2026-04)
# Profil enrichi
GET /api/profile/me → stats + titre actif + badges + succès count
PUT /api/profile/title → { title: "Héros" } → changer titre actif
```
---
## Architecture modules
```
src/
├── achievement/
│ ├── achievement.entity.ts
│ ├── player-achievement.entity.ts
│ ├── achievement.module.ts
│ ├── achievement.service.ts → check + unlock logic
│ ├── achievement.controller.ts
│ └── achievement.listener.ts → écoute events combat/craft/forge/levelup
├── community/
│ ├── community-goal.entity.ts
│ ├── community-contribution.entity.ts
│ ├── active-boost.entity.ts
│ ├── community.module.ts
│ ├── community.service.ts
│ └── community.controller.ts
├── halloffame/
│ ├── hall-of-fame.entity.ts
│ ├── halloffame.module.ts
│ ├── halloffame.service.ts → calcul mensuel + badge attribution
│ └── halloffame.controller.ts
├── profile/
│ ├── profile.module.ts
│ ├── profile.service.ts
│ └── profile.controller.ts
└── database/
├── achievements-seed.ts
└── community-goals-seed.ts
```
---
## Intégration modules existants
### CombatService — émission événements succès
```typescript
// Après résolution combat — émettre pour achievement tracker
if (result.winner === 'player') {
this.eventEmitter.emit('achievement.check', {
characterId: character.id,
type: 'combat_wins',
increment: 1,
});
this.eventEmitter.emit('community.contribute', {
characterId: character.id,
type: 'total_monsters_killed',
increment: 1,
});
}
```
### ForgeService / CraftService — même pattern
```typescript
// Après forge réussie
this.eventEmitter.emit('achievement.check', {
characterId, type: 'forge_upgrades', increment: 1,
});
this.eventEmitter.emit('community.contribute', {
characterId, type: 'total_forge_upgrades', increment: 1,
});
```
### Boosts actifs — application dans CombatEngine
```typescript
// Dans CombatService — vérifier boosts communautaires actifs
const xpBoost = await this.communityService.getActiveMultiplier('xp_boost');
rewards.xp = Math.floor(baseXp * xpBoost); // xpBoost = 1.0 si aucun boost
```
---
## Migration TypeORM
```
Sprint4Achievements — 6 tables :
achievements, player_achievements,
community_goals, community_contributions,
hall_of_fame, active_boosts
+ ALTER characters ADD active_title VARCHAR(100) NULL
+ ALTER characters ADD total_gold_earned BIGINT DEFAULT 0 -- tracking cumulé pour succès
```
---
## Critères de validation integrator
- [ ] `GET /api/achievements` → 15 succès seedés, 5 catégories
- [ ] `POST /api/combat/start` → victoire → `player_achievements.progress` incrémenté pour `combat_wins`
- [ ] 10 victoires → succès `combat_10` débloqué automatiquement
- [ ] `GET /api/achievements/me` → progression visible avec %
- [ ] `POST /api/achievements/claim/:id` → or crédité, titre disponible
- [ ] Claim succès déjà réclamé → 400
- [ ] `PUT /api/profile/title` → titre actif changé
- [ ] `GET /api/profile/me` → titre, badges, count succès
- [ ] `GET /api/community/goals` → 3 objectifs avec barre progression
- [ ] Combat victoire → `community_contributions` incrémentée
- [ ] `GET /api/community/goals/:id/top` → top 10 contributeurs
- [ ] Objectif communautaire atteint → boost créé dans `active_boosts`
- [ ] `GET /api/community/boosts` → multiplicateur actif
- [ ] Combat avec boost actif → XP = baseXp × multiplier
- [ ] Boost expiré → non retourné par GET
- [ ] `GET /api/halloffame/current` → classement mois en cours
- [ ] Sans cookie → 401 sur toutes les routes protégées
- [ ] Level up → `player_achievements.progress` incrémenté pour `level_reached`
- [ ] Forge → incrémente `forge_upgrades` + `total_forge_upgrades` communautaire

51
SPRINT5.md Normal file
View File

@@ -0,0 +1,51 @@
# TetaRdPG — Brief Sprint 5
> Statut : en cours
> Objectif : Audit API, corrections intégrité, features manquantes, équilibrage
> Stack : NestJS · MySQL · TypeORM
> Prérequis : Sprint 4 livré ✅
---
## Plan Sprint 5
### P0 — Exploits / Intégrité données
- [ ] Race condition combat — transaction isolée pour endurance + character save
- [ ] Race condition forge — transaction lock sur forgeLevel
- [ ] Race condition craft — consumeMaterials atomique (transaction)
- [ ] Race condition equip — transaction sur item equip/unequip
- [ ] Forge gratuite — ajouter coût (or + endurance)
### P1 — Features manquantes (gameplay)
- [ ] Endpoint distribution stat points (POST /api/characters/stats)
- [ ] Endpoint recovery HP — auberge/repos (POST /api/characters/rest)
- [ ] Item stat bonuses appliqués au combat (force_bonus, agilite_bonus, etc.)
### P2 — Indexes DB
- [ ] character_id sur : character_items, character_materials, combat_logs, craft_jobs, player_achievements, community_contributions
- [ ] equipped sur character_items
- [ ] period sur hall_of_fame
### P3 — Cleanup
- [ ] Supprimer @nestjs/jwt de package.json
- [ ] Supprimer pg de package.json
- [ ] Migrer seed.ts vers MySQL (AppDataSource)
- [ ] ProcessedEvent TTL (cleanup > 90 jours)
---
## Critères de validation
- [ ] 2 combats simultanés sur même perso → 1 seul passe, l'autre 409
- [ ] Forge déduit or + endurance
- [ ] Forge sans or/endurance → 400
- [ ] Craft démarre en transaction atomique (matériaux non exploitables)
- [ ] POST /api/characters/stats → distribue les points, valide total
- [ ] POST /api/characters/rest → regen HP (coût endurance)
- [ ] Combat applique item stat bonuses dans les calculs
- [ ] Indexes créés (SHOW INDEX FROM)
- [ ] @nestjs/jwt et pg absents de package.json

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,80 @@
Cas concrets. Aucun padding.
---
NO HANDOFF — le brain qui sait qui il est
Lundi 7h. Tu ouvres une session sans rien préparer.
Layer 0 charge : KERNEL, BHP schema, règles de collaboration, frozen layer.
La todo dit : brainstorm architecture.
Le brain répond : "session brainstorm — pas de contexte projet chargé, espace libre."
Tu fais de l'architecture propre parce qu'il n'y a pas de bruit du sprint de vendredi. Le cold start n'est pas un handicap — c'est ce qui rend la pensée architecturale possible. Un brain qui cold-start bien sur un brainstorm vaut plus qu'un brain qui traîne 400 lignes de workspace RAM d'un sprint de jeu.
---
SEMI — le chirurgien qui n'a besoin que du dossier
22h. Bug critique en prod sur SuperOAuth. Token refresh qui expire.
Layer 0 charge. Layer 1 partiel : position debug × SuperOAuth.
Le brain sait que SuperOAuth tourne port 3006, pm2 cluster, Redis dédié.
Il ne charge pas le sprint entier, pas le workspace de la semaine.
Tu as ce qu'il faut en 30 secondes. Tu fixes. Tu fermes.
Pas de bruit. Pas de friction. Le bon contexte au bon niveau.
---
SEMI+ — lundi matin sur un sprint vivant
TetaRdPG Sprint 4. Tu n'as pas de session ouverte depuis 3 jours.
Layer 0 + Layer 1 complet : état du sprint, ce qui est livré, ce qui reste, les décisions d'architecture du GDD.
Pas besoin de handoff de la dernière session — l'état du projet suffit.
Tu reprends en 2 minutes. Le brain n'a pas besoin de ta mémoire — il a la sienne.
---
FULL — la continuation chirurgicale
Hier soir, refacto TypeORM complexe sur OriginsDigital. Session coupée à 23h en plein milieu d'une migration.
Ce matin : FULL HANDOFF. Workspace RAM complet — les 3 fichiers en cours, la décision d'hier sur le cascade delete, le message d'erreur non résolu.
Tu reprends exactement là. Pas de reconstruction. Pas de "où j'en étais déjà ?". Continuité parfaite sur une tâche qui l'exige.
---
Le gradient intelligent dans un sprint
Lundi matin → SEMI+ reprendre l'état du sprint
Lundi soir → FULL continuation directe session d'avant
Mardi matin → SEMI+ nouveau jour, état suffit
Mercredi, bug → SEMI juste la position debug, rien d'autre
Vendredi close → FULL wrap complet, scribe, metabolism
Tu ne choisis pas toujours FULL parce que tu n'en as pas toujours besoin. Le niveau juste = moins de bruit, plus de vitesse.
---
Multi-agent — le cas qui prouve tout
Sprint OriginsDigital. Agent A : backend. Agent B : frontend.
- Layer 0 : identique pour les deux — même identité, même règles
- Layer 1 : partagé — même état de sprint, même API contracts
- Layer 2 : isolé — Agent A a son workspace backend, Agent B le sien
Agent B n'a pas besoin du workspace RAM d'Agent A. Il a besoin de savoir ce que l'API expose — c'est Layer 1. Le FULL d'Agent A ne pollue pas Agent B.
C'est ça le vrai gain : des agents qui partagent l'identité et l'état, mais pas la mémoire de travail.
---
Brain-as-a-Service — la promesse qui tient
brain new installe Layer 0 sur une machine vierge.
Première session d'un utilisateur : NO HANDOFF complet.
Pourtant il est productif immédiatement — parce que Layer 0 est solide.
C'est la promesse qu'on peut vendre : un brain qui cold-start bien est un brain qui scale.
Si NO HANDOFF est cassé, brain new ne marche pas. Layer 0 est le produit.
---
⚡ Coach — tu vois ce que ça implique ?
Layer 0 n'est pas juste de la philosophie. C'est le critère de qualité mesurable du système. Si NO HANDOFF est productif en moins de 2 minutes → Layer 0 est bon. Sinon il est insuffisant.
C'est un KPI qu'on peut tester.

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.

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TetaRdPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3165
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
frontend/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

74
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { AuthProvider, useAuth } from './context/AuthContext';
import { Layout } from './components/Layout';
import { LoginPage } from './pages/LoginPage';
import { AuthCallback } from './pages/AuthCallback';
import { DashboardPage } from './pages/DashboardPage';
import { CombatPage } from './pages/CombatPage';
import { TurnCombatPage } from './pages/TurnCombatPage';
import { InventoryPage } from './pages/InventoryPage';
import { CraftPage } from './pages/CraftPage';
import { ForgePage } from './pages/ForgePage';
import { QuestPage } from './pages/QuestPage';
import { AchievementsPage } from './pages/AchievementsPage';
import { ShopPage } from './pages/ShopPage';
import { VillagePage } from './pages/VillagePage';
import { GuidePage } from './pages/GuidePage';
import { NotFoundPage } from './pages/NotFoundPage';
const qc = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
function ProtectedLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6b7a99', fontSize: 14 }}>
Chargement
</div>
);
if (!user) return <Navigate to="/login" replace />;
return <Layout>{children}</Layout>;
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/guide" element={<GuidePage />} />
<Route path="/dashboard" element={<ProtectedLayout><DashboardPage /></ProtectedLayout>} />
<Route path="/village" element={<ProtectedLayout><VillagePage /></ProtectedLayout>} />
<Route path="/quests" element={<ProtectedLayout><QuestPage /></ProtectedLayout>} />
<Route path="/combat" element={<ProtectedLayout><CombatPage /></ProtectedLayout>} />
<Route path="/combat/tactical" element={<ProtectedLayout><TurnCombatPage /></ProtectedLayout>} />
<Route path="/inventory" element={<ProtectedLayout><InventoryPage /></ProtectedLayout>} />
<Route path="/craft" element={<ProtectedLayout><CraftPage /></ProtectedLayout>} />
<Route path="/forge" element={<ProtectedLayout><ForgePage /></ProtectedLayout>} />
<Route path="/achievements" element={<ProtectedLayout><AchievementsPage /></ProtectedLayout>} />
<Route path="/shop" element={<ProtectedLayout><ShopPage /></ProtectedLayout>} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
export default function App() {
return (
<QueryClientProvider client={qc}>
<AuthProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</AuthProvider>
<Toaster
position="bottom-right"
toastOptions={{
duration: 3000,
style: { background: '#1e2535', color: '#dce4f0', border: '1px solid #2a3448', fontSize: 13 },
success: { iconTheme: { primary: '#3ddc84', secondary: '#1e2535' } },
error: { iconTheme: { primary: '#e84040', secondary: '#1e2535' }, duration: 4000 },
}}
/>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,60 @@
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:4000/api';
let refreshPromise: Promise<boolean> | null = null;
async function tryRefresh(): Promise<boolean> {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const res = await fetch(`${BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
return res.ok;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
});
if (res.status === 401 && path !== '/auth/refresh') {
const refreshed = await tryRefresh();
if (refreshed) {
const retry = await fetch(`${BASE}${path}`, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
});
if (retry.ok) {
if (retry.status === 204) return undefined as T;
return retry.json();
}
}
window.dispatchEvent(new Event('auth:expired'));
throw new Error('Session expired');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message ?? `HTTP ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,93 @@
import { api } from './client';
import type {
User, Character, Monster, CombatLog,
CharacterItem, CharacterMaterial, Recipe, CraftJob, Item,
TurnResult, TurnSpell, DaoPathProgress, NpcView,
} from './types';
// Auth
export const authApi = {
setSession: (token: string, refreshToken?: string) =>
api.post<User>('/auth/session', { token, refreshToken }),
me: () => api.get<User>('/auth/me'),
logout: () => api.post<void>('/auth/logout'),
};
// Character
export const characterApi = {
create: (name: string, stats: Record<string, number>) =>
api.post<Character>('/characters', { name, ...stats }),
me: () => api.get<Character>('/characters/me'),
distributeStats: (stats: Record<string, number>) =>
api.post<Character>('/characters/stats', stats),
rest: () => api.post<{ hpBefore: number; hpAfter: number; hpMax: number; healed: number }>('/characters/rest'),
setTitle: (title: string | null) => api.put<any>('/profile/title', { title }),
};
// Combat
export const combatApi = {
zones: () => api.get<any[]>('/monsters/zones'),
monsters: () => api.get<Monster[]>('/monsters'),
start: (monsterId: string, attackType: string, count?: number) => api.post<any>('/combat/start', { monsterId, attackType, ...(count && count > 1 ? { count } : {}) }),
history: () => api.get<CombatLog[]>('/combat/history'),
};
// Turn Combat
export const turnCombatApi = {
start: (monsterId: string, attackType: string, companion?: string | null) =>
api.post<TurnResult>('/combat/turn/start', { monsterId, attackType, ...(companion ? { companion } : {}) }),
action: (sessionId: string, type: string, spellId?: string) =>
api.post<TurnResult>('/combat/turn/action', { sessionId, type, ...(spellId ? { spellId } : {}) }),
session: (sessionId: string) =>
api.get<TurnResult>(`/combat/turn/session/${sessionId}`),
spells: () => api.get<TurnSpell[]>('/combat/turn/spells'),
unlockedSpells: () => api.get<TurnSpell[]>('/combat/turn/spells/unlocked'),
unlockSpell: (spellId: string) =>
api.post<any>('/combat/turn/spells/unlock', { spellId }),
dao: () => api.get<DaoPathProgress[]>('/combat/turn/dao'),
chooseDaoPath: (path: string) =>
api.post<DaoPathProgress>('/combat/turn/dao/choose', { path }),
};
// Items
export const itemApi = {
catalogue: () => api.get<Item[]>('/items'),
inventory: () => api.get<CharacterItem[]>('/items/inventory'),
equip: (id: string) => api.post<void>(`/items/equip/${id}`),
unequip: (slot: 'weapon' | 'armor') => api.post<void>(`/items/unequip/${slot}`),
};
// Materials
export const materialApi = {
inventory: () => api.get<CharacterMaterial[]>('/materials/inventory'),
};
// Craft
export const craftApi = {
recipes: () => api.get<Recipe[]>('/craft/recipes'),
start: (recipeId: string) => api.post<CraftJob>('/craft/start', { recipeId }),
active: () => api.get<CraftJob | { status: 'none' }>('/craft/active'),
collect: (jobId: string) => api.post<CharacterItem>(`/craft/collect/${jobId}`),
};
// Quests
export const questApi = {
available: () => api.get<any[]>('/quests/available'),
active: () => api.get<any[]>('/quests/active'),
completed: () => api.get<any[]>('/quests/completed'),
accept: (questId: string) => api.post<any>(`/quests/accept/${questId}`),
claim: (playerQuestId: string) => api.post<any>(`/quests/claim/${playerQuestId}`),
abandon: (playerQuestId: string) => api.post<any>(`/quests/abandon/${playerQuestId}`),
arcs: () => api.get<any[]>('/quests/arcs'),
};
// NPCs
export const npcApi = {
all: () => api.get<NpcView[]>('/npcs'),
};
// Forge
export const forgeApi = {
upgrade: (charItemId: string) =>
api.post<{ success: boolean; forgeLevel: number; item: string; goldSpent: number; message: string }>(`/forge/upgrade/${charItemId}`),
};

261
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,261 @@
export interface User {
id: string;
username: string;
email: string | null;
createdAt: string;
}
export interface Character {
id: string;
userId: string;
name: string;
level: number;
xp: number;
gold: number;
force: number;
agilite: number;
intelligence: number;
chance: number;
vitalite: number;
hpCurrent: number;
hpMax: number;
enduranceSaved: number;
lastEnduranceTs: string;
enduranceMax: number;
enduranceCurrent: number;
statPoints: number;
activeTitle: string | null;
totalGoldEarned: number;
xpToNextLevel: number;
createdAt: string;
updatedAt: string;
}
export interface Monster {
id: string;
name: string;
minLevel: number;
maxLevel: number;
hp: number;
attack: number;
defense: number;
attackType: 'melee' | 'ranged' | 'magic';
xpReward: number;
goldMin: number;
goldMax: number;
dropMaterialId: string | null;
}
export interface CombatRound {
round: number;
playerAttack: { damage: number; isCrit: boolean; isDodged: boolean; log: string };
monsterAttack: { damage: number; isCrit: boolean; isDodged: boolean; log: string };
playerHp: number;
monsterHp: number;
log: string[];
}
export interface CombatRewards {
xp: number;
gold: number;
goldLost: number;
levelUp: boolean;
newLevel: number;
statPointsGained: number;
loot: { name: string; quantity: number } | null;
}
export interface CombatCharacterState {
level: number;
xp: number;
xpToNextLevel: number;
gold: number;
hpCurrent: number;
hpMax: number;
enduranceCurrent: number;
enduranceMax: number;
statPoints: number;
}
export interface CombatResult {
winner: 'player' | 'monster';
rounds: CombatRound[];
summary: string;
rewards: CombatRewards;
character: CombatCharacterState;
}
export interface MultiCombatResult {
mode: 'multi';
count: number;
totals: {
wins: number;
losses: number;
xp: number;
gold: number;
goldLost: number;
loot: { name: string; quantity: number }[];
levelsGained: number;
};
lastResult: CombatResult;
character: CombatCharacterState;
}
export interface CombatLog {
id: string;
winner: 'player' | 'monster';
totalRounds: number;
xpEarned: number;
goldEarned: number;
levelUp: boolean;
createdAt: string;
lootQuantity: 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 interface Item {
id: string;
name: string;
description: string;
type: 'weapon' | 'armor' | 'consumable';
rarity: Rarity;
attackBonus: number;
defenseBonus: number;
forceBonus: number;
agiliteBonus: number;
intelligenceBonus: number;
chanceBonus: number;
vitaliteBonus: number;
}
export interface CharacterItem {
id: string;
item: Item;
forgeLevel: number;
equipped: boolean;
acquiredAt: string;
}
export interface Material {
id: string;
name: string;
description: string;
rarity: Rarity;
}
export interface CharacterMaterial {
id: string;
material: Material;
quantity: number;
}
export interface Recipe {
id: string;
name: string;
resultItem: Item;
craftDurationSeconds: number;
enduranceCost: number;
ingredients: { materialId: string; materialName?: string; quantity: number }[];
}
export interface CraftJob {
id: string;
recipe: Recipe;
startedAt: string;
completedAt: string;
collected: boolean;
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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,24 @@
interface BarProps {
value: number;
max: number;
type: 'hp' | 'end' | 'xp';
label?: string;
showValues?: boolean;
}
export function Bar({ value, max, type, label, showValues = true }: BarProps) {
const pct = Math.min(100, Math.round((value / Math.max(max, 1)) * 100));
return (
<div>
{(label || showValues) && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4, fontSize: 12, color: '#6b7a99' }}>
{label && <span>{label}</span>}
{showValues && <span>{value} / {max}</span>}
</div>
)}
<div className="bar-track">
<div className={`bar-fill-${type}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import type { CombatResult, MultiCombatResult, CombatLog } from '../api/types';
import { Trophy, Skull } from 'lucide-react';
export function CombatLogView({ result }: { result: CombatResult }) {
const won = result.winner === 'player';
return (
<div className="card mt-4">
<div className="text-center py-3 mb-3 border-b border-rpg-border">
{won
? <div className="text-rpg-green font-extrabold text-lg">
<Trophy size={20} className="inline mr-2" />
Victoire ! +{result.rewards.xp} XP +{result.rewards.gold} or
</div>
: <div className="text-rpg-red font-extrabold text-lg">
<Skull size={20} className="inline mr-2" />
Défaite Retour à l'auberge
</div>
}
{result.rewards.loot && (
<div className="text-sm text-rpg-gold mt-1">
🎁 Loot : {result.rewards.loot.name} ×{result.rewards.loot.quantity}
</div>
)}
{result.rewards.levelUp && (
<div className="text-sm text-rpg-purple mt-1">
🎉 LEVEL UP ! Niveau {result.rewards.newLevel} — +{result.rewards.statPointsGained} points de stats
</div>
)}
</div>
<p className="text-xs font-bold text-rpg-muted mb-1.5">
Log — {result.rounds.length} tour{result.rounds.length > 1 ? 's' : ''}
</p>
<div className="combat-log">
{result.rounds.flatMap(r =>
r.log.map((line, i) => {
const cls = line.includes('CRITIQUE') ? 'log-crit'
: line.includes('esquive') ? 'log-crit'
: line.includes('HP') ? 'log-system'
: i === 0 ? 'log-player' : 'log-monster';
return <div key={`${r.round}-${i}`} className={cls}>[T{r.round}] {line}</div>;
})
)}
<div className={won ? 'log-system' : 'log-monster'}>══ {won ? 'Victoire' : 'Défaite'} ══</div>
</div>
</div>
);
}
export function MultiCombatView({ result }: { result: MultiCombatResult }) {
const t = result.totals;
return (
<div className="card mt-4">
<div className="text-center py-3 mb-3 border-b border-rpg-border">
<div className={`font-extrabold text-lg ${t.losses > 0 ? 'text-rpg-red' : 'text-rpg-green'}`}>
{t.losses > 0 ? <Skull size={20} className="inline mr-2" /> : <Trophy size={20} className="inline mr-2" />}
{result.count} combat{result.count > 1 ? 's' : ''} — {t.wins}V / {t.losses}D
</div>
<div className="text-sm text-rpg-text mt-1.5">
+{t.xp} XP +{t.gold} Or
{t.goldLost > 0 && <span className="text-rpg-red"> {t.goldLost} Or</span>}
</div>
{t.levelsGained > 0 && (
<div className="text-sm text-rpg-purple mt-1">🎉 {t.levelsGained} level up{t.levelsGained > 1 ? 's' : ''} !</div>
)}
{t.loot.length > 0 && (
<div className="text-sm text-rpg-gold mt-1">🎁 Loot : {t.loot.reduce((sum, l) => sum + l.quantity, 0)} matériaux</div>
)}
{t.losses > 0 && (
<div className="text-[11px] text-rpg-muted mt-1">Série interrompue par une défaite</div>
)}
</div>
</div>
);
}
export function HistoryEntry({ h }: { h: CombatLog }) {
return (
<div className="flex justify-between text-xs py-0.5 border-b border-[#1e2535]">
<span className={h.winner === 'player' ? 'text-rpg-green' : 'text-rpg-red'}>
{h.winner === 'player' ? '' : ''} {h.monster.name}
</span>
<span className="text-rpg-muted">
{h.winner === 'player'
? `+${h.xpEarned}xp +${h.goldEarned}or${h.lootQuantity > 0 ? ` 🎁×${h.lootQuantity}` : ''}`
: `${h.totalRounds} tours`
}
</span>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi } from '../api/endpoints';
import { STAT_LABELS } from '../constants';
const STATS = ['force', 'agilite', 'intelligence', 'chance', 'vitalite'] as const;
export function CreateCharacter() {
const qc = useQueryClient();
const [name, setName] = useState('');
const [pts, setPts] = useState<Record<string, number>>({ force:1, agilite:1, intelligence:1, chance:1, vitalite:1 });
const used = Object.values(pts).reduce((a, b) => a + b, 0) - 5;
const remaining = 5 - used;
const mut = useMutation({
mutationFn: () => characterApi.create(name, pts),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
const adjust = (stat: string, delta: number) => {
const next = (pts[stat] ?? 1) + delta;
if (next < 1 || next > 10) return;
if (delta > 0 && remaining <= 0) return;
setPts(p => ({ ...p, [stat]: next }));
};
return (
<div className="max-w-md mx-auto mt-16">
<div className="card card-gold p-6">
<h2 className="text-rpg-gold text-xl font-bold mb-1">Créer ton personnage</h2>
<p className="text-rpg-muted text-sm mb-5">
{remaining > 0 ? `${remaining} point${remaining > 1 ? 's' : ''} à répartir` : 'Tous les points répartis'}
</p>
<input
className="input-rpg mb-4"
placeholder="Nom du personnage"
value={name}
onChange={e => setName(e.target.value)}
maxLength={30}
/>
<div className="flex flex-col gap-2 mb-5">
{STATS.map(s => (
<div key={s} className="flex items-center justify-between">
<span className="text-sm text-rpg-text w-28">{STAT_LABELS[s]}</span>
<div className="flex items-center gap-2">
<button className="btn btn-ghost px-2 py-0.5 text-sm" onClick={() => adjust(s, -1)}></button>
<span className="w-5 text-center font-bold text-rpg-gold">{pts[s]}</span>
<button className="btn btn-ghost px-2 py-0.5 text-sm" onClick={() => adjust(s, +1)}>+</button>
</div>
</div>
))}
</div>
<button
className="btn btn-gold w-full"
disabled={!name.trim() || remaining !== 0 || mut.isPending}
onClick={() => mut.mutate()}
>
{mut.isPending ? 'Création…' : 'Commencer l\'aventure ⚔️'}
</button>
{mut.isError && <p className="text-rpg-red text-xs mt-2">{(mut.error as Error).message}</p>}
</div>
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { useState, useEffect, useRef } from 'react';
import { Search, X } from 'lucide-react';
import { useGuideData } from '../hooks/useGuideData';
import { RARITY_COLORS } from '../constants';
import { RarityDot } from './RarityBadge';
export function GuideDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const [search, setSearch] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const { filteredMonsters, filteredItems, filteredRecipes, matMap, q } = useGuideData(search);
useEffect(() => {
if (open) {
setSearch('');
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
// Escape to close
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!open) return null;
const hasResults = q && (filteredMonsters.length > 0 || filteredItems.length > 0 || filteredRecipes.length > 0);
const noResults = q && !hasResults;
const totalResults = q ? filteredMonsters.length + filteredItems.length + filteredRecipes.length : 0;
return (
<>
{/* Backdrop */}
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
zIndex: 90, transition: 'opacity 0.2s',
}}
/>
{/* Drawer */}
<div style={{
position: 'fixed', top: 0, right: 0, bottom: 0, width: 380,
background: '#0d0f14', borderLeft: '1px solid #2a3448',
zIndex: 100, display: 'flex', flexDirection: 'column',
animation: 'slideIn 0.2s ease-out',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 16px', borderBottom: '1px solid #2a3448',
}}>
<span style={{ fontWeight: 700, color: '#f4c94e', fontSize: 14 }}>📖 Guide rapide</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#6b7a99', cursor: 'pointer', padding: 4 }}>
<X size={16} />
</button>
</div>
{/* Search */}
<div style={{ padding: '12px 16px', borderBottom: '1px solid #1e2535' }}>
<div style={{ position: 'relative' }}>
<Search size={13} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: '#6b7a99' }} />
<input
ref={inputRef}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Monstre, item, matériau, recette…"
style={{
width: '100%', padding: '8px 10px 8px 30px', fontSize: 12,
background: '#1e2535', border: '1px solid #2a3448', borderRadius: 6,
color: '#dce4f0', outline: 'none', boxSizing: 'border-box',
}}
/>
</div>
{q && (
<div style={{ fontSize: 10, color: '#6b7a99', marginTop: 4 }}>
{totalResults} résultat{totalResults !== 1 ? 's' : ''}
</div>
)}
</div>
{/* Results */}
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 16px' }}>
{!q && (
<div style={{ textAlign: 'center', padding: '2rem 0', color: '#6b7a99', fontSize: 12 }}>
Tapez pour rechercher dans le guide
</div>
)}
{noResults && (
<div style={{ textAlign: 'center', padding: '2rem 0', color: '#6b7a99', fontSize: 12 }}>
Aucun résultat pour « {search} »
</div>
)}
{/* Monstres */}
{q && filteredMonsters.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', marginBottom: 6 }}>
Monstres ({filteredMonsters.length})
</div>
{filteredMonsters.map(m => (
<div key={m.id} style={{ padding: '6px 0', borderBottom: '1px solid #1e2535', fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#dce4f0', fontWeight: 600 }}>{m.name}</span>
<span style={{ color: '#6b7a99', fontSize: 10 }}>Niv. {m.minLevel}{m.maxLevel}</span>
</div>
<div style={{ color: '#6b7a99', fontSize: 10, marginTop: 2 }}>
{m.hp} {m.attack} 🛡{m.defense} · {m.xpReward}xp · 💰{m.goldMin}{m.goldMax}
{m.dropMaterialId && matMap.get(m.dropMaterialId) && (
<span style={{ color: '#f4c94e' }}> · 🎁 {matMap.get(m.dropMaterialId)!.name}</span>
)}
</div>
</div>
))}
</div>
)}
{/* Items */}
{q && filteredItems.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', marginBottom: 6 }}>
Équipement ({filteredItems.length})
</div>
{filteredItems.map(item => (
<div key={item.id} style={{ padding: '6px 0', borderBottom: '1px solid #1e2535', fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: RARITY_COLORS[item.rarity], fontWeight: 600 }}>
<RarityDot rarity={item.rarity} />
{item.type === 'weapon' ? '⚔️' : item.type === 'armor' ? '🛡️' : '🧪'} {item.name}
</span>
</div>
<div style={{ color: '#6b7a99', fontSize: 10, marginTop: 2 }}>
{item.attackBonus > 0 && `ATK+${item.attackBonus} `}
{item.defenseBonus > 0 && `DEF+${item.defenseBonus} `}
{item.agiliteBonus > 0 && `AGI+${item.agiliteBonus} `}
{item.intelligenceBonus > 0 && `INT+${item.intelligenceBonus} `}
{(item as any).buyPrice > 0 ? `· 💰${(item as any).buyPrice}` : '· 🔨 Craft'}
</div>
</div>
))}
</div>
)}
{/* Recettes */}
{q && filteredRecipes.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', marginBottom: 6 }}>
Recettes ({filteredRecipes.length})
</div>
{filteredRecipes.map(r => (
<div key={r.id} style={{ padding: '6px 0', borderBottom: '1px solid #1e2535', fontSize: 12 }}>
<div style={{ color: RARITY_COLORS[r.resultItem?.rarity] ?? '#dce4f0', fontWeight: 600 }}>
<RarityDot rarity={r.resultItem?.rarity ?? 'common'} />
{r.resultItem?.name ?? r.name}
</div>
<div style={{ color: '#6b7a99', fontSize: 10, marginTop: 2 }}>
{r.ingredients.map((ing, i) => (
<span key={i}>{i > 0 ? ' + ' : ''}{ing.quantity}× {matMap.get(ing.materialId)?.name ?? '???'}</span>
))}
<span> · {r.craftDurationSeconds}s · {r.enduranceCost}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div style={{
padding: '10px 16px', borderTop: '1px solid #2a3448',
textAlign: 'center',
}}>
<a href="/guide" style={{ fontSize: 11, color: '#6b7a99', textDecoration: 'none' }}>
Ouvrir le guide complet
</a>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,121 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { characterApi, questApi } from '../api/endpoints';
import { Heart, Zap, Star, Coins, Scroll, Clock } from 'lucide-react';
import { useState, useEffect } from 'react';
function RegenTimer({ endurance, enduranceMax, lastEnduranceTs }: { endurance: number; enduranceMax: number; lastEnduranceTs: string }) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (endurance >= enduranceMax) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [endurance, enduranceMax]);
if (endurance >= enduranceMax) return null;
const elapsedMs = now - new Date(lastEnduranceTs).getTime();
const elapsedInCycle = elapsedMs % (3 * 60 * 1000);
const remainingMs = 3 * 60 * 1000 - elapsedInCycle;
const remainingSec = Math.max(0, Math.floor(remainingMs / 1000));
const min = Math.floor(remainingSec / 60);
const sec = remainingSec % 60;
return (
<span className="hud-regen text-[9px] text-rpg-blue inline-flex items-center gap-0.5">
<Clock size={8} className="inline" />
+1 dans {min}:{sec.toString().padStart(2, '0')}
</span>
);
}
export function HudBar() {
const { data: char } = useQuery({
queryKey: ['character'],
queryFn: characterApi.me,
refetchInterval: 30_000,
});
useEffect(() => {
document.title = char?.name ? `${char.name} — TetaRdPG` : 'TetaRdPG';
}, [char?.name]);
const { data: activeQuests } = useQuery({
queryKey: ['questsActive'],
queryFn: questApi.active,
refetchInterval: 60_000,
});
if (!char) return null;
const endurance = char.enduranceCurrent;
const xpNext = char.xpToNextLevel;
const questCount = activeQuests?.filter((pq: any) => pq.status === 'active').length ?? 0;
const questReady = activeQuests?.filter((pq: any) => pq.status === 'completed').length ?? 0;
return (
<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">
{/* Name + Level */}
<Link to="/dashboard" className="no-underline flex items-center gap-1.5">
<span className="text-sm">🐸</span>
<span className="font-bold text-rpg-text text-xs">{char.name}</span>
<span className="hud-label text-rpg-muted">Niv.{char.level}</span>
</Link>
<span className="hud-sep text-[#2a3448]">|</span>
{/* HP */}
<Link to="/dashboard" className="no-underline flex items-center gap-1">
<Heart size={10} className="text-rpg-red" />
<span className={char.hpCurrent < char.hpMax ? 'text-rpg-red' : 'text-rpg-muted'}>
{char.hpCurrent}<span className="hud-label">/{char.hpMax}</span>
</span>
</Link>
<span className="hud-sep text-[#2a3448]">|</span>
{/* Endurance + timer */}
<Link to="/dashboard" className="no-underline flex items-center gap-1">
<Zap size={10} className="text-rpg-blue" />
<span className={endurance < 5 ? 'text-rpg-red' : 'text-rpg-muted'}>
{endurance}<span className="hud-label">/{char.enduranceMax}</span>
</span>
{char.lastEnduranceTs && (
<RegenTimer
endurance={endurance}
enduranceMax={char.enduranceMax}
lastEnduranceTs={char.lastEnduranceTs}
/>
)}
</Link>
<span className="hud-sep text-[#2a3448]">|</span>
{/* XP */}
<Link to="/dashboard" className="no-underline flex items-center gap-1">
<Star size={10} className="text-rpg-purple" />
<span>{char.xp}<span className="hud-label">/{xpNext}</span></span>
</Link>
<span className="hud-sep text-[#2a3448]">|</span>
{/* Gold */}
<span className="flex items-center gap-1">
<Coins size={10} className="text-rpg-gold" />
<span>{char.gold}</span>
</span>
<span className="hud-sep text-[#2a3448]">|</span>
{/* Quests */}
<Link to="/quests" className="no-underline flex items-center gap-1">
<Scroll size={10} className={questReady > 0 ? 'text-rpg-gold' : 'text-rpg-muted'} />
<span className="hud-label">{questCount} quête{questCount !== 1 ? 's' : ''}</span>
{questReady > 0 && (
<span className="text-rpg-gold font-bold">({questReady} prête{questReady > 1 ? 's' : ''} !)</span>
)}
</Link>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Swords, Package, Hammer, User, LogOut, Shield, Scroll, Trophy, ShoppingBag, BookOpen, Landmark } from 'lucide-react';
import { HudBar } from './HudBar';
import { GuideDrawer } from './GuideDrawer';
const NAV = [
{ to: '/dashboard', icon: User, label: 'Personnage' },
{ to: '/village', icon: Landmark, label: 'Village' },
{ to: '/quests', icon: Scroll, label: 'Quêtes' },
{ to: '/combat', icon: Swords, label: 'Combat' },
{ to: '/inventory', icon: Package, label: 'Inventaire' },
{ to: '/craft', icon: Hammer, label: 'Artisanat' },
{ to: '/forge', icon: Shield, label: 'Forge' },
{ to: '/shop', icon: ShoppingBag, label: 'Boutique' },
{ to: '/achievements', icon: Trophy, label: 'Succès' },
];
export function Layout({ children }: { children: React.ReactNode }) {
const { user, logout } = useAuth();
const loc = useLocation();
const [guideOpen, setGuideOpen] = useState(false);
return (
<div className="layout">
{/* Header */}
<header style={{
background: '#161b25', borderBottom: '1px solid #2a3448',
padding: '0 1.5rem', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', height: 52,
position: 'sticky', top: 0, zIndex: 10,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 20 }}>🐸</span>
<span style={{ fontWeight: 800, color: '#f4c94e', letterSpacing: '-0.5px' }}>TetaRdPG</span>
</div>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="header-username" style={{ fontSize: 13, color: '#6b7a99' }}>{user.username}</span>
<button className="btn btn-ghost" style={{ padding: '0.3rem 0.6rem' }} onClick={logout} title="Déconnexion">
<LogOut size={14} />
</button>
</div>
)}
</header>
<HudBar />
<div className="layout-body">
{/* Sidebar / Bottom nav */}
<nav className="sidebar">
{NAV.map(({ to, icon: Icon, label }) => {
const active = loc.pathname.startsWith(to);
return (
<Link key={to} to={to} title={label} className={`nav-item ${active ? 'active' : ''}`}>
<Icon size={18} />
</Link>
);
})}
<div className="nav-spacer" style={{ flex: 1 }} />
<button
onClick={() => setGuideOpen(true)}
title="Guide rapide"
className={`nav-item ${guideOpen ? 'active' : ''}`}
>
<BookOpen size={18} />
</button>
</nav>
{/* Main content */}
<main className="layout-main">
{children}
</main>
</div>
<GuideDrawer open={guideOpen} onClose={() => setGuideOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type { Monster } from '../api/types';
export function MonsterCard({ m, selected, onSelect, playerLevel }: {
m: Monster; selected: boolean; onSelect: () => void; playerLevel: number;
}) {
const tooHard = m.minLevel > playerLevel + 2;
return (
<div
className={`card card-hover ${selected ? 'card-gold' : ''} cursor-pointer transition-all ${tooHard ? 'opacity-40' : ''}`}
onClick={onSelect}
>
<div className="flex justify-between items-start mb-1.5">
<span className={`font-bold text-sm ${selected ? 'text-rpg-gold' : 'text-rpg-text'}`}>{m.name}</span>
<span className={`badge ${tooHard ? 'badge-red' : 'badge-green'} text-[10px]`}>
Niv. {m.minLevel}{m.maxLevel}
</span>
</div>
<div className="flex gap-3 text-xs text-rpg-muted">
<span> {m.hp}</span>
<span> {m.attack}</span>
<span>🛡 {m.defense}</span>
<span> {m.xpReward} XP</span>
<span>💰 {m.goldMin}{m.goldMax}</span>
</div>
{tooHard && <div className="text-[10px] text-rpg-red mt-1">Niveau trop élevé</div>}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Swords, Scroll, Hammer, BookOpen, ChevronRight } from 'lucide-react';
const STEPS = [
{ icon: Scroll, title: 'Accepte une quête', desc: 'Les quêtes te guident et donnent le plus d\'XP. Commence par les Marais.', cta: 'Voir les quêtes', to: '/quests' },
{ icon: Swords, title: 'Combats un monstre', desc: 'Chaque combat coûte 5 endurance. Tu gagneras XP, or et matériaux.', cta: 'Aller au combat', to: '/combat' },
{ icon: Hammer, title: 'Craft ton équipement', desc: 'Avec les matériaux droppés, fabrique des armes plus puissantes.', cta: 'Voir l\'artisanat', to: '/craft' },
{ icon: BookOpen, title: 'Consulte le guide', desc: 'Bestiaire, recettes, zones, forge — tout est dans le guide.', cta: 'Ouvrir le guide', to: '/guide' },
];
export function Onboarding({ level, onDismiss }: { level: number; onDismiss: () => void }) {
const navigate = useNavigate();
const [dismissed, setDismissed] = useState(false);
if (level > 3 || dismissed) return null;
return (
<div className="card mb-4 border-l-3 border-l-rpg-gold">
<div className="flex justify-between items-center mb-3">
<h3 className="text-rpg-gold text-[15px] font-bold m-0">Bienvenue, aventurier !</h3>
<button
onClick={() => { setDismissed(true); onDismiss(); }}
className="bg-transparent border-none text-rpg-muted cursor-pointer text-[11px]"
>
Masquer
</button>
</div>
<p className="text-rpg-muted text-xs mb-4">
Voici les premières étapes pour bien démarrer ton aventure dans les Marais.
</p>
<div className="grid grid-cols-2 gap-2 max-sm:grid-cols-1">
{STEPS.map((step, i) => {
const Icon = step.icon;
return (
<button
key={i}
onClick={() => navigate(step.to)}
className="flex items-start gap-2.5 p-3 bg-[#111620] border border-rpg-border rounded-lg cursor-pointer text-left transition-colors hover:border-rpg-gold"
>
<Icon size={18} className="text-rpg-gold shrink-0 mt-0.5" />
<div className="flex-1">
<div className="text-xs font-bold text-rpg-text mb-0.5">{i + 1}. {step.title}</div>
<div className="text-[10px] text-rpg-muted leading-snug">{step.desc}</div>
<div className="text-[10px] text-rpg-gold mt-1 flex items-center gap-0.5">
{step.cta} <ChevronRight size={10} />
</div>
</div>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { RARITY_COLORS, RARITY_LABELS } from '../constants';
export function RarityBadge({ rarity }: { rarity: string }) {
const color = RARITY_COLORS[rarity] ?? '#6b7a99';
return (
<span
className="text-[10px] font-bold px-1.5 py-0.5 rounded"
style={{ background: color + '22', color }}
>
{RARITY_LABELS[rarity] ?? rarity}
</span>
);
}
export function RarityDot({ rarity }: { rarity: string }) {
return (
<span
className="inline-block w-1.5 h-1.5 rounded-full mr-1"
style={{ background: RARITY_COLORS[rarity] ?? '#6b7a99' }}
/>
);
}

76
frontend/src/constants.ts Normal file
View File

@@ -0,0 +1,76 @@
// ── Game Constants — Source unique frontend ──
// Centralise toutes les constantes dupliquées dans les pages.
export const RARITY_COLORS: Record<string, string> = {
common: '#9ca3af',
rare: '#5ba4f5',
epic: '#a78bfa',
legendary: '#f4c94e',
};
export const RARITY_LABELS: Record<string, string> = {
common: 'Commun',
rare: 'Rare',
epic: 'Épique',
legendary: 'Légendaire',
};
export const ZONE_INFO: Record<string, { name: string; emoji: string; color: string }> = {
marais: { name: 'Les Marais', emoji: '🌿', color: '#3ddc84' },
egouts: { name: 'Les Égouts', emoji: '🕳️', color: '#5ba4f5' },
desert: { name: 'Le Désert', emoji: '🏜️', color: '#f4c94e' },
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> = {
force: 'Force',
agilite: 'Agilité',
intelligence: 'Intelligence',
chance: 'Chance',
vitalite: 'Vitalité',
};
export const TYPE_EMOJI: Record<string, string> = {
weapon: '⚔️',
armor: '🛡️',
consumable: '🧪',
};
// ── Coûts de jeu ──
export const COMBAT_COST = 5;
export const REST_COST = 10;
export const FORGE_ENDURANCE_COST = 10;
export const FORGE_GOLD_COST: Record<number, number> = {
1: 50,
2: 100,
3: 200,
4: 400,
5: 700,
};
export const FORGE_FAIL_CHANCE: Record<number, number> = {
1: 0,
2: 0,
3: 20,
4: 30,
5: 40,
};
export const FORGE_TABLE = [
{ level: 1, gold: 50, endurance: 10, risk: '0%', bonus: '+2' },
{ level: 2, gold: 100, endurance: 10, risk: '0%', bonus: '+4' },
{ level: 3, gold: 200, endurance: 10, risk: '20%', bonus: '+6' },
{ level: 4, gold: 400, endurance: 10, risk: '30%', bonus: '+8' },
{ level: 5, gold: 700, endurance: 10, risk: '40%', bonus: '+10' },
];
export const ATTACK_TYPES = [
{ id: 'melee', label: 'Mêlée', emoji: '⚔️', stat: 'Force × 1.5' },
{ id: 'ranged', label: 'Distance', emoji: '🏹', stat: 'Agilité × 1.5' },
{ id: 'magic', label: 'Magie', emoji: '✨', stat: 'Intelligence × 1.5' },
];

View File

@@ -0,0 +1,49 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { authApi } from '../api/endpoints';
import type { User } from '../api/types';
interface AuthCtx {
user: User | null;
loading: boolean;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const Ctx = createContext<AuthCtx | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = async () => {
try {
const u = await authApi.me();
setUser(u);
} catch {
setUser(null);
}
};
useEffect(() => {
refresh().finally(() => setLoading(false));
}, []);
useEffect(() => {
const onExpired = () => setUser(null);
window.addEventListener('auth:expired', onExpired);
return () => window.removeEventListener('auth:expired', onExpired);
}, []);
const logout = async () => {
await authApi.logout();
setUser(null);
};
return <Ctx.Provider value={{ user, loading, logout, refresh }}>{children}</Ctx.Provider>;
}
export function useAuth() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View File

@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import type { Monster, Item, Recipe } from '../api/types';
const guideApi = {
monsters: () => api.get<(Monster & { zone: string })[]>('/monsters/bestiary'),
items: () => api.get<Item[]>('/items'),
materials: () => api.get<any[]>('/materials'),
recipes: () => api.get<Recipe[]>('/craft/recipes'),
};
export function useGuideData(search: string) {
const { data: monsters = [] } = useQuery({ queryKey: ['guide-monsters'], queryFn: guideApi.monsters, staleTime: 5 * 60_000 });
const { data: items = [] } = useQuery({ queryKey: ['guide-items'], queryFn: guideApi.items, staleTime: 5 * 60_000 });
const { data: materials = [] } = useQuery({ queryKey: ['guide-materials'], queryFn: guideApi.materials, staleTime: 5 * 60_000 });
const { data: recipes = [] } = useQuery({ queryKey: ['guide-recipes'], queryFn: guideApi.recipes, staleTime: 5 * 60_000 });
const q = search.toLowerCase().trim();
const filteredMonsters = useMemo(
() => q ? monsters.filter(m => m.name.toLowerCase().includes(q) || (m as any).zone?.toLowerCase().includes(q)) : monsters,
[monsters, q],
);
const filteredItems = useMemo(
() => q ? items.filter(i => i.name.toLowerCase().includes(q) || i.rarity.toLowerCase().includes(q) || i.description?.toLowerCase().includes(q)) : items,
[items, q],
);
const matMap = useMemo(() => new Map<string, any>(materials.map(m => [m.id, m])), [materials]);
const filteredRecipes = useMemo(() => {
if (!q) return recipes;
return recipes.filter(r =>
r.name.toLowerCase().includes(q) ||
r.resultItem?.name.toLowerCase().includes(q) ||
r.ingredients.some(ing => matMap.get(ing.materialId)?.name.toLowerCase().includes(q))
);
}, [recipes, matMap, q]);
return { monsters, items, materials, recipes, filteredMonsters, filteredItems, filteredRecipes, matMap, q };
}

157
frontend/src/index.css Normal file
View File

@@ -0,0 +1,157 @@
@import "tailwindcss";
@theme {
--color-rpg-bg: #0d0f14;
--color-rpg-surface: #161b25;
--color-rpg-border: #2a3448;
--color-rpg-gold: #f4c94e;
--color-rpg-gold-dim: #c49c2e;
--color-rpg-red: #e84040;
--color-rpg-green: #3ddc84;
--color-rpg-blue: #5ba4f5;
--color-rpg-purple: #a78bfa;
--color-rpg-text: #dce4f0;
--color-rpg-muted: #6b7a99;
}
* { box-sizing: border-box; }
body {
margin: 0;
background-color: #0d0f14;
color: #dce4f0;
font-family: system-ui, sans-serif;
min-height: 100vh;
}
#root { min-height: 100vh; }
/* Barres */
.bar-track { background: #1e2535; border-radius: 4px; overflow: hidden; height: 10px; }
.bar-fill-hp { background: linear-gradient(90deg, #c0392b, #e84040); height: 100%; transition: width 0.4s ease; }
.bar-fill-end { background: linear-gradient(90deg, #1d6fa4, #5ba4f5); height: 100%; transition: width 0.4s ease; }
.bar-fill-xp { background: linear-gradient(90deg, #7c3aed, #a78bfa); height: 100%; transition: width 0.4s ease; }
/* Cards */
.card { background: #161b25; border: 1px solid #2a3448; border-radius: 8px; padding: 1rem; }
.card-gold { border-color: #c49c2e; }
.card-hover { cursor: pointer; transition: border-color 0.2s; }
.card-hover:hover { border-color: #f4c94e; }
/* Boutons */
.btn { font-weight: 600; padding: 0.5rem 1.25rem; border-radius: 6px; border: none; cursor: pointer; transition: opacity 0.2s; font-size: 0.875rem; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-gold { background: linear-gradient(135deg, #c49c2e, #f4c94e); color: #0d0f14; }
.btn-gold:hover:not(:disabled) { opacity: 0.85; }
.btn-red { background: linear-gradient(135deg, #c0392b, #e84040); color: #fff; }
.btn-red:hover:not(:disabled) { opacity: 0.85; }
.btn-ghost { background: #1e2535; color: #dce4f0; border: 1px solid #2a3448; }
.btn-ghost:hover:not(:disabled) { background: #2a3448; }
.btn-blue { background: linear-gradient(135deg, #1d6fa4, #5ba4f5); color: #fff; }
.btn-blue:hover:not(:disabled) { opacity: 0.85; }
/* Rareté */
.rarity-common { color: #9ca3af; }
.rarity-rare { color: #5ba4f5; }
.rarity-epic { color: #a78bfa; }
.rarity-legendary { color: #f4c94e; }
/* Badge */
.badge { font-size: 0.7rem; font-weight: 700; padding: 2px 8px; border-radius: 99px; text-transform: uppercase; letter-spacing: 0.05em; }
.badge-green { background: #0d2a1a; color: #3ddc84; border: 1px solid #1a5c35; }
.badge-red { background: #2a0d0d; color: #e84040; border: 1px solid #5c1a1a; }
.badge-gold { background: #2a1f0d; color: #f4c94e; border: 1px solid #5c420d; }
.badge-blue { background: #0d1a2a; color: #5ba4f5; border: 1px solid #1a3f5c; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #0d0f14; }
::-webkit-scrollbar-thumb { background: #2a3448; border-radius: 3px; }
/* Séparateur */
.divider { border: none; border-top: 1px solid #2a3448; margin: 1rem 0; }
/* Input */
.input-rpg {
background: #1e2535;
border: 1px solid #2a3448;
color: #dce4f0;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
width: 100%;
outline: none;
transition: border-color 0.2s;
}
.input-rpg:focus { border-color: #f4c94e; }
.input-rpg::placeholder { color: #6b7a99; }
/* Combat log */
.combat-log { background: #0d0f14; border: 1px solid #2a3448; border-radius: 6px; padding: 0.75rem; max-height: 260px; overflow-y: auto; font-size: 0.8rem; font-family: monospace; }
.log-player { color: #3ddc84; }
.log-monster { color: #e84040; }
.log-system { color: #f4c94e; }
.log-crit { color: #a78bfa; font-weight: bold; }
/* ── Layout ── */
.layout { min-height: 100vh; display: flex; flex-direction: column; }
.layout-body { display: flex; flex: 1; }
.layout-main { flex: 1; padding: 1.5rem; max-width: 900px; margin: 0 auto; width: 100%; }
/* Sidebar desktop */
.sidebar {
width: 56px; background: #161b25; border-right: 1px solid #2a3448;
display: flex; flex-direction: column; align-items: center;
padding: 1rem 0; gap: 4px; position: sticky; top: 52px; height: calc(100vh - 52px);
}
/* Nav item */
.nav-item {
display: flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border-radius: 8px;
color: #6b7a99; background: transparent; border: 1px solid transparent;
text-decoration: none; transition: all 0.15s; cursor: pointer;
}
.nav-item.active { color: #f4c94e; background: #1e2535; border-color: #c49c2e; }
/* Grids responsive */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.grid-2-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75rem; }
/* ── Mobile responsive ── */
@media (max-width: 768px) {
.sidebar {
position: fixed; bottom: 0; left: 0; right: 0; top: auto;
width: 100%; height: 56px; flex-direction: row;
justify-content: space-around; padding: 0 0.25rem;
border-right: none; border-top: 1px solid #2a3448;
z-index: 20;
}
.sidebar .nav-spacer { display: none; }
.layout-body { padding-bottom: 56px; }
.layout-main { padding: 1rem 0.75rem; }
.grid-2 { grid-template-columns: 1fr; }
.grid-2-cards { grid-template-columns: 1fr; }
/* HudBar compact */
.hud-bar { font-size: 10px; gap: 6px; padding: 4px 8px; flex-wrap: wrap; }
.hud-regen { display: none; }
/* Guide drawer full width mobile */
.guide-drawer { width: 100% !important; }
/* Cards légèrement plus compacts */
.card { padding: 0.75rem; }
/* Header compact */
.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; }
}

110
frontend/src/lib/oauth.ts Normal file
View File

@@ -0,0 +1,110 @@
// OAuth 2.0 PKCE client — SuperOAuth consumer for TetaRdPG
// Adapted from OriginsDigital reference pattern
const OAUTH_URL = import.meta.env.VITE_OAUTH_URL || '';
const OAUTH_CLIENT_ID = import.meta.env.VITE_OAUTH_CLIENT_ID || '';
const SESSION_KEY_VERIFIER = 'trpg_pkce_verifier';
// --- PKCE helpers ---
function base64UrlEncode(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array.buffer);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(digest);
}
// --- Auth URL ---
export async function buildAuthUrl(
redirectUri: string,
provider: string,
scope = 'openid profile email',
clientId = OAUTH_CLIENT_ID,
): Promise<{ url: string; verifier: string }> {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)).buffer);
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
provider,
code_challenge: challenge,
code_challenge_method: 'S256',
});
return {
url: `${OAUTH_URL}/oauth/authorize?${params.toString()}`,
verifier,
};
}
// --- Token exchange ---
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope?: string;
}
export async function exchangeCode(
code: string,
verifier: string,
redirectUri: string,
clientId = OAUTH_CLIENT_ID,
): Promise<TokenResponse> {
const response = await fetch(`${OAUTH_URL}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
}).toString(),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
}
const data = await response.json() as TokenResponse;
if (!data.access_token) throw new Error('No access_token in OAuth response');
return data;
}
// --- PKCE verifier persistence (avant redirect) ---
export function saveVerifier(verifier: string): void {
localStorage.setItem(SESSION_KEY_VERIFIER, verifier);
}
export function loadVerifier(): string | null {
return localStorage.getItem(SESSION_KEY_VERIFIER);
}
export function clearVerifier(): void {
localStorage.removeItem(SESSION_KEY_VERIFIER);
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,209 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { Trophy, Lock, CheckCircle, Gift, Star, Coins } from 'lucide-react';
const CATEGORY_LABELS: Record<string, { label: string; emoji: string }> = {
combat: { label: 'Combat', emoji: '⚔️' },
progression: { label: 'Progression', emoji: '⭐' },
zones: { label: 'Zones', emoji: '🗺️' },
economy: { label: 'Économie', emoji: '💰' },
equipment: { label: 'Équipement', emoji: '🔨' },
};
const TIER_COLORS: Record<string, string> = {
bronze: '#cd7f32',
silver: '#c0c0c0',
gold: '#f4c94e',
};
interface AchievementProgress {
id: string;
key: string;
name: string;
description: string;
category: string;
tier: string;
criteriaType: string;
criteriaValue: number;
rewardGold: number;
rewardTitle: string | null;
progress: number;
unlocked: boolean;
unlockedAt: string | null;
claimed: boolean;
percentage: number;
}
function AchievementCard({ a }: { a: AchievementProgress }) {
const qc = useQueryClient();
const claimMut = useMutation({
mutationFn: () => api.post(`/achievements/claim/${a.id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['achievements'] });
qc.invalidateQueries({ queryKey: ['character'] });
},
});
const tierColor = TIER_COLORS[a.tier] ?? '#6b7a99';
const canClaim = a.unlocked && !a.claimed;
return (
<div
className={`card ${a.unlocked ? 'card-gold' : ''}`}
style={{ padding: '0.75rem 1rem', opacity: a.unlocked ? 1 : 0.7 }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
{/* Icon */}
<div style={{
width: 36, height: 36, borderRadius: 8,
background: a.unlocked ? tierColor + '22' : '#1e2535',
border: `2px solid ${a.unlocked ? tierColor : '#2a3448'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{a.claimed ? <CheckCircle size={18} color="#3ddc84" />
: a.unlocked ? <Trophy size={18} color={tierColor} />
: <Lock size={16} color="#3a4560" />
}
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 700, fontSize: 13, color: a.unlocked ? tierColor : '#6b7a99' }}>
{a.name}
</span>
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 4,
background: tierColor + '22', color: tierColor, fontWeight: 700,
textTransform: 'uppercase',
}}>
{a.tier}
</span>
</div>
<p style={{ margin: 0, fontSize: 11, color: '#6b7a99' }}>{a.description}</p>
{/* Progress bar */}
{!a.claimed && (
<div style={{ marginTop: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: '#6b7a99', marginBottom: 2 }}>
<span>{a.progress} / {a.criteriaValue}</span>
<span>{a.percentage}%</span>
</div>
<div style={{ background: '#1e2535', borderRadius: 3, height: 4, overflow: 'hidden' }}>
<div style={{
width: `${a.percentage}%`, height: '100%',
background: a.unlocked ? tierColor : '#5ba4f5',
borderRadius: 3, transition: 'width 0.3s',
}} />
</div>
</div>
)}
{/* Rewards */}
<div style={{ display: 'flex', gap: 10, marginTop: 6, fontSize: 10, color: '#6b7a99' }}>
{a.rewardGold > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Coins size={9} color="#f4c94e" /> {a.rewardGold} or
</span>
)}
{a.rewardTitle && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Star size={9} color="#a78bfa" /> Titre : {a.rewardTitle}
</span>
)}
</div>
{/* Claim button */}
{canClaim && (
<button
className="btn btn-gold"
style={{ marginTop: 6, fontSize: 11, padding: '0.2rem 0.75rem' }}
disabled={claimMut.isPending}
onClick={() => claimMut.mutate()}
>
<Gift size={12} style={{ display: 'inline', marginRight: 4 }} />
{claimMut.isPending ? 'Réclamation…' : 'Réclamer'}
</button>
)}
{a.claimed && (
<div style={{ marginTop: 4, fontSize: 10, color: '#3ddc84', display: 'flex', alignItems: 'center', gap: 4 }}>
<CheckCircle size={10} /> Réclamé
</div>
)}
</div>
</div>
</div>
);
}
export function AchievementsPage() {
const { data: achievements, isLoading } = useQuery({
queryKey: ['achievements'],
queryFn: () => api.get<AchievementProgress[]>('/achievements/me'),
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
if (!achievements) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Aucun succès</div>;
// Group by category
const categories = new Map<string, AchievementProgress[]>();
for (const a of achievements) {
const list = categories.get(a.category) ?? [];
list.push(a);
categories.set(a.category, list);
}
const totalUnlocked = achievements.filter(a => a.unlocked).length;
const totalClaimed = achievements.filter(a => a.claimed).length;
const claimable = achievements.filter(a => a.unlocked && !a.claimed).length;
return (
<div>
<h2 style={{ margin: '0 0 0.5rem', color: '#f4c94e', fontSize: 20 }}>🏆 Succès</h2>
{/* Summary */}
<div className="card" style={{ marginBottom: '1rem', display: 'flex', gap: 24, padding: '0.75rem 1rem' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#f4c94e' }}>{totalUnlocked}</div>
<div style={{ fontSize: 10, color: '#6b7a99' }}>Débloqués</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#3ddc84' }}>{totalClaimed}</div>
<div style={{ fontSize: 10, color: '#6b7a99' }}>Réclamés</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#dce4f0' }}>{achievements.length}</div>
<div style={{ fontSize: 10, color: '#6b7a99' }}>Total</div>
</div>
{claimable > 0 && (
<div style={{ textAlign: 'center', marginLeft: 'auto' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#f4c94e' }}>🎁 {claimable} à réclamer !</div>
</div>
)}
</div>
{/* Categories */}
{Array.from(categories.entries()).map(([cat, achs]) => {
const info = CATEGORY_LABELS[cat] ?? { label: cat, emoji: '📋' };
return (
<div key={cat} style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>
{info.emoji} {info.label}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{achs
.sort((a, b) => {
const tierOrder = { bronze: 0, silver: 1, gold: 2 };
return (tierOrder[a.tier as keyof typeof tierOrder] ?? 0) - (tierOrder[b.tier as keyof typeof tierOrder] ?? 0);
})
.map(a => <AchievementCard key={a.id} a={a} />)
}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { exchangeCode, loadVerifier, clearVerifier } from '../lib/oauth';
import { authApi } from '../api/endpoints';
import { useAuth } from '../context/AuthContext';
export function AuthCallback() {
const navigate = useNavigate();
const { refresh } = useAuth();
const called = useRef(false);
const [status, setStatus] = useState<'loading' | 'error'>('loading');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (called.current) return;
called.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const error = params.get('error');
if (error) {
setStatus('error');
setErrorMsg(error);
return;
}
if (!code) {
navigate('/login?error=no_code', { replace: true });
return;
}
const verifier = loadVerifier();
if (!verifier) {
navigate('/login?error=no_verifier', { replace: true });
return;
}
const redirectUri = `${window.location.origin}/auth/callback`;
exchangeCode(code, verifier, redirectUri)
.then((tokens) => {
clearVerifier();
return authApi.setSession(tokens.access_token, tokens.refresh_token);
})
.then(() => refresh())
.then(() => navigate('/dashboard', { replace: true }))
.catch(() => {
clearVerifier();
navigate('/login?error=session_failed', { replace: true });
});
}, [navigate, refresh]);
if (status === 'error') {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 40, marginBottom: 16 }}>💀</div>
<p style={{ color: '#ef4444', fontSize: 14, marginBottom: 8 }}>Erreur d'authentification</p>
<p style={{ color: '#6b7a99', fontSize: 12 }}>{errorMsg}</p>
</div>
</div>
);
}
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 40, marginBottom: 16 }}>⚔️</div>
<p style={{ color: '#6b7a99', fontSize: 14 }}>Connexion en cours</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,221 @@
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { combatApi, characterApi } from '../api/endpoints';
import type { Monster, CombatResult, MultiCombatResult } from '../api/types';
import { Swords, Clock, Zap, Heart, Lock, Sparkles } from 'lucide-react';
import { Link } from 'react-router-dom';
import { COMBAT_COST, REST_COST, ATTACK_TYPES, ZONE_INFO } from '../constants';
import { MonsterCard } from '../components/MonsterCard';
import { CombatLogView, MultiCombatView, HistoryEntry } from '../components/CombatViews';
export function CombatPage() {
const qc = useQueryClient();
const [selectedMonster, setSelectedMonster] = useState<Monster | null>(null);
const [attackType, setAttackType] = useState('melee');
const [lastResult, setLastResult] = useState<CombatResult | null>(null);
const [lastMultiResult, setLastMultiResult] = useState<MultiCombatResult | null>(null);
const [cooldown, setCooldown] = useState(false);
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const endurance = char?.enduranceCurrent ?? 0;
const playerLevel = char?.level ?? 1;
const canFight = endurance >= COMBAT_COST;
const needsHeal = char ? char.hpCurrent < char.hpMax : false;
const canHeal = needsHeal && endurance >= REST_COST;
const healMut = useMutation({
mutationFn: () => characterApi.rest(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
const { data: monsters, isLoading } = useQuery({
queryKey: ['monsters'],
queryFn: combatApi.monsters,
});
const { data: zones } = useQuery({
queryKey: ['zones'],
queryFn: combatApi.zones,
});
const { data: history } = useQuery({
queryKey: ['combatHistory'],
queryFn: combatApi.history,
});
const startCooldown = useCallback(() => {
setCooldown(true);
setTimeout(() => setCooldown(false), 1500);
}, []);
const fight = useMutation({
mutationFn: (count: number = 1) => combatApi.start(selectedMonster!.id, attackType, count),
onSuccess: (result) => {
if (result.mode === 'multi') {
setLastMultiResult(result as MultiCombatResult);
setLastResult(null);
} else {
setLastResult(result as CombatResult);
setLastMultiResult(null);
}
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['combatHistory'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
qc.invalidateQueries({ queryKey: ['materialsInventory'] });
startCooldown();
},
onError: (err: Error) => { toast.error(err.message); startCooldown(); },
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement des monstres</div>;
// Group monsters by zone
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 ZONE_LABELS = ZONE_INFO;
// Locked zones (zones not in monsters response = locked)
const lockedZones = (zones ?? []).filter((z: any) => !z.unlocked);
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, color: '#f4c94e', fontSize: 20 }}> Combat</h2>
<Link to="/combat/tactical" className="btn btn-blue" style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, padding: '0.5rem 1rem' }}>
<Sparkles size={14} /> Combat Tactique
</Link>
</div>
<div className="grid-2" style={{ marginBottom: '1rem' }}>
{/* Choix monstre par zone */}
<div>
{Array.from(monstersByZone.entries()).map(([zone, zoneMonsters]) => {
const info = ZONE_LABELS[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={playerLevel}
/>
))}
</div>
</div>
);
})}
{/* Zones verrouillées */}
{lockedZones.map((z: any) => (
<div key={z.id} className="card" style={{ marginBottom: '0.5rem', opacity: 0.4, textAlign: 'center', padding: '1rem' }}>
<Lock size={16} color="#6b7a99" style={{ display: 'inline', marginRight: 6 }} />
<span style={{ fontSize: 13, color: '#6b7a99' }}>
{z.emoji} {z.name} Complétez l'arc précédent pour débloquer
</span>
</div>
))}
</div>
{/* Panneau droite */}
<div>
{/* Type d'attaque */}
<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>
{/* Soins rapide */}
{needsHeal && (
<button
className="btn btn-ghost"
style={{ width: '100%', marginBottom: 8, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center', opacity: canHeal ? 1 : 0.5 }}
disabled={healMut.isPending || !canHeal}
onClick={() => healMut.mutate()}
>
<Heart size={12} color="#e84040" />
{healMut.isPending ? 'Soins' : `Soins (+50% PV, ${REST_COST}⚡)`}
<span style={{ color: '#6b7a99', fontSize: 11 }}>— {char!.hpCurrent}/{char!.hpMax} PV</span>
</button>
)}
{/* Coût endurance */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginBottom: 6, fontSize: 12, color: canFight ? '#5ba4f5' : '#e84040' }}>
<Zap size={12} /> Coût : {COMBAT_COST} endurance — Disponible : {endurance}
{canFight && <span style={{ color: '#6b7a99' }}>({Math.floor(endurance / COMBAT_COST)} combats)</span>}
</div>
{/* Boutons combattre */}
<div style={{ display: 'flex', gap: 6 }}>
{[1, 5, 10].map(n => (
<button
key={n}
className="btn btn-red"
style={{ flex: n === 1 ? 2 : 1, fontSize: n === 1 ? 14 : 12, padding: '0.75rem 0.5rem', opacity: canFight && !cooldown ? 1 : 0.5 }}
disabled={!selectedMonster || fight.isPending || !canFight || cooldown}
onClick={() => fight.mutate(n)}
>
{fight.isPending ? (
<span><Swords size={14} style={{ display: 'inline', marginRight: 4 }} />Combat…</span>
) : (
n === 1
? <span>⚔️ Combat ({COMBAT_COST}⚡)</span>
: <span>×{n} ({COMBAT_COST * n}⚡)</span>
)}
</button>
))}
</div>
{fight.isError && (
<p style={{ color: '#e84040', fontSize: 12, marginTop: 8 }}>{(fight.error as Error).message}</p>
)}
{/* Historique récent */}
{history && history.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', display:'flex', alignItems:'center', gap:4 }}>
<Clock size={11} /> Historique récent
</p>
<div className="card" style={{ padding: '0.75rem' }}>
{history.slice(0, 10).map(h => <HistoryEntry key={h.id} h={h} />)}
</div>
</div>
)}
</div>
</div>
{/* Résultat du dernier combat */}
{lastMultiResult && <MultiCombatView result={lastMultiResult} />}
{lastResult && <CombatLogView result={lastResult} />}
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { craftApi, materialApi } from '../api/endpoints';
import type { Recipe, CraftJob } from '../api/types';
import { Hammer, Clock, CheckCircle } from 'lucide-react';
function timeLeft(completedAt: string): string {
const diff = new Date(completedAt).getTime() - Date.now();
if (diff <= 0) return 'Prêt !';
const s = Math.ceil(diff / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
function ActiveCraft({ job, onCollect }: { job: CraftJob; onCollect: () => void }) {
const [, tick] = useState(0);
useEffect(() => {
const id = setInterval(() => tick(n => n + 1), 1000);
return () => clearInterval(id);
}, []);
const ready = job.status === 'ready' || new Date(job.completedAt) <= new Date();
return (
<div className={`card ${ready ? 'card-gold' : ''}`} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{ready ? <CheckCircle size={16} color="#3ddc84" /> : <Clock size={16} color="#5ba4f5" />}
<div>
<span style={{ fontWeight: 700, fontSize: 14 }}>{job.recipe.name}</span>
<span style={{ fontSize: 12, color: '#6b7a99', marginLeft: 8 }}>
{job.recipe.resultItem.name}
</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!ready && <span style={{ fontSize: 13, color: '#5ba4f5', fontFamily: 'monospace' }}>{timeLeft(job.completedAt)}</span>}
<button
className={`btn ${ready ? 'btn-gold' : 'btn-ghost'}`}
style={{ fontSize: 12, padding: '0.25rem 0.75rem' }}
disabled={!ready}
onClick={onCollect}
>
{ready ? '⚒️ Collecter' : 'En cours…'}
</button>
</div>
</div>
</div>
);
}
function RecipeCard({ recipe, onCraft, disabled, materials }: {
recipe: Recipe;
onCraft: () => void;
disabled: boolean;
materials: Map<string, number>;
}) {
const canCraft = recipe.ingredients.every(ing => (materials.get(ing.materialId) ?? 0) >= ing.quantity);
return (
<div className={`card ${canCraft ? '' : ''}`} style={{ opacity: canCraft ? 1 : 0.6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
<span style={{ fontWeight: 700, fontSize: 14 }}>{recipe.name}</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{recipe.craftDurationSeconds}s · {recipe.enduranceCost} end.</span>
</div>
<div style={{ fontSize: 12, color: '#6b7a99', marginBottom: 8 }}>
<span style={{ color: '#dce4f0' }}>{recipe.resultItem.name}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 10 }}>
{recipe.ingredients.map(ing => {
const have = materials.get(ing.materialId) ?? 0;
const ok = have >= ing.quantity;
return (
<span key={ing.materialId} className={`badge ${ok ? 'badge-green' : 'badge-red'}`}>
{ing.materialName ?? '?'} {have}/{ing.quantity}
</span>
);
})}
</div>
<button
className="btn btn-gold"
style={{ fontSize: 12, padding: '0.3rem 0.875rem' }}
disabled={!canCraft || disabled}
onClick={onCraft}
>
<Hammer size={12} style={{ display: 'inline', marginRight: 4 }} />
Craft
</button>
</div>
);
}
export function CraftPage() {
const qc = useQueryClient();
const { data: recipes } = useQuery({ queryKey: ['recipes'], queryFn: craftApi.recipes });
const { data: activeCraft, refetch: refetchActive } = useQuery({ queryKey: ['activeCraft'], queryFn: craftApi.active, refetchInterval: 5000 });
const { data: mats } = useQuery({ queryKey: ['materials'], queryFn: materialApi.inventory });
const materialMap = new Map(mats?.map(cm => [cm.material.id, cm.quantity]) ?? []);
const startMut = useMutation({
mutationFn: (recipeId: string) => craftApi.start(recipeId),
onSuccess: () => {
toast.success('Craft lancé !');
qc.invalidateQueries({ queryKey: ['activeCraft'] }); qc.invalidateQueries({ queryKey: ['character'] }); qc.invalidateQueries({ queryKey: ['materials'] });
},
onError: (err: Error) => toast.error(err.message),
});
const collectMut = useMutation({
mutationFn: (jobId: string) => craftApi.collect(jobId),
onSuccess: () => {
toast.success('Item récupéré !');
qc.invalidateQueries({ queryKey: ['activeCraft'] }); qc.invalidateQueries({ queryKey: ['inventory'] }); refetchActive();
},
onError: (err: Error) => toast.error(err.message),
});
const hasActive = activeCraft && 'id' in activeCraft;
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>
<Hammer size={18} style={{ display: 'inline', marginRight: 8 }} />Artisanat
</h2>
{hasActive && (
<ActiveCraft
job={activeCraft as CraftJob}
onCollect={() => collectMut.mutate((activeCraft as CraftJob).id)}
/>
)}
{hasActive && (
<div className="card" style={{ marginBottom: '1rem', textAlign: 'center', color: '#6b7a99', fontSize: 13 }}>
Un craft est en cours tu ne peux pas en lancer un autre.
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.75rem' }}>
{recipes?.map(r => (
<RecipeCard
key={r.id}
recipe={r}
onCraft={() => startMut.mutate(r.id)}
disabled={hasActive || startMut.isPending}
materials={materialMap}
/>
))}
</div>
{recipes?.length === 0 && (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: '#6b7a99' }}>
Aucune recette disponible.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,307 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { characterApi, itemApi } from '../api/endpoints';
import { api } from '../api/client';
import { Bar } from '../components/Bar';
import { Onboarding } from '../components/Onboarding';
import { CreateCharacter } from '../components/CreateCharacter';
import { STAT_LABELS as STAT_LABELS_MAP } from '../constants';
import { Zap, Heart, Star, Coins, Sword, Shield, BedDouble } from 'lucide-react';
const STATS = ['force', 'agilite', 'intelligence', 'chance', 'vitalite'] as const;
const STAT_LABELS = STAT_LABELS_MAP;
function StatDistributor({ char }: { char: any }) {
const qc = useQueryClient();
const [pts, setPts] = useState<Record<string, number>>({ force: 0, agilite: 0, intelligence: 0, chance: 0, vitalite: 0 });
const used = Object.values(pts).reduce((a, b) => a + b, 0);
const remaining = (char.statPoints ?? 0) - used;
const mut = useMutation({
mutationFn: () => characterApi.distributeStats(pts),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['character'] });
setPts({ force: 0, agilite: 0, intelligence: 0, chance: 0, vitalite: 0 });
},
});
const adjust = (stat: string, delta: number) => {
const next = (pts[stat] ?? 0) + delta;
if (next < 0) return;
if (delta > 0 && remaining <= 0) return;
setPts(p => ({ ...p, [stat]: next }));
};
return (
<div className="card card-gold">
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#f4c94e' }}>
Répartir {char.statPoints} point{char.statPoints > 1 ? 's' : ''} de stats
</p>
<p style={{ margin: '0 0 0.75rem', fontSize: 11, color: '#6b7a99' }}>
{remaining > 0 ? `${remaining} restant${remaining > 1 ? 's' : ''}` : 'Prêt à valider'}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: '0.75rem' }}>
{STATS.map(s => (
<div key={s} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 12, color: '#dce4f0', width: 100 }}>
{STAT_LABELS[s]} <span style={{ color: '#6b7a99' }}>({char[s]})</span>
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button className="btn btn-ghost" style={{ padding: '0.1rem 0.4rem', fontSize: 13 }} onClick={() => adjust(s, -1)} disabled={pts[s] <= 0}></button>
<span style={{ width: 20, textAlign: 'center', fontWeight: 700, color: pts[s] > 0 ? '#3ddc84' : '#6b7a99', fontSize: 13 }}>
{pts[s] > 0 ? `+${pts[s]}` : '0'}
</span>
<button className="btn btn-ghost" style={{ padding: '0.1rem 0.4rem', fontSize: 13 }} onClick={() => adjust(s, +1)} disabled={remaining <= 0}>+</button>
</div>
</div>
))}
</div>
<button
className="btn btn-gold"
style={{ width: '100%', fontSize: 13 }}
disabled={used === 0 || mut.isPending}
onClick={() => mut.mutate()}
>
{mut.isPending ? 'Application…' : `Valider (+${used} pts)`}
</button>
{mut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 6 }}>{(mut.error as Error).message}</p>}
</div>
);
}
function TitleSelector({ char }: { char: any }) {
const qc = useQueryClient();
const { data: achievements } = useQuery({
queryKey: ['achievements'],
queryFn: () => api.get<any[]>('/achievements/me'),
});
const titleMut = useMutation({
mutationFn: (title: string | null) => characterApi.setTitle(title),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
// Collect unlocked titles from claimed achievements
const unlockedTitles: string[] = [];
if (achievements) {
for (const a of achievements) {
if (a.claimed && a.rewardTitle) {
unlockedTitles.push(a.rewardTitle);
}
}
}
if (unlockedTitles.length === 0) return null;
return (
<div className="card" style={{ gridColumn: '1 / -1' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>🏅 Titre actif</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
className={`btn ${!char.activeTitle ? 'btn-gold' : 'btn-ghost'}`}
style={{ fontSize: 11, padding: '0.2rem 0.6rem' }}
disabled={titleMut.isPending}
onClick={() => titleMut.mutate(null)}
>
Aucun
</button>
{unlockedTitles.map(t => (
<button
key={t}
className={`btn ${char.activeTitle === t ? 'btn-gold' : 'btn-ghost'}`}
style={{ fontSize: 11, padding: '0.2rem 0.6rem' }}
disabled={titleMut.isPending}
onClick={() => titleMut.mutate(t)}
>
{t}
</button>
))}
</div>
</div>
);
}
function CombatStatsPanel({ char }: { char: any }) {
const { data: inventory } = useQuery({ queryKey: ['inventory'], queryFn: itemApi.inventory });
const weapon = inventory?.find((ci: any) => ci.equipped && ci.item.type === 'weapon');
const armor = inventory?.find((ci: any) => ci.equipped && ci.item.type === 'armor');
const weaponATK = weapon ? weapon.item.attackBonus + weapon.forgeLevel * 2 : 0;
const armorDEF = armor ? armor.item.defenseBonus + armor.forgeLevel * 2 : 0;
const baseDmg = 3 + weaponATK + Math.floor(char.force * 1.5);
return (
<div className="card" style={{ gridColumn: '1 / -1' }}>
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Combat actuel</p>
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Sword size={14} color="#f4c94e" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Attaque : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{baseDmg}</span>
{weapon && <span style={{ fontSize: 10, color: '#6b7a99' }}>({weapon.item.name} {weapon.forgeLevel > 0 ? `+${weapon.forgeLevel}` : ''})</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Shield size={14} color="#5ba4f5" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Défense : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{armorDEF}</span>
{armor && <span style={{ fontSize: 10, color: '#6b7a99' }}>({armor.item.name} {armor.forgeLevel > 0 ? `+${armor.forgeLevel}` : ''})</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Zap size={14} color="#3ddc84" />
<span style={{ fontSize: 13, color: '#6b7a99' }}>Critique : </span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{(5 + char.chance * 0.2).toFixed(1)}%</span>
</div>
</div>
</div>
);
}
export function DashboardPage() {
const qc = useQueryClient();
const { data: char, isLoading, isError } = useQuery({
queryKey: ['character'],
queryFn: characterApi.me,
retry: 1,
});
const restMut = useMutation({
mutationFn: () => characterApi.rest(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['character'] }),
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
if (isError || !char) return <CreateCharacter />;
const xpNext = char.xpToNextLevel;
const statPoints = char.statPoints ?? 0;
const needsHeal = char.hpCurrent < char.hpMax;
const endurance = char.enduranceCurrent;
const REST_COST = 10;
const COMBAT_COST = 5;
const FORGE_COST = 10;
const canRest = endurance >= REST_COST && needsHeal;
return (
<div>
{/* Header perso */}
<div className="card card-gold" style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '1rem', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 48 }}>🐸</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<h2 style={{ margin: 0, fontSize: 22, color: '#f4c94e' }}>{char.name}</h2>
<span style={{ fontSize: 13, color: '#6b7a99' }}>Niveau {char.level}</span>
{char.activeTitle && (
<span style={{ fontSize: 11, color: '#a78bfa', fontStyle: 'italic' }}>« {char.activeTitle} »</span>
)}
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
<Coins size={12} color="#f4c94e" /> {char.gold} or
</span>
<span style={{ fontSize: 12, color: '#6b7a99', display: 'flex', alignItems: 'center', gap: 4 }}>
<Star size={12} color="#a78bfa" /> {char.xp} / {xpNext} XP
</span>
{statPoints > 0 && (
<span className="badge badge-gold">+{statPoints} pts à répartir</span>
)}
</div>
</div>
</div>
<Onboarding level={char.level} onDismiss={() => {}} />
<div className="grid-2">
{/* Barres vitales */}
<div className="card">
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>État</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#e84040', display: 'flex', alignItems: 'center', gap: 4 }}>
<Heart size={11} /> PV
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.hpCurrent} / {char.hpMax}</span>
</div>
<Bar value={char.hpCurrent} max={char.hpMax} type="hp" showValues={false} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#5ba4f5', display: 'flex', alignItems: 'center', gap: 4 }}>
<Zap size={11} /> Endurance
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{endurance} / {char.enduranceMax}</span>
</div>
<Bar value={endurance} max={char.enduranceMax} type="end" showValues={false} />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: '#a78bfa', display: 'flex', alignItems: 'center', gap: 4 }}>
<Star size={11} /> XP
</span>
<span style={{ fontSize: 11, color: '#6b7a99' }}>{char.xp} / {xpNext}</span>
</div>
<Bar value={char.xp} max={xpNext} type="xp" showValues={false} />
</div>
{/* Budget endurance */}
<div style={{ marginTop: 8, padding: '6px 8px', background: '#111620', borderRadius: 6, fontSize: 11, color: '#6b7a99' }}>
<span style={{ fontWeight: 700, color: '#5ba4f5' }}> Budget :</span>
{' '}{Math.floor(endurance / COMBAT_COST)} combats
{' · '}{Math.floor(endurance / FORGE_COST)} forges
{' · '}{Math.floor(endurance / REST_COST)} soins
</div>
{needsHeal && (
<button
className="btn btn-ghost"
style={{ marginTop: 4, fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center', opacity: canRest ? 1 : 0.5 }}
disabled={restMut.isPending || !canRest}
onClick={() => restMut.mutate()}
>
<BedDouble size={13} />
{restMut.isPending ? 'Soins…' : `Soins (+50% PV, ${REST_COST}⚡)`}
</button>
)}
{needsHeal && !canRest && endurance < REST_COST && (
<p style={{ fontSize: 10, color: '#e84040', textAlign: 'center', margin: '2px 0 0' }}>Endurance insuffisante pour les soins</p>
)}
{restMut.isError && <p style={{ color: '#e84040', fontSize: 11, marginTop: 2 }}>{(restMut.error as Error).message}</p>}
</div>
</div>
{/* Stats */}
<div className="card">
<p style={{ margin: '0 0 0.75rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>Statistiques</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px' }}>
{STATS.map(s => (
<div key={s} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#6b7a99' }}>{STAT_LABELS[s]}</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char[s]}</span>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#e84040', display:'flex', alignItems:'center', gap:3 }}><Heart size={10}/> PV max</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0' }}>{char.hpMax}</span>
</div>
</div>
</div>
{/* Distributeur de stats */}
{statPoints > 0 && (
<div style={{ gridColumn: '1 / -1' }}>
<StatDistributor char={char} />
</div>
)}
{/* Titres */}
<TitleSelector char={char} />
{/* Équipement résumé */}
<CombatStatsPanel char={char} />
</div>
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { itemApi, forgeApi, characterApi } from '../api/endpoints';
import type { CharacterItem } from '../api/types';
import { Shield, CheckCircle, XCircle, AlertTriangle, Zap, Coins } from 'lucide-react';
import { FORGE_FAIL_CHANCE, FORGE_ENDURANCE_COST, FORGE_GOLD_COST } from '../constants';
const FORGE_RISK = [0, 0, 0, FORGE_FAIL_CHANCE[3], FORGE_FAIL_CHANCE[4], FORGE_FAIL_CHANCE[5]];
const FORGE_LABEL = ['—', '—', 'Garanti', `${FORGE_FAIL_CHANCE[3]}% échec`, `${FORGE_FAIL_CHANCE[4]}% échec`, `${FORGE_FAIL_CHANCE[5]}% échec`];
function ForgePanel({ nextLevel, risk, endurance, gold, isPending, onForge }: {
nextLevel: number; risk: number; endurance: number; gold: number; isPending: boolean; onForge: () => void;
}) {
const goldCost = FORGE_GOLD_COST[nextLevel] ?? 0;
const canForge = endurance >= FORGE_ENDURANCE_COST && gold >= goldCost;
return (
<>
<div className="card" style={{ marginBottom: '0.75rem', textAlign: 'center' }}>
<div style={{ fontSize: 12, color: '#6b7a99', marginBottom: 4 }}>Prochain niveau : +{nextLevel}</div>
<div style={{
fontSize: 14, fontWeight: 700,
color: risk === 0 ? '#3ddc84' : risk <= 20 ? '#f4c94e' : '#e84040',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4
}}>
{risk === 0
? <><CheckCircle size={14} /> Succès garanti</>
: <><AlertTriangle size={14} /> {FORGE_LABEL[nextLevel]}</>
}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 16, marginBottom: 8, fontSize: 12 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: endurance >= FORGE_ENDURANCE_COST ? '#5ba4f5' : '#e84040' }}>
<Zap size={11} /> {FORGE_ENDURANCE_COST} endurance
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: gold >= goldCost ? '#f4c94e' : '#e84040' }}>
<Coins size={11} /> {goldCost} or
</span>
</div>
<button
className="btn btn-gold"
style={{ width: '100%', fontSize: 14, padding: '0.75rem', opacity: canForge ? 1 : 0.5 }}
disabled={isPending || !canForge}
onClick={onForge}
>
{isPending ? 'Forge en cours…' : `🔨 Forger → +${nextLevel} (${FORGE_ENDURANCE_COST}${goldCost}💰)`}
</button>
{!canForge && (
<p style={{ textAlign: 'center', fontSize: 11, color: '#e84040', marginTop: 4 }}>
{endurance < FORGE_ENDURANCE_COST ? 'Endurance insuffisante' : 'Or insuffisant'}
</p>
)}
</>
);
}
export function ForgePage() {
const qc = useQueryClient();
const [selected, setSelected] = useState<CharacterItem | null>(null);
const [lastResult, setLastResult] = useState<{ success: boolean; newLevel: number } | null>(null);
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const endurance = char?.enduranceCurrent ?? 0;
const gold = char?.gold ?? 0;
const { data: inventory, isLoading } = useQuery({
queryKey: ['inventory'],
queryFn: itemApi.inventory,
});
const forgeMut = useMutation({
mutationFn: () => forgeApi.upgrade(selected!.id),
onSuccess: (res) => {
setLastResult({ success: res.success, newLevel: res.forgeLevel });
if (res.success) {
toast.success(`Forge réussie ! +${res.forgeLevel}`);
setSelected(prev => prev ? { ...prev, forgeLevel: res.forgeLevel } : null);
} else {
toast.error('Forge échouée — or et endurance perdus');
}
qc.invalidateQueries({ queryKey: ['inventory'] });
qc.invalidateQueries({ queryKey: ['character'] });
},
onError: (err: Error) => toast.error(err.message),
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
const forgeable = inventory ?? [];
const nextLevel = (selected?.forgeLevel ?? 0) + 1;
const risk = FORGE_RISK[nextLevel] ?? 40;
const atMax = selected && selected.forgeLevel >= 5;
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>
<Shield size={18} style={{ display: 'inline', marginRight: 8 }} />Forge
</h2>
<div className="grid-2">
{/* Sélection item */}
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
Choisir un équipement
</p>
{forgeable.length === 0 ? (
<div className="card" style={{ color: '#6b7a99', fontSize: 13, textAlign: 'center', padding: '1.5rem' }}>
Aucun item dans l'inventaire
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{forgeable.map(ci => (
<div
key={ci.id}
className={`card card-hover ${selected?.id === ci.id ? 'card-gold' : ''}`}
onClick={() => { setSelected(ci); setLastResult(null); }}
style={{ display: 'flex', alignItems: 'center', gap: 10 }}
>
<span style={{ fontSize: 20 }}>{ci.item.type === 'weapon' ? '' : '🛡'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 13, color: selected?.id === ci.id ? '#f4c94e' : '#dce4f0' }}>
{ci.item.name}
</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>Niveau forge : {ci.forgeLevel}/5</div>
</div>
{ci.forgeLevel > 0 && (
<span className="badge badge-blue" style={{ fontSize: 9 }}>+{ci.forgeLevel}</span>
)}
</div>
))}
</div>
)}
</div>
{/* Panneau forge */}
<div>
{selected ? (
<div className="card card-gold" style={{ padding: '1.25rem' }}>
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<div style={{ fontSize: 40, marginBottom: 4 }}>
{selected.item.type === 'weapon' ? '' : '🛡'}
</div>
<div style={{ fontWeight: 800, fontSize: 16, color: '#f4c94e' }}>{selected.item.name}</div>
<div style={{ fontSize: 12, color: '#6b7a99', marginTop: 2 }}>Forge actuelle : +{selected.forgeLevel}</div>
</div>
{!atMax ? (
<ForgePanel
nextLevel={nextLevel} risk={risk} endurance={endurance} gold={gold}
isPending={forgeMut.isPending} onForge={() => forgeMut.mutate()}
/>
) : (
<div style={{ textAlign: 'center', color: '#f4c94e', fontSize: 13, padding: '0.5rem' }}>
✨ Niveau maximum atteint (+5)
</div>
)}
{/* Résultat */}
{lastResult && (
<div style={{ marginTop: '0.75rem', textAlign: 'center' }}>
{lastResult.success
? <div style={{ color: '#3ddc84', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<CheckCircle size={16} /> Succès ! Item à +{lastResult.newLevel}
</div>
: <div style={{ color: '#e84040', fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<XCircle size={16} /> Échec — l'item est inchangé
</div>
}
</div>
)}
</div>
) : (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: '#6b7a99', fontSize: 13 }}>
Sélectionne un équipement à améliorer
</div>
)}
{/* Tableau des risques */}
<div className="card" style={{ marginTop: '1rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99' }}>Risques par niveau</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{[1,2,3,4,5].map(n => (
<div key={n} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, padding: '2px 0' }}>
<span style={{ color: '#9ca3af' }}>Niv. {n}</span>
<span style={{ color: FORGE_RISK[n] === 0 ? '#3ddc84' : FORGE_RISK[n] <= 20 ? '#f4c94e' : '#e84040', fontWeight: 600 }}>
{FORGE_LABEL[n]}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,561 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { Monster, Item, Recipe } from '../api/types';
import { Swords, Shield, Map as MapIcon, Hammer, ShoppingBag, BookOpen, Sparkles, Search, Gamepad2 } from 'lucide-react';
import { useGuideData } from '../hooks/useGuideData';
import { RARITY_COLORS, FORGE_TABLE, ZONE_INFO } from '../constants';
import { RarityBadge } from '../components/RarityBadge';
const ZONES = [
// 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: '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 = [
{ id: 'start', label: 'Démarrer', icon: BookOpen },
{ id: 'zones', label: 'Zones', icon: MapIcon },
{ id: 'dao', label: 'Dao', icon: Gamepad2 },
{ id: 'bestiary', label: 'Bestiaire', icon: Swords },
{ id: 'items', label: 'Équipement', icon: Shield },
{ id: 'craft', label: 'Artisanat', icon: Hammer },
{ id: 'forge', label: 'Forge', icon: Sparkles },
{ id: 'shop', label: 'Boutique', icon: ShoppingBag },
];
// ── Components ──
function StatBar({ label, value, max, color }: { label: string; value: number; max: number; color: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11 }}>
<span style={{ width: 24, color: '#6b7a99' }}>{label}</span>
<div style={{ flex: 1, height: 6, background: '#1e2535', borderRadius: 3 }}>
<div style={{ width: `${Math.min(100, (value / max) * 100)}%`, height: '100%', background: color, borderRadius: 3 }} />
</div>
<span style={{ width: 28, textAlign: 'right', color: '#dce4f0' }}>{value}</span>
</div>
);
}
// ── Tab: Démarrer ──
function StartTab() {
return (
<div>
<h3 style={{ color: '#f4c94e', margin: '0 0 1rem', fontSize: 18 }}>Bienvenue dans TetaRdPG</h3>
<div className="card" style={{ marginBottom: '1rem' }}>
<p style={{ color: '#dce4f0', fontSize: 13, lineHeight: 1.6, margin: 0 }}>
TetaRdPG est un RPG textuel idle chaque action coûte de l'endurance.
Combattez des monstres, récoltez des matériaux, craftez de l'équipement et forgez-le pour devenir plus fort.
</p>
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Comment progresser ?</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[
{ step: '1', title: 'Combattre', desc: 'Affrontez des monstres pour gagner XP, Or et matériaux.' },
{ step: '2', title: 'Récolter', desc: 'Chaque monstre peut dropper un matériau propre à sa zone.' },
{ step: '3', title: 'Crafter', desc: 'Utilisez vos matériaux pour fabriquer des armes et armures.' },
{ step: '4', title: 'Forger', desc: 'Améliorez votre équipement (+1 à +5) pour des bonus de stats.' },
].map(s => (
<div key={s.step} className="card" style={{ padding: '0.75rem' }}>
<div style={{ fontSize: 20, color: '#f4c94e', fontWeight: 800 }}>{s.step}</div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#dce4f0', marginBottom: 4 }}>{s.title}</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>{s.desc}</div>
</div>
))}
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Mécaniques clés</h4>
<div className="card" style={{ fontSize: 12, lineHeight: 1.8, color: '#9ca3af' }}>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#dce4f0' }}>Endurance</strong> Chaque combat coûte 5. Recharge : 1 point / 6 min. Max : 100+.</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#dce4f0' }}>Types d'attaque</strong> — Mêlée (Force ×1.5), Distance (Agilité ×1.5), Magie (Intelligence ×1.5).</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#dce4f0' }}>Drop rate</strong> — Varie selon la difficulté du monstre : 25% (facile) → 80% (boss). Les boss droppent 2-3 matériaux.</p>
<p style={{ margin: 0 }}><strong style={{ color: '#dce4f0' }}>Défaite</strong> — Perte d'endurance (25), PV réduits à 20%, perte de 5% de l'or.</p>
</div>
</div>
);
}
// ── Tab: Zones ──
function ZonesTab() {
return (
<div>
<h3 style={{ color: '#f4c94e', margin: '0 0 1rem', fontSize: 18 }}>Les Zones</h3>
<p style={{ color: '#6b7a99', fontSize: 12, marginBottom: '1rem' }}>
Progressez de zone en zone en complétant les arcs de quêtes. Chaque zone a ses monstres, matériaux et équipements.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{ZONES.map((z, i) => (
<div key={z.id} className="card" style={{ borderLeft: `3px solid ${z.color}` }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 24 }}>{z.emoji}</span>
<div>
<div style={{ fontWeight: 700, color: z.color, fontSize: 15 }}>{z.name}</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>{z.desc}</div>
</div>
</div>
<div style={{ fontSize: 11, color: '#9ca3af' }}>
{i === 0 ? '🔓 Toujours accessible' : `🔒 Déblocage : compléter l'arc de quêtes ${ZONES[i - 1].name}`}
</div>
</div>
))}
</div>
</div>
);
}
// ── 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 ──
function BestiaryTab({ monsters, materials }: { monsters: (Monster & { zone: string })[]; materials: any[] }) {
const matMap = new Map<string, any>(materials.map(m => [m.id, m]));
return (
<div>
<h3 style={{ color: '#f4c94e', margin: '0 0 1rem', fontSize: 18 }}>Bestiaire</h3>
{ZONES.map(zone => {
const zoneMonsters = monsters.filter(m => m.zone === zone.id).sort((a, b) => a.minLevel - b.minLevel);
if (!zoneMonsters.length) return null;
return (
<div key={zone.id} style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: zone.color }}>
{zone.emoji} {zone.name}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{zoneMonsters.map(m => {
const dropMat = m.dropMaterialId ? matMap.get(m.dropMaterialId) : null;
return (
<div key={m.id} className="card" style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontWeight: 700, color: '#dce4f0', fontSize: 13 }}>{m.name}</span>
<span className="badge badge-green" style={{ fontSize: 10 }}>Niv. {m.minLevel}{m.maxLevel}</span>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<StatBar label="PV" value={m.hp} max={300} color="#e84040" />
</div>
<div style={{ display: 'flex', gap: 16, fontSize: 11, color: '#6b7a99' }}>
<span>⚔️ {m.attack}</span>
<span>🛡️ {m.defense}</span>
<span>⭐ {m.xpReward} XP</span>
<span>💰 {m.goldMin}{m.goldMax}</span>
<span style={{ textTransform: 'capitalize' }}>🎯 {m.attackType}</span>
</div>
{dropMat && (
<div style={{ marginTop: 6, fontSize: 11, color: '#f4c94e' }}>
🎁 Drop : {dropMat.name} <RarityBadge rarity={dropMat.rarity} />
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// ── Tab: Équipement ──
function ItemsTab({ items }: { items: Item[] }) {
const equipment = items.filter(i => i.type !== 'consumable');
const consumables = items.filter(i => i.type === 'consumable');
return (
<div>
<h3 style={{ color: '#f4c94e', margin: '0 0 1rem', fontSize: 18 }}>Équipement</h3>
{ZONES.map(zone => {
const zoneItems = equipment.filter(i => (i as any).zone === zone.id).sort((a, b) => a.attackBonus + a.defenseBonus - b.attackBonus - b.defenseBonus);
if (!zoneItems.length) return null;
return (
<div key={zone.id} style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: zone.color }}>
{zone.emoji} {zone.name}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{zoneItems.map(item => (
<div key={item.id} className="card" style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontWeight: 700, color: RARITY_COLORS[item.rarity], fontSize: 12 }}>
{item.type === 'weapon' ? '⚔️' : '🛡️'} {item.name}
</span>
<RarityBadge rarity={item.rarity} />
</div>
<div style={{ fontSize: 11, color: '#6b7a99', marginBottom: 4 }}>{item.description}</div>
<div style={{ fontSize: 11, color: '#dce4f0' }}>
{item.attackBonus > 0 && <span>ATK +{item.attackBonus} </span>}
{item.defenseBonus > 0 && <span>DEF +{item.defenseBonus} </span>}
{item.agiliteBonus > 0 && <span>AGI +{item.agiliteBonus} </span>}
{item.intelligenceBonus > 0 && <span>INT +{item.intelligenceBonus} </span>}
{(item as any).buyPrice > 0 && <span style={{ color: '#f4c94e' }}>💰 {(item as any).buyPrice}</span>}
{(item as any).buyPrice === 0 && <span style={{ color: '#a78bfa' }}>🔨 Craft only</span>}
</div>
</div>
))}
</div>
</div>
);
})}
{consumables.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#e84040' }}>🧪 Consommables</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{consumables.map(item => (
<div key={item.id} className="card" style={{ padding: '0.75rem' }}>
<div style={{ fontWeight: 700, color: '#dce4f0', fontSize: 12, marginBottom: 4 }}>
🧪 {item.name}
</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>{item.description}</div>
{(item as any).buyPrice > 0 && (
<div style={{ fontSize: 11, color: '#f4c94e', marginTop: 4 }}>💰 {(item as any).buyPrice}</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
// ── Tab: Artisanat ──
function CraftTab({ recipes, materials }: { recipes: Recipe[]; materials: any[] }) {
const matMap = new Map<string, any>(materials.map(m => [m.id, m]));
return (
<div>
<h3 style={{ color: '#f4c94e', margin: '0 0 1rem', fontSize: 18 }}>Artisanat</h3>
<p style={{ color: '#6b7a99', fontSize: 12, marginBottom: '1rem' }}>
Combinez des matériaux droppés par les monstres pour crafter de l'équipement unique — souvent meilleur que la boutique.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{recipes.sort((a, b) => a.enduranceCost - b.enduranceCost).map(recipe => (
<div key={recipe.id} className="card" style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontWeight: 700, color: RARITY_COLORS[recipe.resultItem?.rarity] ?? '#dce4f0', fontSize: 13 }}>
{recipe.resultItem?.type === 'weapon' ? '⚔️' : recipe.resultItem?.type === 'armor' ? '🛡️' : '🧪'} {recipe.resultItem?.name ?? recipe.name}
</span>
{recipe.resultItem && <RarityBadge rarity={recipe.resultItem.rarity} />}
</div>
{recipe.resultItem && (
<div style={{ fontSize: 11, color: '#dce4f0', marginBottom: 6 }}>
{recipe.resultItem.attackBonus > 0 && <span>ATK +{recipe.resultItem.attackBonus} </span>}
{recipe.resultItem.defenseBonus > 0 && <span>DEF +{recipe.resultItem.defenseBonus} </span>}
{recipe.resultItem.agiliteBonus > 0 && <span>AGI +{recipe.resultItem.agiliteBonus} </span>}
{recipe.resultItem.intelligenceBonus > 0 && <span>INT +{recipe.resultItem.intelligenceBonus} </span>}
</div>
)}
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 6 }}>
<span>⏱️ {recipe.craftDurationSeconds}s</span>
<span style={{ marginLeft: 12 }}>⚡ {recipe.enduranceCost} endurance</span>
</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>
Ingrédients : {recipe.ingredients.map((ing, i) => {
const mat = matMap.get(ing.materialId);
return (
<span key={i}>
{i > 0 && ' + '}
<span style={{ color: RARITY_COLORS[mat?.rarity] ?? '#dce4f0' }}>
{ing.quantity}× {mat?.name ?? '???'}
</span>
</span>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
// ── Tab: Forge ──
function ForgeTab() {
return (
<div>
<h3 style={{ color: '#f4c94e', margin: '0 0 1rem', fontSize: 18 }}>Forge</h3>
<p style={{ color: '#6b7a99', fontSize: 12, marginBottom: '1rem' }}>
Améliorez vos armes et armures de +1 à +5. Les niveaux 1-2 sont garantis. A partir du +3, il y a un risque d'échec — l'or et l'endurance sont perdus même en cas d'échec.
</p>
<div className="card">
<table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid #2a3448', color: '#6b7a99', fontSize: 11 }}>
<th style={{ padding: '8px 4px', textAlign: 'left' }}>Niveau</th>
<th style={{ padding: '8px 4px', textAlign: 'right' }}>Or</th>
<th style={{ padding: '8px 4px', textAlign: 'right' }}>Endurance</th>
<th style={{ padding: '8px 4px', textAlign: 'right' }}>Risque</th>
<th style={{ padding: '8px 4px', textAlign: 'right' }}>Bonus total</th>
</tr>
</thead>
<tbody>
{FORGE_TABLE.map(row => (
<tr key={row.level} style={{ borderBottom: '1px solid #1e2535' }}>
<td style={{ padding: '6px 4px', color: '#f4c94e', fontWeight: 700 }}>+{row.level}</td>
<td style={{ padding: '6px 4px', textAlign: 'right', color: '#dce4f0' }}>{row.gold} 💰</td>
<td style={{ padding: '6px 4px', textAlign: 'right', color: '#5ba4f5' }}>{row.endurance} ⚡</td>
<td style={{ padding: '6px 4px', textAlign: 'right', color: row.risk === '0%' ? '#3ddc84' : '#e84040' }}>{row.risk}</td>
<td style={{ padding: '6px 4px', textAlign: 'right', color: '#a78bfa', fontWeight: 700 }}>{row.bonus}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card" style={{ marginTop: '0.75rem', fontSize: 12, color: '#9ca3af', lineHeight: 1.6 }}>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#3ddc84' }}>Niv. 1-2</strong> — Succès garanti. Aucun risque.</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#e84040' }}>Niv. 3-5</strong> — Risque croissant. En cas d'échec : or et endurance perdus, équipement intact.</p>
<p style={{ margin: 0 }}><strong style={{ color: '#f4c94e' }}>Bonus</strong> — +2 ATK (armes) ou +2 DEF (armures) par niveau de forge.</p>
</div>
</div>
);
}
// ── Tab: Boutique ──
function ShopTab({ items }: { items: Item[] }) {
const shopItems = items.filter(i => (i as any).buyPrice > 0);
return (
<div>
<h3 style={{ color: '#f4c94e', margin: '0 0 1rem', fontSize: 18 }}>Boutique</h3>
<p style={{ color: '#6b7a99', fontSize: 12, marginBottom: '1rem' }}>
Achetez de l'équipement avec votre or. Les items de la boutique sont accessibles dès que vous débloquez la zone correspondante.
Revente : 40% du prix + 50% de l'investissement de forge.
</p>
{ZONES.map(zone => {
const zoneShop = shopItems.filter(i => (i as any).zone === zone.id).sort((a, b) => (a as any).buyPrice - (b as any).buyPrice);
if (!zoneShop.length) return null;
return (
<div key={zone.id} style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: zone.color }}>
{zone.emoji} {zone.name}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{zoneShop.map(item => (
<div key={item.id} className="card" style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontWeight: 700, color: '#dce4f0', fontSize: 12 }}>
{item.type === 'weapon' ? '⚔️' : item.type === 'armor' ? '🛡️' : '🧪'} {item.name}
</span>
<span style={{ color: '#f4c94e', fontSize: 12, fontWeight: 700 }}>{(item as any).buyPrice} 💰</span>
</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>
{item.attackBonus > 0 && <span>ATK +{item.attackBonus} </span>}
{item.defenseBonus > 0 && <span>DEF +{item.defenseBonus} </span>}
<span> · Niv. {(item as any).minLevel}+</span>
</div>
</div>
))}
</div>
</div>
);
})}
{/* Potions */}
{(() => {
const potions = shopItems.filter(i => i.type === 'consumable');
if (!potions.length) return null;
return (
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#e84040' }}>🧪 Potions</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{potions.map(item => (
<div key={item.id} className="card" style={{ padding: '0.75rem' }}>
<div style={{ fontWeight: 700, color: '#dce4f0', fontSize: 12, marginBottom: 4 }}>{item.name}</div>
<div style={{ fontSize: 11, color: '#6b7a99' }}>{item.description}</div>
<div style={{ fontSize: 11, color: '#f4c94e', marginTop: 4 }}>{(item as any).buyPrice} 💰</div>
</div>
))}
</div>
</div>
);
})()}
</div>
);
}
// ── Main Page ──
export function GuidePage() {
const [tab, setTab] = useState('start');
const [search, setSearch] = useState('');
const { materials, filteredMonsters, filteredItems, filteredRecipes, q } = useGuideData(search);
const navigate = useNavigate();
return (
<div style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem' }}>
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: '1.5rem', position: 'relative' }}>
<h1 style={{ color: '#f4c94e', fontSize: 28, margin: '0 0 0.25rem', fontWeight: 800 }}>
📖 Guide du Têtard
</h1>
<p style={{ color: '#6b7a99', fontSize: 13, margin: 0 }}>
Tout ce qu'il faut savoir pour survivre dans le monde de TetaRdPG
</p>
<button
onClick={() => navigate('/dashboard')}
style={{
position: 'absolute', right: 0, top: '50%', transform: 'translateY(-50%)',
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 14px', borderRadius: 8, border: '1px solid #2a3448',
background: '#1e2535', color: '#f4c94e', fontSize: 12,
fontWeight: 600, cursor: 'pointer', transition: 'all 0.15s',
}}
>
<Gamepad2 size={14} />
Jouer
</button>
</div>
{/* Search */}
<div style={{ position: 'relative', marginBottom: '1rem' }}>
<Search size={14} style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#6b7a99' }} />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Rechercher un monstre, item, matériau, recette…"
style={{
width: '100%', padding: '10px 12px 10px 34px', fontSize: 13,
background: '#1e2535', border: '1px solid #2a3448', borderRadius: 8,
color: '#dce4f0', outline: 'none', boxSizing: 'border-box',
}}
/>
{search && (
<button
onClick={() => setSearch('')}
style={{
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', color: '#6b7a99', cursor: 'pointer', fontSize: 14,
}}
>✕</button>
)}
</div>
{/* Tab navigation */}
<div style={{
display: 'flex', gap: 4, marginBottom: '1.5rem', overflowX: 'auto',
borderBottom: '1px solid #2a3448', paddingBottom: 8,
}}>
{TABS.map(t => {
const Icon = t.icon;
const active = tab === t.id;
const count = q ? (
t.id === 'bestiary' ? filteredMonsters.length :
t.id === 'items' ? filteredItems.filter(i => i.type !== 'consumable').length :
t.id === 'craft' ? filteredRecipes.length :
t.id === 'shop' ? filteredItems.filter(i => (i as any).buyPrice > 0).length :
null
) : null;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '8px 14px', border: 'none', borderRadius: 6,
background: active ? '#f4c94e22' : 'transparent',
color: active ? '#f4c94e' : '#6b7a99',
fontWeight: active ? 700 : 500, fontSize: 12,
cursor: 'pointer', whiteSpace: 'nowrap',
transition: 'all 0.15s',
}}
>
<Icon size={14} />
{t.label}
{count !== null && count > 0 && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 8,
background: active ? '#f4c94e33' : '#2a3448', color: active ? '#f4c94e' : '#9ca3af',
minWidth: 16, textAlign: 'center',
}}>{count}</span>
)}
</button>
);
})}
</div>
{/* Tab content */}
{tab === 'start' && <StartTab />}
{tab === 'zones' && <ZonesTab />}
{tab === 'dao' && <DaoTab />}
{tab === 'bestiary' && <BestiaryTab monsters={filteredMonsters} materials={materials} />}
{tab === 'items' && <ItemsTab items={filteredItems} />}
{tab === 'craft' && <CraftTab recipes={filteredRecipes} materials={materials} />}
{tab === 'forge' && <ForgeTab />}
{tab === 'shop' && <ShopTab items={filteredItems} />}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { itemApi, materialApi } from '../api/endpoints';
import { api } from '../api/client';
import type { CharacterItem } from '../api/types';
import { Package, Sword, Shield, Coins } from 'lucide-react';
import { RARITY_LABELS as RARITY_LABEL, FORGE_GOLD_COST as FORGE_COSTS_MAP } from '../constants';
function ItemCard({ ci, onEquip, onUnequip, onSell, selling }: {
ci: CharacterItem; onEquip: () => void; onUnequip: () => void; onSell: () => void; selling: boolean;
}) {
const { item } = ci;
const forgeBonusATK = item.type === 'weapon' ? ci.forgeLevel * 2 : 0;
const forgeBonusDEF = item.type === 'armor' ? ci.forgeLevel * 2 : 0;
const totalATK = item.attackBonus + forgeBonusATK;
const totalDEF = item.defenseBonus + forgeBonusDEF;
let forgeInvestment = 0;
for (let i = 1; i <= ci.forgeLevel; i++) forgeInvestment += FORGE_COSTS_MAP[i] ?? 0;
const sellPrice = Math.floor(((item as any).buyPrice || 0) * 0.4 + forgeInvestment * 0.5);
const bonuses = [
totalATK > 0 && `+${totalATK} ATK${forgeBonusATK > 0 ? ` (${item.attackBonus}+${forgeBonusATK})` : ''}`,
totalDEF > 0 && `+${totalDEF} DEF${forgeBonusDEF > 0 ? ` (${item.defenseBonus}+${forgeBonusDEF})` : ''}`,
item.forceBonus && `+${item.forceBonus} FOR`,
item.agiliteBonus && `+${item.agiliteBonus} AGI`,
item.intelligenceBonus && `+${item.intelligenceBonus} INT`,
item.chanceBonus && `+${item.chanceBonus} CHA`,
item.vitaliteBonus && `+${item.vitaliteBonus} VIT`,
].filter(Boolean).join(' · ');
return (
<div className={`card ${ci.equipped ? 'card-gold' : ''}`} style={{ position: 'relative' }}>
{ci.equipped && (
<span className="badge badge-gold" style={{ position: 'absolute', top: 8, right: 8, fontSize: 9 }}>Équipé</span>
)}
{ci.forgeLevel > 0 && (
<span className="badge badge-blue" style={{ position: 'absolute', top: ci.equipped ? 28 : 8, right: 8, fontSize: 9 }}>
+{ci.forgeLevel}
</span>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 20 }}>{item.type === 'weapon' ? '⚔️' : '🛡️'}</span>
<div>
<div style={{ fontWeight: 700, fontSize: 13 }}>{item.name}</div>
<div className={`rarity-${item.rarity}`} style={{ fontSize: 11 }}>{RARITY_LABEL[item.rarity]}</div>
</div>
</div>
{bonuses && <div style={{ fontSize: 11, color: '#3ddc84', marginBottom: 8 }}>{bonuses}</div>}
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{!ci.equipped
? <button className="btn btn-ghost" style={{ fontSize: 11, padding: '0.2rem 0.6rem' }} onClick={onEquip}>Équiper</button>
: <button className="btn btn-ghost" style={{ fontSize: 11, padding: '0.2rem 0.6rem' }} onClick={onUnequip}>Déséquiper</button>
}
{!ci.equipped && sellPrice > 0 && (
<button className="btn btn-ghost" style={{ fontSize: 10, padding: '0.15rem 0.5rem', color: '#6b7a99' }}
disabled={selling} onClick={onSell}>
<Coins size={10} style={{ display: 'inline', marginRight: 3 }} />{selling ? '...' : `Vendre (${sellPrice}💰)`}
</button>
)}
</div>
</div>
);
}
export function InventoryPage() {
const qc = useQueryClient();
const { data: inventory, isLoading: loadInv } = useQuery({
queryKey: ['inventory'],
queryFn: itemApi.inventory,
});
const { data: materials, isLoading: loadMat } = useQuery({
queryKey: ['materials'],
queryFn: materialApi.inventory,
});
const equipMut = useMutation({
mutationFn: (id: string) => itemApi.equip(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['inventory'] }),
});
const unequipMut = useMutation({
mutationFn: (slot: 'weapon' | 'armor') => itemApi.unequip(slot),
onSuccess: () => qc.invalidateQueries({ queryKey: ['inventory'] }),
});
const sellMut = useMutation({
mutationFn: (charItemId: string) => api.post<any>(`/shop/sell/${charItemId}`),
onSuccess: () => {
toast.success('Item vendu !');
qc.invalidateQueries({ queryKey: ['inventory'] });
qc.invalidateQueries({ queryKey: ['character'] });
},
onError: (err: Error) => toast.error(err.message),
});
if (loadInv || loadMat) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
const weapons = inventory?.filter(ci => ci.item.type === 'weapon') ?? [];
const armors = inventory?.filter(ci => ci.item.type === 'armor') ?? [];
return (
<div>
<h2 style={{ margin: '0 0 1rem', color: '#f4c94e', fontSize: 20 }}>
<Package size={18} style={{ display: 'inline', marginRight: 8 }} />Inventaire
</h2>
{inventory?.length === 0 && (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: '#6b7a99' }}>
Inventaire vide gagne des combats pour lootter des matériaux et crafter des équipements !
</div>
)}
{/* Armes */}
{weapons.length > 0 && (
<div style={{ marginBottom: '1.25rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', display:'flex', alignItems:'center', gap:4 }}>
<Sword size={11} /> Armes ({weapons.length})
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.75rem' }}>
{weapons.map(ci => (
<ItemCard
key={ci.id} ci={ci}
onEquip={() => equipMut.mutate(ci.id)}
onUnequip={() => unequipMut.mutate('weapon')}
onSell={() => { if (confirm(`Vendre ${ci.item.name} ?`)) sellMut.mutate(ci.id); }}
selling={sellMut.isPending}
/>
))}
</div>
</div>
)}
{/* Armures */}
{armors.length > 0 && (
<div style={{ marginBottom: '1.25rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase', display:'flex', alignItems:'center', gap:4 }}>
<Shield size={11} /> Armures ({armors.length})
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.75rem' }}>
{armors.map(ci => (
<ItemCard
key={ci.id} ci={ci}
onEquip={() => equipMut.mutate(ci.id)}
onUnequip={() => unequipMut.mutate('armor')}
onSell={() => { if (confirm(`Vendre ${ci.item.name} ?`)) sellMut.mutate(ci.id); }}
selling={sellMut.isPending}
/>
))}
</div>
</div>
)}
{/* Matériaux */}
{materials && materials.length > 0 && (
<div>
<p style={{ margin: '0 0 0.5rem', fontSize: 12, fontWeight: 700, color: '#6b7a99', textTransform: 'uppercase' }}>
🌿 Matériaux
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: '0.5rem' }}>
{materials.map(cm => (
<div key={cm.id} className="card" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0.625rem' }}>
<span style={{ fontSize: 18 }}>🌿</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{cm.material.name}</div>
<div className={`rarity-${cm.material.rarity}`} style={{ fontSize: 11 }}>×{cm.quantity}</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { buildAuthUrl, saveVerifier } from '../lib/oauth';
const PROVIDERS = [
{ id: 'discord', label: 'Discord', emoji: '🎮' },
{ id: 'github', label: 'GitHub', emoji: '🐙' },
{ id: 'google', label: 'Google', emoji: '🌐' },
{ id: 'twitch', label: 'Twitch', emoji: '🎬' },
];
export function LoginPage() {
const login = async (provider: string) => {
const redirectUri = `${window.location.origin}/auth/callback`;
const { url, verifier } = await buildAuthUrl(redirectUri, provider);
saveVerifier(verifier);
window.location.href = url;
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'radial-gradient(ellipse at 50% 0%, #1a1f2e 0%, #0d0f14 60%)',
}}>
<div style={{ textAlign: 'center', maxWidth: 380, width: '100%', padding: '0 1rem' }}>
{/* Logo */}
<div style={{ marginBottom: 32 }}>
<div style={{ fontSize: 64, marginBottom: 8 }}>🐸</div>
<h1 style={{ margin: 0, fontSize: 36, fontWeight: 900, color: '#f4c94e', letterSpacing: '-1px' }}>TetaRdPG</h1>
<p style={{ margin: '8px 0 0', color: '#6b7a99', fontSize: 14 }}>
RPG communautaire asynchrone
</p>
</div>
{/* Card login */}
<div className="card" style={{ padding: '1.5rem' }}>
<p style={{ margin: '0 0 1.25rem', color: '#9ca3af', fontSize: 13 }}>
Connecte-toi pour commencer ton aventure
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{PROVIDERS.map(p => (
<button
key={p.id}
onClick={() => login(p.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '0.625rem 1rem',
background: '#1e2535',
border: '1px solid #2a3448',
borderRadius: 8,
color: '#dce4f0',
cursor: 'pointer',
fontSize: 14,
fontWeight: 600,
transition: 'border-color 0.2s',
width: '100%',
}}
onMouseEnter={e => (e.currentTarget.style.borderColor = '#f4c94e')}
onMouseLeave={e => (e.currentTarget.style.borderColor = '#2a3448')}
>
<span style={{ fontSize: 18 }}>{p.emoji}</span>
<span>Continuer avec {p.label}</span>
</button>
))}
</div>
</div>
<p style={{ marginTop: 20, fontSize: 11, color: '#3a4558' }}>
En te connectant, tu acceptes les règles de la taverne du Têtard Prophétique.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { useNavigate } from 'react-router-dom';
import { Gamepad2, BookOpen, MapPin } from 'lucide-react';
export function NotFoundPage() {
const navigate = useNavigate();
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-rpg-bg text-rpg-text text-center p-8">
<div className="text-7xl mb-2">🐸</div>
<h1 className="text-5xl font-extrabold text-rpg-gold mb-2">404</h1>
<p className="text-rpg-muted max-w-sm mb-8">
Ce chemin ne mène nulle part Le têtard s'est perdu dans les marais.
</p>
<div className="flex gap-3 flex-wrap justify-center">
<button onClick={() => navigate('/dashboard')} className="btn btn-gold flex items-center gap-1.5 px-6 py-3 text-sm">
<Gamepad2 size={16} /> Retour au jeu
</button>
<button onClick={() => navigate('/guide')} className="btn btn-ghost flex items-center gap-1.5 px-6 py-3 text-sm">
<BookOpen size={16} /> Guide
</button>
</div>
<p className="text-[11px] text-rpg-border mt-12 flex items-center gap-1">
<MapPin size={10} /> Zone inconnue coordonnées introuvables
</p>
</div>
);
}

View File

@@ -0,0 +1,323 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { questApi } from '../api/endpoints';
import { Scroll, CheckCircle, Circle, Trophy, ChevronDown, ChevronRight, Star, Coins, Swords, Lock } from 'lucide-react';
import { useState, useMemo } from 'react';
const OBJ_LABELS: Record<string, string> = {
kill_monster: 'Tuer',
kill_any: 'Gagner des combats',
gather_material: 'Récolter',
craft_item: 'Crafter',
forge_item: 'Forger',
};
function useInvalidateQuests() {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ['quests'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
qc.invalidateQueries({ queryKey: ['questsAvailable'] });
qc.invalidateQueries({ queryKey: ['questsCompleted'] });
qc.invalidateQueries({ queryKey: ['questArcs'] });
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({
mutationFn: () => questApi.accept(quest.id),
onSuccess: invalidateAll,
});
const claimMut = useMutation({
mutationFn: () => questApi.claim(pq.id),
onSuccess: invalidateAll,
});
const abandonMut = useMutation({
mutationFn: () => questApi.abandon(pq.id),
onSuccess: invalidateAll,
});
const isCompleted = status === 'completed';
const isClaimed = status === 'claimed';
return (
<div className={`card ${isCompleted ? 'card-gold' : ''} py-3 px-4`}>
<div className="flex justify-between items-start mb-1">
<div className="flex-1">
<div className="flex items-center gap-1.5">
{isClaimed ? <CheckCircle size={14} className="text-rpg-green" /> : isCompleted ? <Trophy size={14} className="text-rpg-gold" /> : <Circle size={13} className="text-rpg-muted" />}
<span className={`font-bold text-[13px] ${isCompleted ? 'text-rpg-gold' : 'text-rpg-text'}`}>{quest.name}</span>
{quest.repeatable && <span className="text-[9px] text-rpg-blue bg-[#1a2540] px-1.5 py-px rounded">répétable</span>}
</div>
<p className="mt-1 mb-0 text-[11px] text-rpg-muted">{quest.description}</p>
</div>
</div>
{/* Objectif */}
<div className="text-[11px] text-[#9ca3af] mt-1.5 mb-1">
{OBJ_LABELS[quest.objectiveType] ?? quest.objectiveType} {mode === 'active' ? `${progress}/${quest.objectiveCount}` : `×${quest.objectiveCount}`}
</div>
{/* Progress bar (active quests only) */}
{mode === 'active' && (
<div className="bar-track mb-1.5" style={{ height: 6 }}>
<div className={isCompleted ? 'bar-fill-xp' : 'bar-fill-end'} style={{ width: `${pct}%` }} />
</div>
)}
{/* Rewards */}
<div className="flex gap-3 text-[11px] text-rpg-muted mb-1.5">
<span className="flex items-center gap-1"><Star size={10} className="text-rpg-purple" /> {quest.rewardXp} XP</span>
<span className="flex items-center gap-1"><Coins size={10} className="text-rpg-gold" /> {quest.rewardGold} or</span>
{quest.rewardTitle && <span className="text-rpg-gold">🏅 {quest.rewardTitle}</span>}
{quest.minLevel > 1 && <span>Niv. {quest.minLevel}+</span>}
</div>
{/* Actions */}
{mode === 'available' && (
<button className="btn btn-ghost text-[11px] py-1 px-3" disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
{acceptMut.isPending ? 'Acceptation…' : '+ Accepter'}
</button>
)}
{mode === 'active' && isCompleted && (
<button className="btn btn-gold text-[11px] py-1 px-3" disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
{claimMut.isPending ? 'Réclamation…' : '🎁 Réclamer la récompense'}
</button>
)}
{mode === 'active' && !isCompleted && (
<button className="btn btn-ghost text-[10px] py-0.5 px-2 text-rpg-muted" disabled={abandonMut.isPending} onClick={() => abandonMut.mutate()}>
{abandonMut.isPending ? '…' : '✕ Abandonner'}
</button>
)}
{acceptMut.isError && <p className="text-rpg-red text-[11px] mt-1">{(acceptMut.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 className="text-rpg-red text-[11px] mt-1">{(abandonMut.error as Error).message}</p>}
</div>
);
}
function ArcQuestRow({ q }: { q: any }) {
const qc = useQueryClient();
const acceptMut = useMutation({
mutationFn: () => questApi.accept(q.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['questArcs'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
},
});
const claimMut = useMutation({
mutationFn: () => questApi.claim(q.playerQuestId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['questArcs'] });
qc.invalidateQueries({ queryKey: ['questsActive'] });
qc.invalidateQueries({ queryKey: ['character'] });
},
});
return (
<div className="flex items-center gap-2 text-xs py-1 border-b border-[#1a2030]">
{q.playerStatus === 'claimed'
? <CheckCircle size={12} className="text-rpg-green shrink-0" />
: q.playerStatus === 'completed'
? <Trophy size={12} className="text-rpg-gold shrink-0" />
: q.playerStatus === 'active'
? <Swords size={12} className="text-rpg-blue shrink-0" />
: <Circle size={11} className="text-[#3a4560] shrink-0" />
}
<div className="flex-1 min-w-0">
<span className={
q.playerStatus === 'claimed' ? 'text-rpg-green' : q.playerStatus === 'active' ? 'text-rpg-text' : 'text-rpg-muted'
}>{q.name}</span>
{q.playerStatus === 'active' && (
<span className="text-[10px] text-rpg-blue ml-1.5">{q.progress}/{q.objectiveCount}</span>
)}
</div>
<span className="text-[10px] text-rpg-muted">{q.rewardXp} XP</span>
{q.minLevel > 1 && !q.levelOk && <span className="text-[9px] text-rpg-red">Niv.{q.minLevel}</span>}
{/* Actions */}
{q.canAccept && (
<button className="btn btn-ghost text-[10px] py-px px-1.5" disabled={acceptMut.isPending} onClick={() => acceptMut.mutate()}>
{acceptMut.isPending ? '...' : '+ Accepter'}
</button>
)}
{q.playerStatus === 'completed' && (
<button className="btn btn-gold text-[10px] py-px px-1.5" disabled={claimMut.isPending} onClick={() => claimMut.mutate()}>
{claimMut.isPending ? '...' : '🎁 Réclamer'}
</button>
)}
{acceptMut.isError && <span className="text-rpg-red text-[9px]">{(acceptMut.error as Error).message}</span>}
</div>
);
}
/** Détermine si un arc doit être ouvert par défaut */
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 locked = !arc.zoneUnlocked;
const pct = total > 0 ? Math.floor((completed / total) * 100) : 0;
return (
<div className={`card ${locked ? '' : arc.completed ? '' : 'card-gold'} py-3 px-4 mb-2 ${locked ? 'opacity-40' : ''}`}>
<div
className={`flex items-center gap-2 cursor-pointer ${open && !locked ? 'mb-2' : ''}`}
onClick={() => setOpen(!open)}
>
{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} className={`shrink-0 ${arc.completed ? 'text-rpg-green' : locked ? 'text-rpg-muted' : 'text-rpg-gold'}`} />
<span className={`font-bold text-sm flex-1 ${arc.completed ? 'text-rpg-green' : locked ? 'text-rpg-muted' : 'text-rpg-gold'}`}>
{arc.name}
</span>
<span className="text-[11px] text-rpg-muted">{completed}/{total}</span>
{arc.completed && <CheckCircle size={14} className="text-rpg-green shrink-0" />}
{locked && <span className="text-[10px] text-rpg-muted">🔒 Complétez l'arc précédent</span>}
</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 && (
<>
<p className="text-[11px] text-rpg-muted mb-2 pl-7">{arc.description}</p>
<div className="flex flex-col pl-3">
{arc.quests.map((q: any) => <ArcQuestRow key={q.id} q={q} />)}
</div>
</>
)}
</div>
);
}
export function QuestPage() {
const { data: active, isLoading: loadActive } = useQuery({ queryKey: ['questsActive'], queryFn: questApi.active });
const { data: available, isLoading: loadAvail } = useQuery({ queryKey: ['questsAvailable'], queryFn: questApi.available });
const { data: arcs } = useQuery({ queryKey: ['questArcs'], queryFn: questApi.arcs });
const [showAllCombat, setShowAllCombat] = useState(false);
// 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 isCombatQuest = (q: any) => !isCraftQuest(q);
const activeAll = active ?? [];
const activeCombat = activeAll.filter((pq: any) => !pq.quest.repeatable && isCombatQuest(pq));
const activeCraft = activeAll.filter((pq: any) => !pq.quest.repeatable && isCraftQuest(pq));
const activeDaily = activeAll.filter((pq: any) => pq.quest.repeatable);
const availableAll = available ?? [];
const availableCombat = availableAll.filter((q: any) => !q.repeatable && isCombatQuest(q));
const availableCraft = availableAll.filter((q: any) => !q.repeatable && isCraftQuest(q));
const availableDaily = availableAll.filter((q: any) => q.repeatable);
const shownCombat = showAllCombat ? availableCombat : availableCombat.slice(0, 3);
const hiddenCount = availableCombat.length - 3;
return (
<div>
<h2 className="mb-4 text-rpg-gold text-xl font-bold">📜 Quêtes</h2>
<div className="grid-2">
{/* Active combat quests */}
<div>
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">
Quêtes actives ({activeCombat.length}/3)
</p>
{activeCombat.length > 0 ? (
<div className="flex flex-col gap-1.5">
{activeCombat.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
</div>
) : (
<div className="card py-6 text-center text-rpg-muted text-[13px]">
Aucune quête active — acceptez-en à droite
</div>
)}
</div>
{/* Available combat quests */}
<div>
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">
Quêtes de combat
</p>
{shownCombat.length > 0 ? (
<div className="flex flex-col gap-1.5">
{shownCombat.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
{hiddenCount > 0 && (
<button
className="btn btn-ghost w-full text-[11px] py-1 mt-0.5"
onClick={() => setShowAllCombat(!showAllCombat)}
>
{showAllCombat ? 'Réduire' : `Voir tout (+${hiddenCount} quête${hiddenCount > 1 ? 's' : ''})`}
</button>
)}
</div>
) : (
<div className="card py-6 text-center text-rpg-muted text-[13px]">
Toutes les quêtes de combat sont complétées
</div>
)}
</div>
</div>
{/* Métiers */}
{(activeCraft.length > 0 || availableCraft.length > 0) && (
<div className="mt-6">
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">🔨 Métiers</p>
<div className="grid-2-cards">
{activeCraft.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
{availableCraft.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
</div>
</div>
)}
{/* Tâches quotidiennes */}
<div className="mt-6">
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">🔄 Tâches quotidiennes</p>
<div className="grid-2-cards">
{activeDaily.map((pq: any) => <QuestCard key={pq.id} pq={pq} mode="active" />)}
{availableDaily.map((q: any) => <QuestCard key={q.id} pq={q} mode="available" />)}
</div>
</div>
{/* Arcs narratifs */}
{arcs && arcs.length > 0 && (
<div className="mt-6">
<p className="mb-2 text-xs font-bold text-rpg-muted uppercase">📖 Arcs narratifs</p>
{arcs.map((arc: any) => (
<ArcSection key={arc.id} arc={arc} defaultOpen={arcDefaultOpen[arc.id] ?? false} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { characterApi } from '../api/endpoints';
import { api } from '../api/client';
import { Coins, ShoppingBag, Sword, Shield, Heart, Zap } from 'lucide-react';
import { RARITY_COLORS, TYPE_EMOJI, ZONE_INFO } from '../constants';
interface ShopItem {
id: string;
name: string;
description: string | null;
type: string;
rarity: string;
attackBonus: number;
defenseBonus: number;
forceBonus: number;
buyPrice: number;
minLevel: number;
zone: string | null;
affordable: boolean;
levelOk: boolean;
sellPrice: number;
}
function ShopItemCard({ item, onBuy, buying }: { item: ShopItem; onBuy: () => void; buying: boolean }) {
const canBuy = item.affordable && item.levelOk;
const rarityColor = RARITY_COLORS[item.rarity] ?? '#9ca3af';
return (
<div className="card" style={{ padding: '0.75rem 1rem', opacity: item.levelOk ? 1 : 0.5 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<span style={{ fontSize: 24 }}>{TYPE_EMOJI[item.type] ?? '📦'}</span>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 700, fontSize: 13, color: rarityColor }}>{item.name}</span>
<span style={{ fontSize: 9, padding: '1px 5px', borderRadius: 4, background: rarityColor + '22', color: rarityColor, textTransform: 'uppercase' }}>
{item.rarity}
</span>
</div>
{item.description && <p style={{ margin: '0 0 4px', fontSize: 11, color: '#6b7a99' }}>{item.description}</p>}
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: '#6b7a99' }}>
{item.attackBonus > 0 && <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Sword size={10} color="#f4c94e" /> +{item.attackBonus} ATK</span>}
{item.defenseBonus > 0 && <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Shield size={10} color="#5ba4f5" /> +{item.defenseBonus} DEF</span>}
{item.type === 'consumable' && item.forceBonus > 0
? <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Zap size={10} color="#5ba4f5" /> +{item.forceBonus} endurance</span>
: item.type === 'consumable' && <span style={{ display: 'flex', alignItems: 'center', gap: 3 }}><Heart size={10} color="#e84040" /> +50% PV</span>
}
{item.minLevel > 1 && <span>Niv. {item.minLevel}+</span>}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 14, fontWeight: 700, color: item.affordable ? '#f4c94e' : '#e84040', display: 'flex', alignItems: 'center', gap: 4, justifyContent: 'flex-end' }}>
<Coins size={12} /> {item.buyPrice}
</div>
<button
className={canBuy ? 'btn btn-gold' : 'btn btn-ghost'}
style={{ marginTop: 4, fontSize: 11, padding: '0.2rem 0.6rem', opacity: canBuy ? 1 : 0.5 }}
disabled={!canBuy || buying}
onClick={onBuy}
>
{buying ? '...' : item.type === 'consumable' ? 'Utiliser' : 'Acheter'}
</button>
{!item.levelOk && <div style={{ fontSize: 9, color: '#e84040', marginTop: 2 }}>Niv. {item.minLevel} requis</div>}
</div>
</div>
</div>
);
}
export function ShopPage() {
const qc = useQueryClient();
const { data: char } = useQuery({ queryKey: ['character'], queryFn: characterApi.me });
const { data: catalogue, isLoading } = useQuery({
queryKey: ['shop'],
queryFn: () => api.get<ShopItem[]>('/shop'),
});
const buyMut = useMutation({
mutationFn: (itemId: string) => api.post<any>(`/shop/buy/${itemId}`),
onSuccess: () => {
toast.success('Achat effectué !');
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['shop'] });
qc.invalidateQueries({ queryKey: ['inventory'] });
},
onError: (err: Error) => toast.error(err.message),
});
if (isLoading) return <div style={{ padding: '2rem', color: '#6b7a99' }}>Chargement</div>;
// Group by zone
const zones = new Map<string, ShopItem[]>();
for (const item of (catalogue ?? [])) {
const zone = item.zone ?? 'general';
const list = zones.get(zone) ?? [];
list.push(item);
zones.set(zone, list);
}
// Order: general first, then by zone
const zoneOrder = ['general', 'marais', 'egouts', 'desert'];
const sortedZones = Array.from(zones.entries()).sort((a, b) =>
zoneOrder.indexOf(a[0]) - zoneOrder.indexOf(b[0])
);
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, color: '#f4c94e', fontSize: 20 }}>
<ShoppingBag size={18} style={{ display: 'inline', marginRight: 8 }} />Boutique
</h2>
{char && (
<span style={{ fontSize: 14, fontWeight: 700, color: '#f4c94e', display: 'flex', alignItems: 'center', gap: 6 }}>
<Coins size={14} /> {char.gold} or
</span>
)}
</div>
{buyMut.isSuccess && (
<div className="card card-gold" style={{ marginBottom: '1rem', padding: '0.5rem 1rem', fontSize: 13, textAlign: 'center' }}>
{(buyMut.data as any)?.effectType === 'endurance'
? `${(buyMut.data as any)?.item} — +${(buyMut.data as any)?.effect?.restored} endurance`
: (buyMut.data as any)?.effectType === 'hp'
? `🧪 ${(buyMut.data as any)?.item} — +${(buyMut.data as any)?.effect?.healed} PV`
: `${(buyMut.data as any)?.item} acheté ! (-${(buyMut.data as any)?.goldSpent} or)`
}
</div>
)}
{buyMut.isError && (
<div style={{ marginBottom: '1rem', color: '#e84040', fontSize: 12 }}>{(buyMut.error as Error).message}</div>
)}
{sortedZones.map(([zone, items]) => (
<div key={zone} style={{ marginBottom: '1.5rem' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: 13, fontWeight: 700, color: '#9ca3af' }}>
{zone === 'general' ? '🧪 Consommables' : ZONE_INFO[zone] ? `${ZONE_INFO[zone].emoji} ${ZONE_INFO[zone].name}` : zone}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{items.map(item => (
<ShopItemCard
key={item.id}
item={item}
onBuy={() => buyMut.mutate(item.id)}
buying={buyMut.isPending}
/>
))}
</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

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:4000',
changeOrigin: true,
},
},
},
})

290
package-lock.json generated
View File

@@ -11,7 +11,7 @@
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.0.0", "@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.0.0", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/throttler": "^5.0.0", "@nestjs/throttler": "^5.0.0",
"@nestjs/typeorm": "^10.0.0", "@nestjs/typeorm": "^10.0.0",
@@ -19,7 +19,7 @@
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"helmet": "^7.0.0", "helmet": "^7.0.0",
"pg": "^8.11.0", "mysql2": "^3.20.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"typeorm": "^0.3.20" "typeorm": "^0.3.20"
@@ -1615,17 +1615,17 @@
} }
} }
}, },
"node_modules/@nestjs/jwt": { "node_modules/@nestjs/event-emitter": {
"version": "10.2.0", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-3.0.1.tgz",
"integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", "integrity": "sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/jsonwebtoken": "9.0.5", "eventemitter2": "6.4.9"
"jsonwebtoken": "9.0.2"
}, },
"peerDependencies": { "peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" "@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
} }
}, },
"node_modules/@nestjs/platform-express": { "node_modules/@nestjs/platform-express": {
@@ -2057,15 +2057,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz",
"integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -2564,6 +2555,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -2884,12 +2884,6 @@
"ieee754": "^1.1.13" "ieee754": "^1.1.13"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -3511,6 +3505,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -3601,15 +3604,6 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -3807,6 +3801,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter2": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
"license": "MIT"
},
"node_modules/events": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -4187,6 +4187,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -4744,6 +4753,12 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-stream": { "node_modules/is-stream": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@@ -5741,55 +5756,6 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.2",
"safe-buffer": "^5.0.1"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -5856,42 +5822,6 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -5899,12 +5829,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-symbols": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@@ -5922,12 +5846,33 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.8", "version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
@@ -6174,6 +6119,56 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/mysql2": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz",
"integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.3.3"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -6539,6 +6534,8 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.12.0", "pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0", "pg-pool": "^3.13.0",
@@ -6566,19 +6563,24 @@
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT", "license": "MIT",
"optional": true "optional": true,
"peer": true
}, },
"node_modules/pg-connection-string": { "node_modules/pg-connection-string": {
"version": "2.12.0", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/pg-int8": { "node_modules/pg-int8": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=4.0.0" "node": ">=4.0.0"
} }
@@ -6588,6 +6590,8 @@
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"peerDependencies": { "peerDependencies": {
"pg": ">=8.0" "pg": ">=8.0"
} }
@@ -6596,13 +6600,17 @@
"version": "1.13.0", "version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT" "license": "MIT",
"optional": true,
"peer": true
}, },
"node_modules/pg-types": { "node_modules/pg-types": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"pg-int8": "1.0.1", "pg-int8": "1.0.1",
"postgres-array": "~2.0.0", "postgres-array": "~2.0.0",
@@ -6619,6 +6627,8 @@
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"split2": "^4.1.0" "split2": "^4.1.0"
} }
@@ -6690,6 +6700,8 @@
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=4" "node": ">=4"
} }
@@ -6699,6 +6711,8 @@
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6708,6 +6722,8 @@
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6717,6 +6733,8 @@
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true,
"dependencies": { "dependencies": {
"xtend": "^4.0.0" "xtend": "^4.0.0"
}, },
@@ -7114,6 +7132,7 @@
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -7368,6 +7387,8 @@
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"engines": { "engines": {
"node": ">= 10.x" "node": ">= 10.x"
} }
@@ -7379,6 +7400,21 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/sql-highlight": { "node_modules/sql-highlight": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz",

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",
@@ -19,7 +20,7 @@
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.0.0", "@nestjs/config": "^3.0.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.0.0", "@nestjs/event-emitter": "^3.0.1",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@nestjs/throttler": "^5.0.0", "@nestjs/throttler": "^5.0.0",
"@nestjs/typeorm": "^10.0.0", "@nestjs/typeorm": "^10.0.0",
@@ -27,7 +28,7 @@
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"helmet": "^7.0.0", "helmet": "^7.0.0",
"pg": "^8.11.0", "mysql2": "^3.20.0",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"typeorm": "^0.3.20" "typeorm": "^0.3.20"

View File

@@ -0,0 +1,43 @@
import { Controller, Get, Post, Param, Req, UseGuards } from '@nestjs/common';
import { AchievementService } from './achievement.service';
import { AuthGuard } from '../auth/guards/auth.guard';
import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from '../character/entities/character.entity';
import { BadRequestException } from '@nestjs/common';
@Controller('achievements')
export class AchievementController {
constructor(
private readonly achievementService: AchievementService,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
) {}
@Get()
findAll() {
return this.achievementService.findAll();
}
@Get('me')
@UseGuards(AuthGuard)
async getMyProgress(@Req() req: Request) {
const character = await this.getCharacter(req);
return this.achievementService.getMyProgress(character.id);
}
@Post('claim/:id')
@UseGuards(AuthGuard)
async claim(@Param('id') achievementId: string, @Req() req: Request) {
const character = await this.getCharacter(req);
return this.achievementService.claim(achievementId, character.id);
}
private async getCharacter(req: Request): Promise<Character> {
const user = (req as any).user;
const character = await this.characterRepo.findOne({ where: { userId: user.id } });
if (!character) throw new BadRequestException('Aucun personnage trouvé');
return character;
}
}

View File

@@ -0,0 +1,38 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
} from 'typeorm';
@Entity('achievements')
export class Achievement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 50, unique: true })
key: string;
@Column({ length: 100 })
name: string;
@Column('text')
description: string;
@Column({ length: 20 })
category: string; // 'progression' | 'combat' | 'zones' | 'equipment' | 'economy'
@Column({ length: 10 })
tier: string; // 'bronze' | 'silver' | 'gold'
@Column({ name: 'criteria_type', length: 30 })
criteriaType: string; // 'combat_wins' | 'level_reached' | 'gold_accumulated' | ...
@Column({ name: 'criteria_value' })
criteriaValue: number;
@Column({ name: 'reward_gold', default: 0 })
rewardGold: number;
@Column({ type: 'varchar', name: 'reward_title', length: 100, nullable: true })
rewardTitle: string | null;
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Achievement } from './achievement.entity';
import { PlayerAchievement } from './player-achievement.entity';
import { AchievementService } from './achievement.service';
import { AchievementController } from './achievement.controller';
import { AuthModule } from '../auth/auth.module';
import { Character } from '../character/entities/character.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Achievement, PlayerAchievement, Character]),
AuthModule,
],
controllers: [AchievementController],
providers: [AchievementService],
exports: [AchievementService],
})
export class AchievementModule {}

View File

@@ -0,0 +1,123 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { Achievement } from './achievement.entity';
import { PlayerAchievement } from './player-achievement.entity';
import { Character } from '../character/entities/character.entity';
export interface AchievementCheckEvent {
characterId: string;
type: string; // matches achievement.criteriaType
increment: number;
absolute?: number; // for 'level_reached', 'gold_accumulated' — set progress directly
}
@Injectable()
export class AchievementService {
constructor(
@InjectRepository(Achievement)
private readonly achievementRepo: Repository<Achievement>,
@InjectRepository(PlayerAchievement)
private readonly playerAchievementRepo: Repository<PlayerAchievement>,
@InjectRepository(Character)
private readonly characterRepo: Repository<Character>,
) {}
findAll() {
return this.achievementRepo.find({ order: { category: 'ASC', criteriaValue: 'ASC' } });
}
async getMyProgress(characterId: string) {
const achievements = await this.achievementRepo.find();
const playerAchievements = await this.playerAchievementRepo.find({
where: { characterId },
});
const progressMap = new Map(playerAchievements.map((pa) => [pa.achievementId, pa]));
return achievements.map((a) => {
const pa = progressMap.get(a.id);
return {
...a,
progress: pa?.progress ?? 0,
unlocked: pa?.unlocked ?? false,
unlockedAt: pa?.unlockedAt ?? null,
claimed: pa?.claimed ?? false,
percentage: Math.min(100, Math.floor(((pa?.progress ?? 0) / a.criteriaValue) * 100)),
};
});
}
async claim(achievementId: string, characterId: string) {
const pa = await this.playerAchievementRepo.findOne({
where: { achievementId, characterId },
relations: ['achievement'],
});
if (!pa) throw new NotFoundException('Succès introuvable');
if (!pa.unlocked) throw new BadRequestException('Succès pas encore débloqué');
if (pa.claimed) throw new BadRequestException('Récompense déjà réclamée');
pa.claimed = true;
await this.playerAchievementRepo.save(pa);
// Credit gold reward
const character = await this.characterRepo.findOne({ where: { id: characterId } });
if (character && pa.achievement.rewardGold > 0) {
character.gold += pa.achievement.rewardGold;
await this.characterRepo.save(character);
}
return {
claimed: true,
achievement: pa.achievement.name,
rewardGold: pa.achievement.rewardGold,
rewardTitle: pa.achievement.rewardTitle,
};
}
@OnEvent('achievement.check')
async handleAchievementCheck(event: AchievementCheckEvent) {
const { characterId, type, increment, absolute } = event;
// Find all achievements matching this criteria type
const achievements = await this.achievementRepo.find({
where: { criteriaType: type },
});
if (!achievements.length) return;
for (const achievement of achievements) {
// Get or create player progress
let pa = await this.playerAchievementRepo.findOne({
where: { characterId, achievementId: achievement.id },
});
if (!pa) {
pa = this.playerAchievementRepo.create({
characterId,
achievementId: achievement.id,
progress: 0,
unlocked: false,
claimed: false,
});
}
if (pa.unlocked) continue; // already unlocked, skip
// Update progress
if (absolute !== undefined) {
pa.progress = Math.max(pa.progress, absolute);
} else {
pa.progress += increment;
}
// Check unlock
if (pa.progress >= achievement.criteriaValue) {
pa.unlocked = true;
pa.unlockedAt = new Date();
}
await this.playerAchievementRepo.save(pa);
}
}
}

View File

@@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
Unique,
Index,
} from 'typeorm';
import { Character } from '../character/entities/character.entity';
import { Achievement } from './achievement.entity';
@Entity('player_achievements')
@Unique(['characterId', 'achievementId'])
export class PlayerAchievement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'character_id' })
@Index()
characterId: string;
@ManyToOne(() => Character)
@JoinColumn({ name: 'character_id' })
character: Character;
@Column({ name: 'achievement_id' })
@Index()
achievementId: string;
@ManyToOne(() => Achievement)
@JoinColumn({ name: 'achievement_id' })
achievement: Achievement;
@Column({ default: 0 })
progress: number;
@Column({ default: false })
unlocked: boolean;
@Column({ name: 'unlocked_at', type: 'timestamp', nullable: true })
unlockedAt: Date | null;
@Column({ default: false })
claimed: boolean;
}

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CharacterModule } from './character/character.module'; import { CharacterModule } from './character/character.module';
import { MonsterModule } from './monster/monster.module'; import { MonsterModule } from './monster/monster.module';
@@ -12,20 +13,28 @@ import { CraftModule } from './craft/craft.module';
import { ForgeModule } from './forge/forge.module'; import { ForgeModule } from './forge/forge.module';
import { EconomyModule } from './economy/economy.module'; import { EconomyModule } from './economy/economy.module';
import { TwitchModule } from './twitch/twitch.module'; import { TwitchModule } from './twitch/twitch.module';
import { AchievementModule } from './achievement/achievement.module';
import { CommunityModule } from './community/community.module';
import { HallOfFameModule } from './halloffame/halloffame.module';
import { ProfileModule } from './profile/profile.module';
import { QuestModule } from './quest/quest.module';
import { ShopModule } from './shop/shop.module';
import { NpcModule } from './npc/npc.module';
import { HealthController } from './common/health.controller'; import { HealthController } from './common/health.controller';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
type: 'postgres', type: 'mysql',
url: config.get<string>('DATABASE_URL'), url: config.get<string>('DATABASE_URL'),
autoLoadEntities: true, autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production', synchronize: config.get('DB_SYNC') === 'true' || config.get('NODE_ENV') !== 'production',
logging: config.get('NODE_ENV') === 'development', logging: config.get('NODE_ENV') === 'development',
}), }),
}), }),
@@ -47,6 +56,13 @@ import { HealthController } from './common/health.controller';
ForgeModule, ForgeModule,
EconomyModule, EconomyModule,
TwitchModule, TwitchModule,
AchievementModule,
CommunityModule,
HallOfFameModule,
ProfileModule,
QuestModule,
ShopModule,
NpcModule,
], ],
controllers: [HealthController], controllers: [HealthController],
}) })

View File

@@ -4,10 +4,11 @@ import {
Get, Get,
Body, Body,
Res, Res,
Req,
UseGuards, UseGuards,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Req, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import { Response, Request } from 'express'; import { Response, Request } from 'express';
@@ -30,6 +31,20 @@ export class AuthController {
return this.authService.setSession(dto, res); return this.authService.setSession(dto, res);
} }
@Post('refresh')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { ttl: 60_000, limit: 10 } })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const refreshToken = (req.signedCookies as Record<string, string>)?.refresh_token;
if (!refreshToken) {
throw new UnauthorizedException('Pas de refresh token');
}
return this.authService.refreshSession(res, refreshToken);
}
@Get('me') @Get('me')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
async getMe(@Req() req: Request & { user: User }) { async getMe(@Req() req: Request & { user: User }) {

View File

@@ -1,23 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthGuard } from './guards/auth.guard'; import { AuthGuard } from './guards/auth.guard';
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([User])],
TypeOrmModule.forFeature([User]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('SUPER_OAUTH_JWT_SECRET'),
}),
}),
],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, AuthGuard], providers: [AuthService, AuthGuard],
exports: [AuthGuard, TypeOrmModule], exports: [AuthGuard, TypeOrmModule],

View File

@@ -1,9 +1,7 @@
import { import {
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@@ -11,62 +9,54 @@ import { Response } from 'express';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
import { SetSessionDto } from './dto/set-session.dto'; import { SetSessionDto } from './dto/set-session.dto';
// Payload émis par SuperOAuth interface SuperOAuthUser {
interface SuperOAuthPayload { id: string;
sub: string; // ID provider (Twitch ID, Discord ID…) tenantId: string;
provider: string; // 'twitch' | 'discord' | 'google' | 'github' email: string | null;
username: string; nickname: string;
avatar_url?: string; isActive: boolean;
iat: number; linkedProviders: string[];
exp: number;
} }
const COOKIE_NAME = 'session';
const REFRESH_COOKIE_NAME = 'refresh_token';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly superOauthUrl: string;
constructor( constructor(
@InjectRepository(User) @InjectRepository(User)
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {
this.superOauthUrl = this.configService.getOrThrow<string>('SUPER_OAUTH_URL');
}
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'oauthId'>> { async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'superOauthId'>> {
let payload: SuperOAuthPayload; const oauthUser = await this.introspectToken(dto.token);
try {
payload = await this.jwtService.verifyAsync<SuperOAuthPayload>(dto.jwt, {
secret: this.configService.get<string>('SUPER_OAUTH_JWT_SECRET'),
});
} catch {
throw new UnauthorizedException('JWT SuperOAuth invalide ou expiré');
}
if (!payload.sub || !payload.provider || !payload.username) {
throw new UnauthorizedException('Payload JWT incomplet');
}
// Upsert user // Upsert user
let user = await this.userRepository.findOne({ let user = await this.userRepository.findOne({
where: { oauthId: payload.sub, provider: payload.provider }, where: { superOauthId: oauthUser.id },
}); });
if (!user) { if (!user) {
user = this.userRepository.create({ user = this.userRepository.create({
oauthId: payload.sub, superOauthId: oauthUser.id,
provider: payload.provider, username: oauthUser.nickname,
username: payload.username, email: oauthUser.email,
avatarUrl: payload.avatar_url ?? null,
}); });
} else { } else {
user.username = payload.username; user.username = oauthUser.nickname;
user.avatarUrl = payload.avatar_url ?? null; user.email = oauthUser.email;
} }
await this.userRepository.save(user); await this.userRepository.save(user);
// Cookie httpOnly signé — valeur = UUID interne // Cookie httpOnly — session = UUID interne
const isProduction = this.configService.get('NODE_ENV') === 'production'; const isProduction = this.configService.get('NODE_ENV') === 'production';
res.cookie('session', user.id, { res.cookie(COOKIE_NAME, user.id, {
httpOnly: true, httpOnly: true,
signed: true, signed: true,
secure: isProduction, secure: isProduction,
@@ -74,16 +64,105 @@ export class AuthService {
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
}); });
const { oauthId: _, ...safeUser } = user; // Refresh token cookie si fourni
if (dto.refreshToken) {
res.cookie(REFRESH_COOKIE_NAME, dto.refreshToken, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 jours
});
}
const { superOauthId: _, ...safeUser } = user;
return safeUser; return safeUser;
} }
async getMe(user: User): Promise<Omit<User, 'oauthId'>> { async refreshSession(res: Response, refreshToken: string): Promise<{ success: boolean }> {
const { oauthId: _, ...safeUser } = user; // Exchange refresh token for new access token via SuperOAuth
const response = await fetch(`${this.superOauthUrl}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}).toString(),
});
if (!response.ok) {
throw new UnauthorizedException('Refresh token invalide ou expiré');
}
const data = await response.json();
if (!data.access_token) {
throw new UnauthorizedException('Refresh échoué — pas de token');
}
// Validate the new access token to get user data
const oauthUser = await this.introspectToken(data.access_token);
const user = await this.userRepository.findOne({
where: { superOauthId: oauthUser.id },
});
if (!user) {
throw new UnauthorizedException('Utilisateur introuvable après refresh');
}
// Set new cookies
const isProduction = this.configService.get('NODE_ENV') === 'production';
res.cookie(COOKIE_NAME, user.id, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
if (data.refresh_token) {
res.cookie(REFRESH_COOKIE_NAME, data.refresh_token, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
}
return { success: true };
}
async getMe(user: User): Promise<Omit<User, 'superOauthId'>> {
const { superOauthId: _, ...safeUser } = user;
return safeUser; return safeUser;
} }
logout(res: Response): void { logout(res: Response): void {
res.clearCookie('session'); res.clearCookie(COOKIE_NAME);
res.clearCookie(REFRESH_COOKIE_NAME);
}
private async introspectToken(token: string): Promise<SuperOAuthUser> {
const response = await fetch(`${this.superOauthUrl}/api/v1/auth/token/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Token SuperOAuth invalide');
}
const data = await response.json();
if (!data.data?.valid || !data.data.user) {
throw new UnauthorizedException('Token SuperOAuth invalide ou expiré');
}
if (!data.data.user.isActive) {
throw new UnauthorizedException('Compte SuperOAuth désactivé');
}
return data.data.user as SuperOAuthUser;
} }
} }

View File

@@ -1,7 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class SetSessionDto { export class SetSessionDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
jwt: string; token: string;
@IsString()
@IsOptional()
refreshToken?: string;
} }

View File

@@ -11,6 +11,7 @@ import {
import { Request } from 'express'; import { Request } from 'express';
import { CharacterService } from './character.service'; import { CharacterService } from './character.service';
import { CreateCharacterDto } from './dto/create-character.dto'; import { CreateCharacterDto } from './dto/create-character.dto';
import { DistributeStatsDto } from './dto/distribute-stats.dto';
import { AuthGuard } from '../auth/guards/auth.guard'; import { AuthGuard } from '../auth/guards/auth.guard';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
@@ -37,4 +38,19 @@ export class CharacterController {
getEndurance(@Req() req: Request & { user: User }) { getEndurance(@Req() req: Request & { user: User }) {
return this.characterService.getEndurance(req.user); return this.characterService.getEndurance(req.user);
} }
@Post('stats')
@HttpCode(HttpStatus.OK)
distributeStats(
@Body() dto: DistributeStatsDto,
@Req() req: Request & { user: User },
) {
return this.characterService.distributeStats(dto, req.user);
}
@Post('rest')
@HttpCode(HttpStatus.OK)
rest(@Req() req: Request & { user: User }) {
return this.characterService.rest(req.user);
}
} }

View File

@@ -5,14 +5,20 @@ import {
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { Character } from './entities/character.entity'; import { Character } from './entities/character.entity';
import { LevelThreshold } from './entities/level-threshold.entity'; import { LevelThreshold } from './entities/level-threshold.entity';
import { CreateCharacterDto } from './dto/create-character.dto'; import { CreateCharacterDto } from './dto/create-character.dto';
import { DistributeStatsDto } from './dto/distribute-stats.dto';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
import { Item } from '../item/item.entity';
import { CharacterItem } from '../item/character-item.entity';
import { xpRequiredForLevel } from '../combat/combat.engine';
const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer
const ENDURANCE_REGEN_MINUTES = 6; // 1 pt d'endurance toutes les 6 min = 10 pts/heure const ENDURANCE_REGEN_MINUTES = 3; // 1 pt d'endurance toutes les 3 min = 20 pts/heure
const REST_ENDURANCE_COST = 10;
const REST_HP_REGEN_RATIO = 0.5; // +50% hpMax
@Injectable() @Injectable()
export class CharacterService { export class CharacterService {
@@ -21,6 +27,7 @@ export class CharacterService {
private readonly characterRepository: Repository<Character>, private readonly characterRepository: Repository<Character>,
@InjectRepository(LevelThreshold) @InjectRepository(LevelThreshold)
private readonly levelThresholdRepository: Repository<LevelThreshold>, private readonly levelThresholdRepository: Repository<LevelThreshold>,
private readonly dataSource: DataSource,
) {} ) {}
// Pattern lazy calculation — pas de timer actif // Pattern lazy calculation — pas de timer actif
@@ -31,7 +38,7 @@ export class CharacterService {
return Math.min(character.enduranceSaved + recharge, character.enduranceMax); return Math.min(character.enduranceSaved + recharge, character.enduranceMax);
} }
async create(dto: CreateCharacterDto, user: User): Promise<Character & { enduranceCurrent: number }> { async create(dto: CreateCharacterDto, user: User) {
const totalStats = const totalStats =
dto.force + dto.agilite + dto.intelligence + dto.chance + dto.vitalite; dto.force + dto.agilite + dto.intelligence + dto.chance + dto.vitalite;
@@ -48,6 +55,7 @@ export class CharacterService {
throw new ConflictException('Ce joueur possède déjà un personnage'); throw new ConflictException('Ce joueur possède déjà un personnage');
} }
const baseHp = 100 + (dto.vitalite - 1) * 10; // vitalité 1 = 100 HP, chaque point = +10
const character = this.characterRepository.create({ const character = this.characterRepository.create({
userId: user.id, userId: user.id,
name: dto.name, name: dto.name,
@@ -56,16 +64,37 @@ export class CharacterService {
intelligence: dto.intelligence, intelligence: dto.intelligence,
chance: dto.chance, chance: dto.chance,
vitalite: dto.vitalite, vitalite: dto.vitalite,
hpMax: baseHp,
hpCurrent: baseHp,
enduranceSaved: 100, enduranceSaved: 100,
lastEnduranceTs: new Date(), lastEnduranceTs: new Date(),
enduranceMax: 100, enduranceMax: 100,
}); });
const saved = await this.characterRepository.save(character); const saved = await this.characterRepository.save(character);
return { ...saved, enduranceCurrent: this.calculateEndurance(saved) };
// Arme de départ — Bâton de Roseau équipé automatiquement
const starterWeapon = await this.dataSource.getRepository(Item)
.findOne({ where: { name: 'Bâton de Roseau' } });
if (starterWeapon) {
await this.dataSource.getRepository(CharacterItem).save(
this.dataSource.getRepository(CharacterItem).create({
characterId: saved.id,
itemId: starterWeapon.id,
forgeLevel: 0,
equipped: true,
}),
);
}
return {
...saved,
enduranceCurrent: this.calculateEndurance(saved),
xpToNextLevel: xpRequiredForLevel(saved.level),
};
} }
async findByUser(user: User): Promise<Character & { enduranceCurrent: number }> { async findByUser(user: User) {
const character = await this.characterRepository.findOne({ const character = await this.characterRepository.findOne({
where: { userId: user.id }, where: { userId: user.id },
}); });
@@ -74,7 +103,11 @@ export class CharacterService {
throw new NotFoundException('Aucun personnage trouvé pour ce joueur'); throw new NotFoundException('Aucun personnage trouvé pour ce joueur');
} }
return { ...character, enduranceCurrent: this.calculateEndurance(character) }; return {
...character,
enduranceCurrent: this.calculateEndurance(character),
xpToNextLevel: xpRequiredForLevel(character.level),
};
} }
async getEndurance( async getEndurance(
@@ -94,4 +127,106 @@ export class CharacterService {
rechargeRatePerHour: 60 / ENDURANCE_REGEN_MINUTES, rechargeRatePerHour: 60 / ENDURANCE_REGEN_MINUTES,
}; };
} }
async distributeStats(dto: DistributeStatsDto, user: User) {
return this.dataSource.transaction(async (manager) => {
const character = await manager
.getRepository(Character)
.createQueryBuilder('c')
.setLock('pessimistic_write')
.where('c.user_id = :userId', { userId: user.id })
.getOne();
if (!character) throw new NotFoundException('Aucun personnage trouvé');
const totalToDistribute =
(dto.force ?? 0) + (dto.agilite ?? 0) + (dto.intelligence ?? 0) +
(dto.chance ?? 0) + (dto.vitalite ?? 0);
if (totalToDistribute <= 0) {
throw new BadRequestException('Aucun point à distribuer');
}
if (totalToDistribute > (character.statPoints ?? 0)) {
throw new BadRequestException(
`Points insuffisants (${character.statPoints ?? 0} disponibles, ${totalToDistribute} demandés)`,
);
}
character.force += dto.force ?? 0;
character.agilite += dto.agilite ?? 0;
character.intelligence += dto.intelligence ?? 0;
character.chance += dto.chance ?? 0;
character.vitalite += dto.vitalite ?? 0;
character.statPoints = (character.statPoints ?? 0) - totalToDistribute;
// Vitalité augmente HP max (+10 par point)
const vitaliteAdded = dto.vitalite ?? 0;
if (vitaliteAdded > 0) {
character.hpMax += vitaliteAdded * 10;
character.hpCurrent += vitaliteAdded * 10; // bonus immédiat
}
await manager.save(character);
return {
statPoints: character.statPoints,
stats: {
force: character.force,
agilite: character.agilite,
intelligence: character.intelligence,
chance: character.chance,
vitalite: character.vitalite,
},
hpMax: character.hpMax,
};
});
}
async rest(user: User) {
return this.dataSource.transaction(async (manager) => {
const character = await manager
.getRepository(Character)
.createQueryBuilder('c')
.setLock('pessimistic_write')
.where('c.user_id = :userId', { userId: user.id })
.getOne();
if (!character) throw new NotFoundException('Aucun personnage trouvé');
if (character.hpCurrent >= character.hpMax) {
throw new BadRequestException('PV déjà au maximum');
}
// Calculer endurance
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
const recharge = Math.floor(elapsedMinutes / ENDURANCE_REGEN_MINUTES);
const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
if (enduranceCurrent < REST_ENDURANCE_COST) {
throw new BadRequestException(
`Endurance insuffisante (${enduranceCurrent}/${REST_ENDURANCE_COST} requis)`,
);
}
const hpBefore = character.hpCurrent;
character.hpCurrent = Math.min(
character.hpMax,
character.hpCurrent + Math.floor(character.hpMax * REST_HP_REGEN_RATIO),
);
character.enduranceSaved = enduranceCurrent - REST_ENDURANCE_COST;
character.lastEnduranceTs = new Date();
await manager.save(character);
return {
hpBefore,
hpAfter: character.hpCurrent,
hpMax: character.hpMax,
healed: character.hpCurrent - hpBefore,
enduranceCurrent: character.enduranceSaved,
enduranceMax: character.enduranceMax,
};
});
}
} }

View File

@@ -0,0 +1,18 @@
import { IsInt, Min, IsOptional } from 'class-validator';
export class DistributeStatsDto {
@IsInt() @Min(0) @IsOptional()
force?: number = 0;
@IsInt() @Min(0) @IsOptional()
agilite?: number = 0;
@IsInt() @Min(0) @IsOptional()
intelligence?: number = 0;
@IsInt() @Min(0) @IsOptional()
chance?: number = 0;
@IsInt() @Min(0) @IsOptional()
vitalite?: number = 0;
}

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;
@@ -71,6 +78,13 @@ export class Character {
@Column({ name: 'stat_points', default: 0 }) @Column({ name: 'stat_points', default: 0 })
statPoints: number; statPoints: number;
// Sprint 4 — Profil enrichi
@Column({ type: 'varchar', name: 'active_title', length: 100, nullable: true })
activeTitle: string | null;
@Column({ name: 'total_gold_earned', type: 'bigint', default: 0 })
totalGoldEarned: number;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@@ -5,6 +5,7 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
CreateDateColumn, CreateDateColumn,
Index,
} from 'typeorm'; } from 'typeorm';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { Monster } from '../monster/monster.entity'; import { Monster } from '../monster/monster.entity';
@@ -15,6 +16,7 @@ export class CombatLog {
id: string; id: string;
@Column({ name: 'character_id' }) @Column({ name: 'character_id' })
@Index()
characterId: string; characterId: string;
@ManyToOne(() => Character) @ManyToOne(() => Character)
@@ -35,7 +37,7 @@ export class CombatLog {
totalRounds: number; totalRounds: number;
// Stocker les rounds en JSON — lecture replay // Stocker les rounds en JSON — lecture replay
@Column({ name: 'rounds_data', type: 'jsonb' }) @Column({ name: 'rounds_data', type: 'json' })
roundsData: object; roundsData: object;
@Column({ name: 'xp_earned', default: 0 }) @Column({ name: 'xp_earned', default: 0 })
@@ -47,6 +49,12 @@ export class CombatLog {
@Column({ name: 'level_up', default: false }) @Column({ name: 'level_up', default: false })
levelUp: boolean; levelUp: boolean;
@Column({ name: 'loot_material_id', type: 'varchar', nullable: true })
lootMaterialId: string | null;
@Column({ name: 'loot_quantity', default: 0 })
lootQuantity: number;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;
} }

View File

@@ -16,6 +16,10 @@ export class CombatController {
@Body() dto: StartCombatDto, @Body() dto: StartCombatDto,
@Req() req: Request & { user: User }, @Req() req: Request & { user: User },
) { ) {
const count = dto.count ?? 1;
if (count > 1) {
return this.combatService.startMultiCombat(dto, req.user, count);
}
return this.combatService.startCombat(dto, req.user); return this.combatService.startCombat(dto, req.user);
} }

View File

@@ -53,7 +53,7 @@ function statForAttackType(stats: CombatantStats): number {
export function calcPlayerDamage(player: CombatantStats, monsterDefense: number): number { export function calcPlayerDamage(player: CombatantStats, monsterDefense: number): number {
const stat = statForAttackType(player); const stat = statForAttackType(player);
const raw = player.attack + Math.floor(stat * 1.5); const raw = 3 + player.attack + Math.floor(stat * 1.5); // +3 base damage
return Math.max(1, raw - monsterDefense); return Math.max(1, raw - monsterDefense);
} }
@@ -181,9 +181,10 @@ export function applyXpGain(currentLevel: number, currentXp: number, xpEarned: n
let xp = currentXp + xpEarned; let xp = currentXp + xpEarned;
let statPointsGained = 0; let statPointsGained = 0;
// Chaîne de level up // Chaîne de level up — seuil basé sur le niveau actuel
// Level 1→2 = 100 XP, Level 2→3 = 283 XP, Level 10→11 = 3162 XP
while (level < 100) { while (level < 100) {
const required = xpRequiredForLevel(level + 1); const required = xpRequiredForLevel(level);
if (xp >= required) { if (xp >= required) {
xp -= required; xp -= required;
level++; level++;

View File

@@ -8,16 +8,24 @@ import { MonsterModule } from '../monster/monster.module';
import { AuthModule } from '../auth/auth.module'; 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 { 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,
], ],
controllers: [CombatController], controllers: [CombatController, TurnCombatController],
providers: [CombatService], providers: [CombatService, SpellSystem, TurnCombatService],
}) })
export class CombatModule {} export class CombatModule {}

View File

@@ -1,6 +1,8 @@
import { Injectable, BadRequestException } from '@nestjs/common'; import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { CharacterMaterial } from '../material/character-material.entity';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Character } from '../character/entities/character.entity'; import { Character } from '../character/entities/character.entity';
import { Monster } from '../monster/monster.entity'; import { Monster } from '../monster/monster.entity';
import { MonsterService } from '../monster/monster.service'; import { MonsterService } from '../monster/monster.service';
@@ -9,20 +11,69 @@ import { StartCombatDto } from './dto/start-combat.dto';
import { User } from '../user/user.entity'; import { User } from '../user/user.entity';
import { ItemService } from '../item/item.service'; import { ItemService } from '../item/item.service';
import { MaterialService } from '../material/material.service'; import { MaterialService } from '../material/material.service';
import { CommunityService } from '../community/community.service';
import { import {
resolveCombat, resolveCombat,
applyXpGain, applyXpGain,
xpRequiredForLevel,
CombatantStats, CombatantStats,
} from './combat.engine'; } from './combat.engine';
const COMBAT_ENDURANCE_COST = 10; const COMBAT_ENDURANCE_COST = 5;
const DEFEAT_ENDURANCE_PENALTY = 50;
/**
* Drop rate variable basé sur la difficulté relative monstre vs joueur.
* Monstre facile = moins de drop, monstre difficile = plus de drop + quantité.
* Boss de zone (maxLevel ≥ 9 et spread ≥ 3) = 80% + 2-3 drops.
*/
function computeDropRate(
playerLevel: number,
monsterMinLevel: number,
monsterMaxLevel: number,
): { dropRate: number; dropQty: number } {
const monsterAvgLevel = (monsterMinLevel + monsterMaxLevel) / 2;
const diff = monsterAvgLevel - playerLevel;
const isBoss = (monsterMaxLevel - monsterMinLevel) >= 3 && monsterMaxLevel >= 9;
if (isBoss) {
return { dropRate: 0.80, dropQty: 2 + (Math.random() < 0.5 ? 1 : 0) }; // 2-3
}
if (diff >= 2) {
return { dropRate: 0.60, dropQty: 1 + (Math.random() < 0.3 ? 1 : 0) }; // 1-2
}
if (diff >= 0) {
return { dropRate: 0.50, dropQty: 1 + (Math.random() < 0.2 ? 1 : 0) }; // 1-2
}
if (diff >= -2) {
return { dropRate: 0.40, dropQty: 1 };
}
// Très facile (level >> monstre)
return { dropRate: 0.25, dropQty: 1 };
}
const DEFEAT_ENDURANCE_PENALTY = 25;
const DEFEAT_HP_RATIO = 0.2; // 20% hpMax à la défaite const DEFEAT_HP_RATIO = 0.2; // 20% hpMax à la défaite
const VICTORY_HP_REGEN_RATIO = 0.1; // +10% hpMax à la victoire const VICTORY_HP_REGEN_RATIO = 0.1; // +10% hpMax à la victoire
const DEFEAT_GOLD_LOSS_RATIO = 0.05; // perte 5% or à la défaite const DEFEAT_GOLD_LOSS_RATIO = 0.05; // perte 5% or à la défaite
/** Ajouter un matériau dans la transaction courante (pas de connexion séparée). */
async function addMaterialInTx(manager: EntityManager, characterId: string, materialId: string, quantity: number) {
const repo = manager.getRepository(CharacterMaterial);
let entry = await repo.findOne({ where: { characterId, materialId } });
if (entry) {
entry.quantity += quantity;
} else {
entry = repo.create({ characterId, materialId, quantity });
}
await repo.save(entry);
}
const COOLDOWN_SINGLE_MS = 2_000;
const COOLDOWN_MULTI_MS = 8_000;
@Injectable() @Injectable()
export class CombatService { export class CombatService {
private readonly cooldowns = new Map<string, number>(); // userId → timestamp
constructor( constructor(
@InjectRepository(Character) @InjectRepository(Character)
private readonly characterRepository: Repository<Character>, private readonly characterRepository: Repository<Character>,
@@ -31,167 +82,403 @@ export class CombatService {
private readonly monsterService: MonsterService, private readonly monsterService: MonsterService,
private readonly itemService: ItemService, private readonly itemService: ItemService,
private readonly materialService: MaterialService, private readonly materialService: MaterialService,
private readonly communityService: CommunityService,
private readonly eventEmitter: EventEmitter2,
private readonly dataSource: DataSource,
) {} ) {}
async startCombat(dto: StartCombatDto, user: User) { private checkCooldown(userId: string): void {
// Charger le personnage const lastCombat = this.cooldowns.get(userId) ?? 0;
const character = await this.characterRepository.findOne({ const remaining = lastCombat - Date.now();
where: { userId: user.id }, if (remaining > 0) {
});
if (!character) throw new BadRequestException('Aucun personnage trouvé');
// Charger le monstre
const monster = await this.monsterService.findOne(dto.monsterId);
// Calculer l'endurance actuelle (lazy pattern)
const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
const recharge = Math.floor(elapsedMinutes / 6);
const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
if (enduranceCurrent < COMBAT_ENDURANCE_COST) {
throw new BadRequestException( throw new BadRequestException(
`Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST} requis)`, `Cooldown actif — attendez ${Math.ceil(remaining / 1000)}s`,
); );
} }
}
if (character.hpCurrent <= 0) { private setCooldown(userId: string, durationMs: number): void {
throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV'); this.cooldowns.set(userId, Date.now() + durationMs);
} }
// Charger l'équipement actif du personnage async startCombat(dto: StartCombatDto, user: User) {
const equipped = await this.itemService.getEquippedItems(character.id); this.checkCooldown(user.id);
const FORGE_BONUS_PER_LEVEL = 2;
const weaponAttack = equipped.weapon
? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FORGE_BONUS_PER_LEVEL
: 0;
const armorDefense = equipped.armor
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
: 0;
// Construire les stats des combattants // Charger le monstre (hors transaction — lecture seule)
const playerStats: CombatantStats = { const monster = await this.monsterService.findOne(dto.monsterId);
name: character.name,
hpCurrent: character.hpCurrent,
hpMax: character.hpMax,
force: character.force,
agilite: character.agilite,
intelligence: character.intelligence,
chance: character.chance,
attack: weaponAttack,
defense: armorDefense,
attackType: dto.attackType,
};
const monsterStats: CombatantStats = { // Transaction isolée — empêche les combats simultanés sur le même perso
name: monster.name, const txResult = await this.dataSource.transaction(async (manager) => {
hpCurrent: monster.hp, // SELECT ... FOR UPDATE — verrouille le personnage
hpMax: monster.hp, const character = await manager
force: 0, .getRepository(Character)
agilite: 0, .createQueryBuilder('c')
intelligence: 0, .setLock('pessimistic_write')
chance: 0, // pas de crit/esquive pour les monstres Sprint 2 .where('c.user_id = :userId', { userId: user.id })
attack: monster.attack, .getOne();
defense: monster.defense,
attackType: monster.attackType,
};
// Résolution combat if (!character) throw new BadRequestException('Aucun personnage trouvé');
const result = resolveCombat(
playerStats,
monsterStats,
monster.xpReward,
monster.goldMin,
monster.goldMax,
);
// Appliquer les effets post-combat sur le personnage // Calculer l'endurance actuelle (lazy pattern)
let newHp = character.hpCurrent; const elapsedMinutes = (Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
let newEnduranceSaved = enduranceCurrent - COMBAT_ENDURANCE_COST; const recharge = Math.floor(elapsedMinutes / 3);
let goldLost = 0; const enduranceCurrent = Math.min(character.enduranceSaved + recharge, character.enduranceMax);
let levelUpData = { levelsGained: 0, statPointsGained: 0, newLevel: character.level, newXp: character.xp };
if (result.winner === 'player') { if (enduranceCurrent < COMBAT_ENDURANCE_COST) {
// Victoire : XP + Or + récup 10% PV throw new BadRequestException(
levelUpData = applyXpGain(character.level, character.xp, result.xpEarned); `Endurance insuffisante (${enduranceCurrent}/${COMBAT_ENDURANCE_COST} requis)`,
character.xp = levelUpData.newXp; );
character.level = levelUpData.newLevel;
character.statPoints = (character.statPoints ?? 0) + levelUpData.statPointsGained;
character.gold += result.goldEarned;
newHp = Math.min(character.hpMax, character.hpCurrent + Math.floor(character.hpMax * VICTORY_HP_REGEN_RATIO));
} else {
// Défaite : retour auberge + pénalités
newEnduranceSaved = Math.max(0, newEnduranceSaved - DEFEAT_ENDURANCE_PENALTY);
newHp = Math.max(1, Math.floor(character.hpMax * DEFEAT_HP_RATIO));
goldLost = Math.floor(character.gold * DEFEAT_GOLD_LOSS_RATIO);
character.gold = Math.max(0, character.gold - goldLost);
}
// Sauvegarder l'endurance (lazy reset)
character.hpCurrent = newHp;
character.enduranceSaved = newEnduranceSaved;
character.lastEnduranceTs = new Date();
await this.characterRepository.save(character);
// Loot matériaux — 40% de chance après victoire si le monstre a un drop_material_id
let lootMaterial: { name: string; quantity: number } | null = null;
if (result.winner === 'player' && monster.dropMaterialId && Math.random() < 0.4) {
await this.materialService.addMaterial(character.id, monster.dropMaterialId, 1);
lootMaterial = { name: 'matériau', quantity: 1 };
}
// Persister le log
const combatLog = this.combatLogRepository.create({
characterId: character.id,
monsterId: monster.id,
winner: result.winner,
totalRounds: result.totalRounds,
roundsData: result.rounds,
xpEarned: result.xpEarned,
goldEarned: result.goldEarned,
levelUp: levelUpData.levelsGained > 0,
});
await this.combatLogRepository.save(combatLog);
// Construire la réponse
const summaryParts: string[] = [];
if (result.winner === 'player') {
summaryParts.push(`Victoire en ${result.totalRounds} tours !`);
summaryParts.push(`+${result.xpEarned} XP, +${result.goldEarned} Or.`);
if (levelUpData.levelsGained > 0) {
summaryParts.push(`LEVEL UP ! Niveau ${levelUpData.newLevel} atteint. +${levelUpData.statPointsGained} points de stats.`);
} }
} else {
summaryParts.push(`Défaite au tour ${result.totalRounds}. Retour à l'auberge.`);
if (goldLost > 0) summaryParts.push(`${goldLost} Or perdu.`);
}
if (lootMaterial) { if (character.hpCurrent <= 0) {
summaryParts.push(`Loot : 1 matériau obtenu !`); throw new BadRequestException('Votre personnage est KO — récupérez d\'abord vos PV');
} }
return { // Charger l'équipement actif du personnage
winner: result.winner, const equipped = await this.itemService.getEquippedItems(character.id);
rounds: result.rounds, const FORGE_BONUS_PER_LEVEL = 2;
summary: summaryParts.join(' '), const weaponAttack = equipped.weapon
rewards: { ? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FORGE_BONUS_PER_LEVEL
xp: result.xpEarned, : 0;
gold: result.goldEarned, const armorDefense = equipped.armor
goldLost, ? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
levelUp: levelUpData.levelsGained > 0, : 0;
newLevel: levelUpData.newLevel,
statPointsGained: levelUpData.statPointsGained, // Item stat bonuses
loot: lootMaterial, const itemForceBonus = (equipped.weapon?.item.forceBonus ?? 0) + (equipped.armor?.item.forceBonus ?? 0);
}, const itemAgiliteBonus = (equipped.weapon?.item.agiliteBonus ?? 0) + (equipped.armor?.item.agiliteBonus ?? 0);
character: { const itemIntelligenceBonus = (equipped.weapon?.item.intelligenceBonus ?? 0) + (equipped.armor?.item.intelligenceBonus ?? 0);
level: character.level, const itemChanceBonus = (equipped.weapon?.item.chanceBonus ?? 0) + (equipped.armor?.item.chanceBonus ?? 0);
xp: character.xp,
gold: character.gold, // Construire les stats des combattants
const playerStats: CombatantStats = {
name: character.name,
hpCurrent: character.hpCurrent, hpCurrent: character.hpCurrent,
hpMax: character.hpMax, hpMax: character.hpMax,
enduranceCurrent: character.enduranceSaved, // déjà le nouveau enduranceSaved post-combat force: character.force + itemForceBonus,
enduranceMax: character.enduranceMax, agilite: character.agilite + itemAgiliteBonus,
statPoints: character.statPoints ?? 0, intelligence: character.intelligence + itemIntelligenceBonus,
}, chance: character.chance + itemChanceBonus,
attack: weaponAttack,
defense: armorDefense,
attackType: dto.attackType,
};
const monsterStats: CombatantStats = {
name: monster.name,
hpCurrent: monster.hp,
hpMax: monster.hp,
force: 0,
agilite: 0,
intelligence: 0,
chance: 0,
attack: monster.attack,
defense: monster.defense,
attackType: monster.attackType,
};
// Résolution combat
const result = resolveCombat(
playerStats,
monsterStats,
monster.xpReward,
monster.goldMin,
monster.goldMax,
);
// Appliquer les effets post-combat sur le personnage
let newHp = character.hpCurrent;
let newEnduranceSaved = enduranceCurrent - COMBAT_ENDURANCE_COST;
let goldLost = 0;
let levelUpData = { levelsGained: 0, statPointsGained: 0, newLevel: character.level, newXp: character.xp };
if (result.winner === 'player') {
// Victoire : XP + Or + récup 10% PV
levelUpData = applyXpGain(character.level, character.xp, result.xpEarned);
character.xp = levelUpData.newXp;
character.level = levelUpData.newLevel;
character.statPoints = (character.statPoints ?? 0) + levelUpData.statPointsGained;
character.gold += result.goldEarned;
character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + result.goldEarned;
newHp = Math.min(character.hpMax, character.hpCurrent + Math.floor(character.hpMax * VICTORY_HP_REGEN_RATIO));
} else {
// Défaite : retour auberge + pénalités
newEnduranceSaved = Math.max(0, newEnduranceSaved - DEFEAT_ENDURANCE_PENALTY);
newHp = Math.max(1, Math.floor(character.hpMax * DEFEAT_HP_RATIO));
goldLost = Math.floor(character.gold * DEFEAT_GOLD_LOSS_RATIO);
character.gold = Math.max(0, character.gold - goldLost);
}
// Sauvegarder le personnage (dans la transaction)
character.hpCurrent = newHp;
character.enduranceSaved = newEnduranceSaved;
character.lastEnduranceTs = new Date();
await manager.save(character);
// Apply XP boost from community (dans la transaction)
if (result.winner === 'player') {
const xpBoost = await this.communityService.getActiveMultiplier('xp_boost');
if (xpBoost > 1.0) {
const bonusXp = Math.floor(result.xpEarned * (xpBoost - 1));
if (bonusXp > 0) {
const boosted = applyXpGain(character.level, character.xp, bonusXp);
character.xp = boosted.newXp;
character.level = boosted.newLevel;
character.statPoints = (character.statPoints ?? 0) + boosted.statPointsGained;
await manager.save(character);
}
}
}
// Loot matériaux — drop rate variable par difficulté relative
let lootMaterial: { name: string; quantity: number } | null = null;
let lootedMaterialId: string | null = null;
if (result.winner === 'player' && monster.dropMaterialId) {
const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel);
if (Math.random() < dropRate) {
await addMaterialInTx(manager, character.id, monster.dropMaterialId, dropQty);
lootMaterial = { name: 'matériau', quantity: dropQty };
lootedMaterialId = monster.dropMaterialId;
}
}
// Persister le log
const combatLog = this.combatLogRepository.create({
characterId: character.id,
monsterId: monster.id,
winner: result.winner,
totalRounds: result.totalRounds,
roundsData: result.rounds,
xpEarned: result.xpEarned,
goldEarned: result.goldEarned,
levelUp: levelUpData.levelsGained > 0,
lootMaterialId: lootedMaterialId,
lootQuantity: lootMaterial?.quantity ?? 0,
});
await manager.save(combatLog);
// Construire la réponse
const summaryParts: string[] = [];
if (result.winner === 'player') {
summaryParts.push(`Victoire en ${result.totalRounds} tours !`);
summaryParts.push(`+${result.xpEarned} XP, +${result.goldEarned} Or.`);
if (levelUpData.levelsGained > 0) {
summaryParts.push(`LEVEL UP ! Niveau ${levelUpData.newLevel} atteint. +${levelUpData.statPointsGained} points de stats.`);
}
} else {
summaryParts.push(`Défaite au tour ${result.totalRounds}. Retour à l'auberge.`);
if (goldLost > 0) summaryParts.push(`${goldLost} Or perdu.`);
}
if (lootMaterial) {
summaryParts.push(`Loot : ${lootMaterial.quantity} matériau${lootMaterial.quantity > 1 ? 'x' : ''} obtenu${lootMaterial.quantity > 1 ? 's' : ''} !`);
}
return {
characterId: character.id,
lootedMaterialId,
response: {
winner: result.winner,
rounds: result.rounds,
summary: summaryParts.join(' '),
rewards: {
xp: result.xpEarned,
gold: result.goldEarned,
goldLost,
levelUp: levelUpData.levelsGained > 0,
newLevel: levelUpData.newLevel,
statPointsGained: levelUpData.statPointsGained,
loot: lootMaterial,
},
character: {
level: character.level,
xp: character.xp,
xpToNextLevel: xpRequiredForLevel(character.level),
gold: character.gold,
hpCurrent: character.hpCurrent,
hpMax: character.hpMax,
enduranceCurrent: character.enduranceSaved,
enduranceMax: character.enduranceMax,
statPoints: character.statPoints ?? 0,
},
},
};
});
// Events émis APRÈS la transaction (fire-and-forget)
if (txResult.response.winner === 'player') {
const cid = txResult.characterId;
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'combat_wins', increment: 1 });
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'level_reached', increment: 0, absolute: txResult.response.character.level });
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'gold_accumulated', increment: 0, absolute: txResult.response.rewards.gold });
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: 1 });
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_gold_earned', increment: txResult.response.rewards.gold });
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_any', increment: 1, zone: monster.zone });
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'kill_monster', targetId: monster.id, increment: 1, zone: monster.zone });
if (txResult.lootedMaterialId) {
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: txResult.lootedMaterialId, increment: 1 });
}
}
this.setCooldown(user.id, COOLDOWN_SINGLE_MS);
return txResult.response;
}
async startMultiCombat(dto: StartCombatDto, user: User, count: number) {
this.checkCooldown(user.id);
const monster = await this.monsterService.findOne(dto.monsterId);
const txResult = await this.dataSource.transaction(async (manager) => {
const character = await manager
.getRepository(Character)
.createQueryBuilder('c')
.setLock('pessimistic_write')
.where('c.user_id = :userId', { userId: user.id })
.getOne();
if (!character) throw new BadRequestException('Aucun personnage trouvé');
// Équipement (une seule fois)
const equipped = await this.itemService.getEquippedItems(character.id);
const FORGE_BONUS_PER_LEVEL = 2;
const weaponAttack = equipped.weapon
? equipped.weapon.item.attackBonus + equipped.weapon.forgeLevel * FORGE_BONUS_PER_LEVEL
: 0;
const armorDefense = equipped.armor
? equipped.armor.item.defenseBonus + equipped.armor.forgeLevel * FORGE_BONUS_PER_LEVEL
: 0;
const itemForceBonus = (equipped.weapon?.item.forceBonus ?? 0) + (equipped.armor?.item.forceBonus ?? 0);
const itemAgiliteBonus = (equipped.weapon?.item.agiliteBonus ?? 0) + (equipped.armor?.item.agiliteBonus ?? 0);
const itemIntelligenceBonus = (equipped.weapon?.item.intelligenceBonus ?? 0) + (equipped.armor?.item.intelligenceBonus ?? 0);
const itemChanceBonus = (equipped.weapon?.item.chanceBonus ?? 0) + (equipped.armor?.item.chanceBonus ?? 0);
const totals = { wins: 0, losses: 0, xp: 0, gold: 0, goldLost: 0, loot: [] as { name: string; quantity: number }[], levelsGained: 0 };
const lootedMaterialIds: string[] = [];
let combatsDone = 0;
for (let i = 0; i < count; i++) {
// Endurance lazy calc
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 || character.hpCurrent <= 0) break;
const playerStats: CombatantStats = {
name: character.name,
hpCurrent: character.hpCurrent,
hpMax: character.hpMax,
force: character.force + itemForceBonus,
agilite: character.agilite + itemAgiliteBonus,
intelligence: character.intelligence + itemIntelligenceBonus,
chance: character.chance + itemChanceBonus,
attack: weaponAttack,
defense: armorDefense,
attackType: dto.attackType,
};
const monsterStats: CombatantStats = {
name: monster.name, hpCurrent: monster.hp, hpMax: monster.hp,
force: 0, agilite: 0, intelligence: 0, chance: 0,
attack: monster.attack, defense: monster.defense, attackType: monster.attackType,
};
const result = resolveCombat(playerStats, monsterStats, monster.xpReward, monster.goldMin, monster.goldMax);
combatsDone++;
let newEnduranceSaved = enduranceCurrent - COMBAT_ENDURANCE_COST;
let combatLootMatId: string | null = null;
let combatLootQty = 0;
if (result.winner === 'player') {
const levelUp = applyXpGain(character.level, character.xp, result.xpEarned);
character.xp = levelUp.newXp;
character.level = levelUp.newLevel;
character.statPoints = (character.statPoints ?? 0) + levelUp.statPointsGained;
character.gold += result.goldEarned;
character.totalGoldEarned = Number(character.totalGoldEarned ?? 0) + result.goldEarned;
character.hpCurrent = Math.min(character.hpMax, character.hpCurrent + Math.floor(character.hpMax * VICTORY_HP_REGEN_RATIO));
totals.wins++;
totals.xp += result.xpEarned;
totals.gold += result.goldEarned;
if (levelUp.levelsGained > 0) totals.levelsGained += levelUp.levelsGained;
// Loot
if (monster.dropMaterialId) {
const { dropRate, dropQty } = computeDropRate(character.level, monster.minLevel, monster.maxLevel);
if (Math.random() < dropRate) {
await addMaterialInTx(manager, character.id, monster.dropMaterialId, dropQty);
totals.loot.push({ name: 'matériau', quantity: dropQty });
lootedMaterialIds.push(monster.dropMaterialId);
combatLootMatId = monster.dropMaterialId;
combatLootQty = dropQty;
}
}
} else {
newEnduranceSaved = Math.max(0, newEnduranceSaved - DEFEAT_ENDURANCE_PENALTY);
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);
totals.losses++;
totals.goldLost += goldLost;
}
character.enduranceSaved = newEnduranceSaved;
character.lastEnduranceTs = new Date();
// Log combat
await manager.save(manager.getRepository(CombatLog).create({
characterId: character.id, monsterId: monster.id,
winner: result.winner, totalRounds: result.totalRounds,
roundsData: result.rounds, xpEarned: result.xpEarned,
goldEarned: result.goldEarned, levelUp: false,
lootMaterialId: combatLootMatId, lootQuantity: combatLootQty,
}));
if (result.winner !== 'player') break; // Arrêt sur défaite
}
// Save final character state
await manager.save(character);
return {
characterId: character.id,
lootedMaterialIds,
combatsDone,
totals,
character: {
level: character.level, xp: character.xp,
xpToNextLevel: xpRequiredForLevel(character.level),
gold: character.gold, hpCurrent: character.hpCurrent,
hpMax: character.hpMax, enduranceCurrent: character.enduranceSaved,
enduranceMax: character.enduranceMax, statPoints: character.statPoints ?? 0,
},
};
});
// Events groupés APRÈS la transaction (une seule fois)
const cid = txResult.characterId;
if (txResult.totals.wins > 0) {
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'combat_wins', increment: txResult.totals.wins });
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'level_reached', increment: 0, absolute: txResult.character.level });
this.eventEmitter.emit('achievement.check', { characterId: cid, type: 'gold_accumulated', increment: 0, absolute: txResult.character.gold });
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('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) {
this.eventEmitter.emit('quest.progress', { characterId: cid, type: 'gather_material', targetId: matId, increment: 1 });
}
}
this.setCooldown(user.id, COOLDOWN_MULTI_MS);
return {
mode: 'multi',
count: txResult.combatsDone,
totals: txResult.totals,
character: txResult.character,
}; };
} }
@@ -213,6 +500,7 @@ export class CombatService {
xpEarned: true, xpEarned: true,
goldEarned: true, goldEarned: true,
levelUp: true, levelUp: true,
lootQuantity: true,
createdAt: true, createdAt: true,
monster: { id: true, name: true, minLevel: true, maxLevel: true } as any, monster: { id: true, name: true, minLevel: true, maxLevel: true } as any,
}, },

View File

@@ -1,4 +1,4 @@
import { IsUUID, IsIn } from 'class-validator'; import { IsUUID, IsIn, IsOptional, IsInt, Min, Max } from 'class-validator';
import { AttackType } from '../../monster/monster.entity'; import { AttackType } from '../../monster/monster.entity';
export class StartCombatDto { export class StartCombatDto {
@@ -7,4 +7,10 @@ export class StartCombatDto {
@IsIn(['melee', 'ranged', 'magic']) @IsIn(['melee', 'ranged', 'magic'])
attackType: AttackType; attackType: AttackType;
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
count?: number;
} }

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

Some files were not shown because too many files have changed in this diff Show More