Compare commits

...

14 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
17 changed files with 666 additions and 169 deletions

View File

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

View File

@@ -73,8 +73,35 @@ ACTE II (niv 13+) — Le monde éveillé
### Règle de coexistence
- Zones 1-3 gardent le combat simple (grind rapide, ×5/×10 toujours dispo)
- Zones 4+ forcent le combat tour par tour (narratif, sorts, compagnons)
- 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

View File

@@ -4,7 +4,7 @@
<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>frontend</title>
<title>TetaRdPG</title>
</head>
<body>
<div id="root"></div>

View File

@@ -14,6 +14,7 @@ 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';
@@ -37,6 +38,7 @@ function AppRoutes() {
<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>} />

View File

@@ -2,7 +2,7 @@ import { api } from './client';
import type {
User, Character, Monster, CombatLog,
CharacterItem, CharacterMaterial, Recipe, CraftJob, Item,
TurnResult, TurnSpell, DaoPathProgress,
TurnResult, TurnSpell, DaoPathProgress, NpcView,
} from './types';
// Auth
@@ -81,6 +81,11 @@ export const questApi = {
arcs: () => api.get<any[]>('/quests/arcs'),
};
// NPCs
export const npcApi = {
all: () => api.get<NpcView[]>('/npcs'),
};
// Forge
export const forgeApi = {
upgrade: (charItemId: string) =>

View File

@@ -247,3 +247,15 @@ export interface CraftJob {
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;
}

View File

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

View File

@@ -1,12 +1,13 @@
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 } from 'lucide-react';
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' },

View File

@@ -137,6 +137,7 @@ body {
/* 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; }
@@ -147,3 +148,10 @@ body {
/* 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; }
}

View File

@@ -7,14 +7,21 @@ import { RARITY_COLORS, FORGE_TABLE, ZONE_INFO } from '../constants';
import { RarityBadge } from '../components/RarityBadge';
const ZONES = [
{ id: 'marais', ...ZONE_INFO.marais, desc: 'Zone de départ. Monstres niv. 1-9. Terre de boue et de brume.' },
// Acte I — L'Étang
{ id: 'marais', ...ZONE_INFO.marais, desc: 'Zone de départ. Monstres niv. 1-5. Terre de boue et de brume.' },
{ id: 'egouts', ...ZONE_INFO.egouts, desc: 'Sous-terrain infesté. Monstres niv. 4-10. Rats, slimes et croco.' },
{ id: '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 },
@@ -105,6 +112,66 @@ function ZonesTab() {
);
}
// ── Tab: Dao du Courant ──
function DaoTab() {
return (
<div>
<h3 style={{ color: '#d4af37', margin: '0 0 1rem', fontSize: 18 }}>Le Dao du Courant</h3>
<div className="card" style={{ marginBottom: '1rem' }}>
<p style={{ color: '#dce4f0', fontSize: 13, lineHeight: 1.6, margin: 0 }}>
Après avoir complété les 3 arcs de l'Acte I et prêté <strong style={{ color: '#f4c94e' }}>Le Serment des Trois</strong>,
le jeu se transforme. Le combat devient <strong>tactique tour par tour</strong> avec sorts, compagnons et stratégie.
</p>
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Les 3 voies</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{[
{ name: 'Écoute', color: '#88c8e8', desc: 'Contrôle et perception. Révèle faiblesses, chant offensif, ancrage.', archetype: 'Le stratège' },
{ name: 'Résonance', color: '#f4c94e', desc: 'Force amplifiée. Onde de choc, bouclier, contre-attaque, stun.', archetype: 'Le protecteur' },
{ name: 'Harmonie', color: '#3ddc84', desc: 'Support et guérison. Heal, purge, buff équipe, symphonie ultime.', archetype: 'L\'harmoniste' },
].map(v => (
<div key={v.name} className="card" style={{ padding: '0.75rem', borderLeft: `3px solid ${v.color}` }}>
<div style={{ fontSize: 14, fontWeight: 700, color: v.color }}>{v.name}</div>
<div style={{ fontSize: 10, color: '#6b7a99', marginBottom: 6 }}>{v.archetype}</div>
<div style={{ fontSize: 11, color: '#9ca3af' }}>{v.desc}</div>
</div>
))}
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Combat tactique</h4>
<div className="card" style={{ fontSize: 12, lineHeight: 1.8, color: '#9ca3af' }}>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#dce4f0' }}>Tour par tour</strong> — Chaque tour : [Attaque] [Sorts] [Items] [Fuir]. Fini l'auto-attaque.</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#dce4f0' }}>Mana</strong> — Les sorts consomment du Mana (base 50 + Intelligence ×2). Régénération : +5/tour.</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#dce4f0' }}>Buffs & Debuffs</strong> — Bouclier, poison, confusion, regen... la stratégie compte.</p>
<p style={{ margin: 0 }}><strong style={{ color: '#dce4f0' }}>Grind rapide</strong> — Les zones 1-3 gardent le combat simple (×1/×5/×10) pour farmer.</p>
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Compagnons</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div className="card" style={{ padding: '0.75rem', borderLeft: '3px solid #88c8e8' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#88c8e8' }}>Mira</div>
<div style={{ fontSize: 11, color: '#9ca3af' }}>Heal si HP bas, buff défensif, purge debuffs. Elle chante pour vous protéger.</div>
</div>
<div className="card" style={{ padding: '0.75rem', borderLeft: '3px solid #f4c94e' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: '#f4c94e' }}>Vell</div>
<div style={{ fontSize: 11, color: '#9ca3af' }}>Tank et protège. Taunt, contre-attaque, onde de choc. Sa force est devenue sagesse.</div>
</div>
</div>
<h4 style={{ color: '#dce4f0', margin: '1rem 0 0.5rem', fontSize: 14 }}>Ce qui change à l'Acte II</h4>
<div className="card" style={{ fontSize: 12, lineHeight: 1.8, color: '#9ca3af' }}>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#f4c94e' }}>Combat</strong> — Auto → Tour par tour stratégique</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#f4c94e' }}>Sorts</strong> — 15 sorts (5 par voie), débloqués avec des points de voie</p>
<p style={{ margin: '0 0 4px' }}><strong style={{ color: '#f4c94e' }}>Compagnons</strong> — Mira et Vell combattent à vos côtés (IA auto)</p>
<p style={{ margin: 0 }}><strong style={{ color: '#f4c94e' }}>L'histoire</strong> — Chaque zone raconte un chapitre de l'Odyssée</p>
</div>
</div>
);
}
// ── Tab: Bestiaire ──
function BestiaryTab({ monsters, materials }: { monsters: (Monster & { zone: string })[]; materials: any[] }) {
@@ -483,6 +550,7 @@ export function GuidePage() {
{/* 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} />}

View File

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

@@ -2,8 +2,8 @@ import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { combatApi, turnCombatApi, characterApi } from '../api/endpoints';
import type { Monster, TurnResult, TurnSpell, TurnBuff } from '../api/types';
import { Swords, Sparkles, PackageOpen, ArrowLeft, Zap, Heart, Shield, Skull, Trophy, Users } from 'lucide-react';
import 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';
@@ -23,6 +23,19 @@ export function TurnCombatPage() {
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;
@@ -65,6 +78,58 @@ export function TurnCombatPage() {
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[]>();

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

@@ -467,6 +467,7 @@ export class CombatService {
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_monsters_killed', increment: txResult.totals.wins });
this.eventEmitter.emit('community.contribute', { characterId: cid, type: 'total_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 });
}

View File

@@ -34,7 +34,7 @@ export async function seedOdysseeQuests(dataSource: DataSource) {
description: 'Gorn en parlait : un ruisseau qui montre ce qu\'on ne veut pas voir. Mira, Vell et toi devez l\'affronter ensemble.',
zone: 'ruisseau_miroir',
sortOrder: 4,
minLevel: 15,
minLevel: 13,
}));
}
@@ -53,7 +53,7 @@ export async function seedOdysseeQuests(dataSource: DataSource) {
arcId: arc4.id,
arcOrder: 1,
zone: null as string | null,
minLevel: 15,
minLevel: 13,
repeatable: false,
},
{
@@ -70,7 +70,7 @@ export async function seedOdysseeQuests(dataSource: DataSource) {
arcId: arc4.id,
arcOrder: 2,
zone: 'ruisseau_miroir',
minLevel: 15,
minLevel: 13,
repeatable: false,
},
{
@@ -401,11 +401,27 @@ export async function seedOdysseeQuests(dataSource: DataSource) {
}
}
// Quêtes répétables (grind léger entre les arcs — optionnel, pas obligatoire pour l'histoire)
// Quêtes de transition — découverte des premiers monstres (bridge Acte I → Acte II)
const transitionQuests = [
{ name: 'Au-delà de l\'Étang', description: 'L\'eau change. Des reflets étranges dansent à la surface du Ruisseau. Affrontez votre premier Reflet Sombre.', objectiveType: 'kill_monster', objectiveTargetId: m.get('Reflet Sombre'), objectiveCount: 1, rewardXp: 400, rewardGold: 200, zone: 'ruisseau_miroir', minLevel: 13 },
{ name: 'Les Cristaux du Ruisseau', description: 'Des insectes de cristal patrouillent les rives. Éliminez 3 Gerris de Cristal.', objectiveType: 'kill_monster', objectiveTargetId: m.get('Gerris de Cristal'), objectiveCount: 3, rewardXp: 500, rewardGold: 250, zone: 'ruisseau_miroir', minLevel: 13 },
{ name: 'Explorateur du Miroir', description: 'Le Ruisseau regorge de créatures inconnues. Remportez 5 combats dans cette zone.', objectiveType: 'kill_any', objectiveTargetId: null, objectiveCount: 5, rewardXp: 600, rewardGold: 300, zone: 'ruisseau_miroir', minLevel: 13 },
{ name: 'Éclats de Vérité', description: 'Les créatures du Ruisseau laissent tomber des éclats brillants. Récoltez 3 Éclats de Miroir.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Éclat de Miroir'), objectiveCount: 3, rewardXp: 500, rewardGold: 250, zone: 'ruisseau_miroir', minLevel: 13 },
];
for (const q of transitionQuests) {
const existing = await questRepo.findOne({ where: { name: q.name } });
if (!existing) {
await questRepo.save(questRepo.create({ ...q, rewardTitle: null, arcId: null, arcOrder: 0, repeatable: false, acceptText: null, completeText: null }));
questsAdded++;
}
}
// Quêtes répétables (grind léger entre les arcs — optionnel)
const dailyQuests = [
{ name: 'Éclats quotidiens', description: 'Récoltez des Éclats de Miroir dans le Ruisseau.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Éclat de Miroir'), objectiveCount: 3, rewardXp: 300, rewardGold: 150, zone: 'ruisseau_miroir', minLevel: 16 },
{ name: 'Brumes du jour', description: 'Récoltez de la Mousse Murmurante dans le Marais.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Mousse Murmurante'), objectiveCount: 2, rewardXp: 400, rewardGold: 200, zone: 'marais_murmures', minLevel: 19 },
{ name: 'Pierres du Torrent', description: 'Récoltez des Pierres de Torrent.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Pierre de Torrent'), objectiveCount: 2, rewardXp: 500, rewardGold: 250, zone: 'torrent_brise', minLevel: 22 },
{ name: 'Éclats quotidiens', description: 'Récoltez des Éclats de Miroir dans le Ruisseau.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Éclat de Miroir'), objectiveCount: 3, rewardXp: 300, rewardGold: 150, zone: 'ruisseau_miroir', minLevel: 13 },
{ name: 'Brumes du jour', description: 'Récoltez de la Mousse Murmurante dans le Marais.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Mousse Murmurante'), objectiveCount: 2, rewardXp: 400, rewardGold: 200, zone: 'marais_murmures', minLevel: 16 },
{ name: 'Pierres du Torrent', description: 'Récoltez des Pierres de Torrent.', objectiveType: 'gather_material', objectiveTargetId: mat.get('Pierre de Torrent'), objectiveCount: 2, rewardXp: 500, rewardGold: 250, zone: 'torrent_brise', minLevel: 19 },
];
for (const q of dailyQuests) {
@@ -416,5 +432,5 @@ export async function seedOdysseeQuests(dataSource: DataSource) {
}
}
console.log(`✅ Odyssée: ${questsAdded} quêtes (4 arcs narratifs + ${dailyQuests.length} répétables)`);
console.log(`✅ Odyssée: ${questsAdded} quêtes (4 arcs + ${transitionQuests.length} transition + ${dailyQuests.length} répétables)`);
}

View File

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

View File

@@ -4,9 +4,11 @@ import { Npc } from './npc.entity';
import { NpcController } from './npc.controller';
import { NpcService } from './npc.service';
import { PlayerQuestArc } from '../quest/player-quest-arc.entity';
import { Character } from '../character/entities/character.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([Npc, PlayerQuestArc])],
imports: [TypeOrmModule.forFeature([Npc, PlayerQuestArc, Character]), AuthModule],
controllers: [NpcController],
providers: [NpcService],
exports: [NpcService],