Compare commits
81 Commits
6d1230d16a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 08f5b0789f | |||
| bab73ae341 | |||
| a3ee7e7bc1 | |||
| d996f5806d | |||
| cc7893ec8f | |||
| fd5e2f6425 | |||
| 4d82346af4 | |||
| 2001c867cb | |||
| cae0ef5d57 | |||
| e8f108a7e8 | |||
| 430fbb6e95 | |||
| f44ce0531f | |||
| 34d1711cee | |||
| 697fb67bbb | |||
| cc450f2113 | |||
| 9d50adf523 | |||
| 4beb1b2ed9 | |||
| 2c94e4f3aa | |||
| 9eff6d541e | |||
| 71070b2e76 | |||
| e769c27a42 | |||
| 17c61a2bb8 | |||
| faf2a98227 | |||
| 0d917a8b39 | |||
| 23843cb72c | |||
| dbdc02f4ab | |||
| 84104cd96f | |||
| 823d7911f0 | |||
| 4fc8be9ea0 | |||
| 74938dd35f | |||
| 909b8da77f | |||
| 6ffc867ef7 | |||
| efe4b4e372 | |||
| ec6d91b0f9 | |||
| 47c90e4d55 | |||
| 6938eedcda | |||
| da8401dec2 | |||
| 0c9839e1d8 | |||
| dd2a025c74 | |||
| d77666c4cf | |||
| 287774ecd0 | |||
| bf896a797f | |||
| 9aadc326e1 | |||
| 810ad5ee64 | |||
| b414200544 | |||
| 66df1013e5 | |||
| 95fcf325dc | |||
| 60d10a5423 | |||
| cc3cbc1d2f | |||
| d1609efaae | |||
| 8cb5fcd5ba | |||
| 1ffde61f97 | |||
| 4d254692b0 | |||
| 210f32b9cc | |||
| 014ffdd789 | |||
| e3c870bb9f | |||
| eafac3d8c7 | |||
| cfdc5c9b02 | |||
| 9fac9e123b | |||
| af247a1c6b | |||
| 8038ca5d0a | |||
| 7651f3d8aa | |||
| 93b34b1f7b | |||
| 214045c7ce | |||
| 6df11f2860 | |||
| 708352be65 | |||
| ac88cbb5ab | |||
| 37d5e628c0 | |||
| 8ee50805ea | |||
| 77052d9219 | |||
| 8fbdcafa7b | |||
| 8c6777c980 | |||
| c1bf793234 | |||
| 28ac5ef139 | |||
| 3ff5a8a84b | |||
| 824ed41a14 | |||
| 921873befd | |||
| b506adf034 | |||
| 49b8aa1211 | |||
| 1fce52f05c | |||
| 23f7dd0f3c |
19
.claude/settings.json
Normal file
19
.claude/settings.json
Normal 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(*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,12 @@ REDIS_URL=redis://localhost:6379
|
||||
# Frontend CORS (virgule-séparé pour multi-origin)
|
||||
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_JWT_SECRET=
|
||||
|
||||
# Cookie signing
|
||||
COOKIE_SECRET=
|
||||
|
||||
# Twitch EventSub webhook
|
||||
TWITCH_WEBHOOK_SECRET=<secret EventSub Twitch>
|
||||
TWITCH_CLIENT_ID=<app client id>
|
||||
|
||||
61
.gitea/workflows/deploy.yml
Normal file
61
.gitea/workflows/deploy.yml
Normal 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"
|
||||
244
SPRINT3.md
Normal file
244
SPRINT3.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# TetaRdPG — Brief Sprint 3
|
||||
|
||||
> Statut : 🔄 En cours
|
||||
> Objectif : Items + Inventaire + Artisanat (Craft) + Forge
|
||||
> Stack : NestJS · PostgreSQL · TypeORM (synchronize dev)
|
||||
> Prérequis : Sprint 2 livré ✅
|
||||
|
||||
---
|
||||
|
||||
## Scope Sprint 3
|
||||
|
||||
### ✅ In scope
|
||||
|
||||
- Entité `items` (armes + armures) avec bonus stats
|
||||
- Inventaire joueur (`character_items`) — possession + équipement actif
|
||||
- Intégration combat : `player.attack` et `player.defense` depuis l'équipement équipé
|
||||
- Entité `materials` + inventaire joueur (`character_materials`) — loot post-combat
|
||||
- Entité `recipes` + ingrédients en jsonb
|
||||
- Artisanat (`craft_jobs`) — lazy calc timer (même pattern que endurance)
|
||||
- Forge — amélioration item niveau 1–5 avec risque croissant
|
||||
- Seeds : 5 items de base, 5 matériaux, 3 recettes
|
||||
- API : voir section dédiée
|
||||
|
||||
### ❌ Out of scope
|
||||
|
||||
- Boutique (achat/vente) — Sprint 4
|
||||
- Bonus de sets d'équipement — Sprint 4
|
||||
- TetardCoin (accélération craft, forge garantie) — sprint monétisation
|
||||
- Twitch, PvP, guildes
|
||||
- Frontend React
|
||||
|
||||
---
|
||||
|
||||
## Décisions de design (game-designer)
|
||||
|
||||
| Décision | Valeur | Justification |
|
||||
|----------|--------|---------------|
|
||||
| Slots équipement | weapon + armor | Simplifié Sprint 3 — casque/bottes Sprint 4 |
|
||||
| Player.attack en combat | `char.weapon?.attackBonus ?? 0` | Remplace attack=0 Sprint 2 |
|
||||
| Player.defense en combat | `char.armor?.defenseBonus ?? 0` | Remplace defense=0 Sprint 2 |
|
||||
| Loot drop | 40% de chance après victoire | 1 matériau aléatoire parmi ceux du monstre |
|
||||
| Craft timer | lazy calc : `startedAt + durationMs` | Même pattern endurance — zéro job schedulé |
|
||||
| Forge risque | Niv.1–2 : 0% | 3 : 20% | 4 : 30% | 5 : 40% | GDD exact |
|
||||
| Forge succès garanti | 12 TetardCoin (non implémenté Sprint 3) | Placeholder, champ `forcedSuccess` |
|
||||
| Forge échec | item inchangé, pas de coût matériaux Sprint 3 | coût matériaux forge = Sprint 4 |
|
||||
| Durée craft | court: 15s (dev) / long: 60s (dev) | Valeurs réelles en prod : 15min–2h |
|
||||
|
||||
---
|
||||
|
||||
## Schéma DB
|
||||
|
||||
### `items`
|
||||
```
|
||||
id uuid PK
|
||||
name varchar(100)
|
||||
description text
|
||||
type varchar(20) -- 'weapon' | 'armor'
|
||||
rarity varchar(20) -- 'common' | 'rare' | 'epic' | 'legendary'
|
||||
attack_bonus int default 0
|
||||
defense_bonus int default 0
|
||||
force_bonus int default 0
|
||||
agilite_bonus int default 0
|
||||
intelligence_bonus int default 0
|
||||
chance_bonus int default 0
|
||||
vitalite_bonus int default 0
|
||||
```
|
||||
|
||||
### `character_items`
|
||||
```
|
||||
id uuid PK
|
||||
character_id uuid FK characters
|
||||
item_id uuid FK items
|
||||
forge_level int default 0 -- 0 = non forgé
|
||||
equipped boolean default false
|
||||
acquired_at timestamp
|
||||
```
|
||||
|
||||
### `materials`
|
||||
```
|
||||
id uuid PK
|
||||
name varchar(100)
|
||||
description text
|
||||
rarity varchar(20)
|
||||
```
|
||||
|
||||
### `character_materials`
|
||||
```
|
||||
id uuid PK
|
||||
character_id uuid FK characters
|
||||
material_id uuid FK materials
|
||||
quantity int default 0
|
||||
```
|
||||
|
||||
### `recipes`
|
||||
```
|
||||
id uuid PK
|
||||
name varchar(100)
|
||||
result_item_id uuid FK items
|
||||
craft_duration_seconds int
|
||||
endurance_cost int
|
||||
ingredients jsonb -- [{ materialId, quantity }]
|
||||
```
|
||||
|
||||
### `craft_jobs`
|
||||
```
|
||||
id uuid PK
|
||||
character_id uuid FK characters
|
||||
recipe_id uuid FK recipes
|
||||
started_at timestamp
|
||||
completed_at timestamp -- lazy : startedAt + duration
|
||||
collected boolean default false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seeds
|
||||
|
||||
### Items (5)
|
||||
|
||||
| Nom | Type | Rareté | Attack | Defense | Notes |
|
||||
|-----|------|--------|--------|---------|-------|
|
||||
| Bâton de Roseau | weapon | common | 3 | 0 | Arme de départ |
|
||||
| Dague Rouillée | weapon | common | 5 | 0 | |
|
||||
| Épée Courte | weapon | rare | 9 | 0 | |
|
||||
| Gilet de Cuir | armor | common | 0 | 3 | |
|
||||
| Cotte de Mailles | armor | rare | 0 | 7 | |
|
||||
|
||||
### Matériaux (5)
|
||||
|
||||
| Nom | Rareté | Sources |
|
||||
|-----|--------|---------|
|
||||
| Bave de Têtard | common | Têtard Vase |
|
||||
| Écailles de Grenouille | common | Grenouille Boueuse |
|
||||
| Venin de Serpent | rare | Serpent des Marais |
|
||||
| Spores Vénéneuses | rare | Champi Vénéneux |
|
||||
| Fragment de Boue | common | Golem de Boue |
|
||||
|
||||
### Recettes (3)
|
||||
|
||||
| Nom | Résultat | Durée (dev) | Endurance | Ingrédients |
|
||||
|-----|----------|-------------|-----------|-------------|
|
||||
| Forge Bâton Renforcé | Bâton de Roseau+? | 15s | 5 | 2× Bave de Têtard |
|
||||
| Craft Dague | Dague Rouillée | 15s | 8 | 3× Bave + 1× Écaille |
|
||||
| Craft Gilet de Cuir | Gilet de Cuir | 30s | 10 | 3× Écaille + 2× Fragment |
|
||||
|
||||
---
|
||||
|
||||
## API Sprint 3
|
||||
|
||||
```
|
||||
# Items
|
||||
GET /api/items → liste tous les items (catalogue)
|
||||
GET /api/items/inventory → inventaire du personnage connecté
|
||||
POST /api/items/equip/:itemId → équiper un item (character_items.id)
|
||||
POST /api/items/unequip/:slot → déséquiper un slot (weapon|armor)
|
||||
|
||||
# Materials
|
||||
GET /api/materials → catalogue matériaux
|
||||
GET /api/materials/inventory → matériaux du personnage connecté
|
||||
|
||||
# Craft
|
||||
GET /api/craft/recipes → liste recettes
|
||||
POST /api/craft/start → { recipeId } → lance le craft
|
||||
GET /api/craft/active → craft en cours (lazy status)
|
||||
POST /api/craft/collect/:jobId → collecter si terminé
|
||||
|
||||
# Forge
|
||||
POST /api/forge/upgrade → { characterItemId } → tente amélioration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture modules
|
||||
|
||||
```
|
||||
src/
|
||||
├── item/
|
||||
│ ├── item.entity.ts
|
||||
│ ├── character-item.entity.ts
|
||||
│ ├── item.module.ts
|
||||
│ ├── item.service.ts
|
||||
│ └── item.controller.ts
|
||||
├── material/
|
||||
│ ├── material.entity.ts
|
||||
│ ├── character-material.entity.ts
|
||||
│ ├── material.module.ts
|
||||
│ ├── material.service.ts
|
||||
│ └── material.controller.ts
|
||||
├── craft/
|
||||
│ ├── recipe.entity.ts
|
||||
│ ├── craft-job.entity.ts
|
||||
│ ├── craft.module.ts
|
||||
│ ├── craft.service.ts
|
||||
│ └── craft.controller.ts
|
||||
├── forge/
|
||||
│ ├── forge.module.ts
|
||||
│ ├── forge.service.ts
|
||||
│ └── forge.controller.ts
|
||||
└── database/
|
||||
└── items-seed.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Intégration combat (CombatEngine)
|
||||
|
||||
```typescript
|
||||
// Dans CombatService.startCombat() — charger l'équipement
|
||||
const weaponBonus = char.equippedWeapon?.item.attackBonus ?? 0;
|
||||
const armorBonus = char.equippedArmor?.item.defenseBonus ?? 0;
|
||||
|
||||
const playerStats: CombatantStats = {
|
||||
...
|
||||
attack: weaponBonus, // était 0 Sprint 2
|
||||
defense: armorBonus, // était 0 Sprint 2
|
||||
};
|
||||
```
|
||||
|
||||
Loot post-victoire :
|
||||
```typescript
|
||||
if (result.winner === 'player' && Math.random() < 0.4) {
|
||||
// créditer 1 matériau aléatoire dans character_materials
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critères de validation integrator
|
||||
|
||||
- [ ] `GET /api/items` → 5 items seedés
|
||||
- [ ] `GET /api/materials` → 5 matériaux seedés
|
||||
- [ ] `POST /api/items/equip/:id` → item équipé, character mis à jour
|
||||
- [ ] Combat avec arme équipée → player.attack = weaponBonus
|
||||
- [ ] Victoire combat → chance de loot matériau (40%)
|
||||
- [ ] `GET /api/materials/inventory` → quantité augmentée après loot
|
||||
- [ ] `GET /api/craft/recipes` → 3 recettes disponibles
|
||||
- [ ] `POST /api/craft/start` → job créé, endurance déduite
|
||||
- [ ] `GET /api/craft/active` → status `pending` | `ready` | `none`
|
||||
- [ ] `POST /api/craft/collect/:jobId` → item ajouté à l'inventaire
|
||||
- [ ] Collect avant fin → 400 "not ready yet"
|
||||
- [ ] `POST /api/forge/upgrade` → niveau 1 : succès garanti
|
||||
- [ ] Forge niveau 3 → 20% chance échec (matériaux déduits, forgeLvl inchangé)
|
||||
- [ ] Sans cookie → 401 sur toutes les routes protégées
|
||||
- [ ] Endurance insuffisante pour craft → 400
|
||||
292
SPRINT4.md
Normal file
292
SPRINT4.md
Normal 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
51
SPRINT5.md
Normal 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
|
||||
BIN
TetaRdPG/Annexes/1. Formules d_Endurance.docx
Normal file
BIN
TetaRdPG/Annexes/1. Formules d_Endurance.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Annexes/2. Formules Système Combat.docx
Normal file
BIN
TetaRdPG/Annexes/2. Formules Système Combat.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Annexes/3. Formules progression & XP.docx
Normal file
BIN
TetaRdPG/Annexes/3. Formules progression & XP.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Annexes/4. Artisanat & Forge.docx
Normal file
BIN
TetaRdPG/Annexes/4. Artisanat & Forge.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Annexes/5. Système de succès.docx
Normal file
BIN
TetaRdPG/Annexes/5. Système de succès.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Annexes/6. Système Économique.docx
Normal file
BIN
TetaRdPG/Annexes/6. Système Économique.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Feuille de Route Post-Lancement (v1.1+).docx
Normal file
BIN
TetaRdPG/Feuille de Route Post-Lancement (v1.1+).docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Game Design Document (GDD) - TetaRdPG.docx
Normal file
BIN
TetaRdPG/Game Design Document (GDD) - TetaRdPG.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Jalon & Responsabilité.docx
Normal file
BIN
TetaRdPG/Jalon & Responsabilité.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Roadmap de Développement.docx
Normal file
BIN
TetaRdPG/Roadmap de Développement.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Sprint/Sprint 1 _ Objectifs et Tâches Prioritaires.docx
Normal file
BIN
TetaRdPG/Sprint/Sprint 1 _ Objectifs et Tâches Prioritaires.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Sprint/Sprint 2 _ Focus Combat PvE.docx
Normal file
BIN
TetaRdPG/Sprint/Sprint 2 _ Focus Combat PvE.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Sprint/Sprint 3 _ Focus Artisanat & Forge.docx
Normal file
BIN
TetaRdPG/Sprint/Sprint 3 _ Focus Artisanat & Forge.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Sprint/Sprint 4 _ Focus Succès & Hall of Fame.docx
Normal file
BIN
TetaRdPG/Sprint/Sprint 4 _ Focus Succès & Hall of Fame.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Sprint/Sprint 5 _ Tests Bêta Fermée & Équilibrage.docx
Normal file
BIN
TetaRdPG/Sprint/Sprint 5 _ Tests Bêta Fermée & Équilibrage.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Sprint/Sprint 6 _ Lancement Public & Suivi Initial.docx
Normal file
BIN
TetaRdPG/Sprint/Sprint 6 _ Lancement Public & Suivi Initial.docx
Normal file
Binary file not shown.
BIN
TetaRdPG/Synthèse 18_03_2025.docx
Normal file
BIN
TetaRdPG/Synthèse 18_03_2025.docx
Normal file
Binary file not shown.
80
archive/ULTRAIMPORTANTNEJAMAISPRIMER.md/Visions1.md
Normal file
80
archive/ULTRAIMPORTANTNEJAMAISPRIMER.md/Visions1.md
Normal 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.
|
||||
@@ -0,0 +1,55 @@
|
||||
## **Feuille de Route Post-Lancement (v1.1+)**
|
||||
|
||||
### **Objectifs Globaux**
|
||||
|
||||
* Étendre le contenu et les fonctionnalités du jeu pour maintenir l'engagement.
|
||||
* Renforcer la communauté avec des mécaniques sociales (guildes, alliances).
|
||||
* Introduire des défis avancés (zones spéciales, PvP, quêtes élite).
|
||||
* Assurer un rythme régulier d'événements et de mises à jour.
|
||||
|
||||
### **Calendrier de Déploiement par Trimestre**
|
||||
|
||||
#### **T1 (Mois 1 à 3 Post-Lancement)**
|
||||
|
||||
* Lancement Système de Guildes (v1.1)
|
||||
* Création, gestion membres, coffre partagé, bonus XP/loot.
|
||||
* Premiers objectifs hebdomadaires de guilde.
|
||||
* Premier GIGABOSS Rotatif
|
||||
* Nouvelle mécanique, classement global, récompenses exclusives.
|
||||
* Boutique de Succès (v1.1.1)
|
||||
* Objets rares à acheter via titres/succès débloqués.
|
||||
* Patchs bi-hebdo : correction bugs, ajustement XP, loot.
|
||||
|
||||
#### **T2 (Mois 4 à 6 Post-Lancement)**
|
||||
|
||||
* Introduction Mode PvP (v1.2)
|
||||
* Duels asynchrones, système Elo, tournois mensuels.
|
||||
* Personnalisation Joueur (v1.2.1)
|
||||
* Skins, titres, badges visibles sur profil/site/Twitch.
|
||||
* Extension Marché Communautaire
|
||||
* Filtres avancés, système d’enchères.
|
||||
* Semaine thématique Zone \+ Quêtes Exclusives.
|
||||
|
||||
#### **T3 (Mois 7 à 9 Post-Lancement)**
|
||||
|
||||
* Nouvelles Zones & Lore Avancé (v1.3)
|
||||
* Temple du Prophète, Lac des Reflets.
|
||||
* Quêtes mystiques, objets uniques, accès restreints.
|
||||
* Amélioration Performance & Sécurité (v1.3.1)
|
||||
* Optimisation serveur/API, surveillance accrue.
|
||||
* Événements saisonniers (Halloween, Hiver).
|
||||
|
||||
#### **T4 (Mois 10 à 12 Post-Lancement)**
|
||||
|
||||
* Extension Alliances de Guildes (v1.4)
|
||||
* Coopération inter-guildes, quêtes communes, bonus partagés.
|
||||
* Nouveaux GIGABOSS Légendaires (v1.4.1)
|
||||
* Mécaniques spéciales, loot rare, accès limité.
|
||||
* Personnalisation Twitch Avancée
|
||||
* Animations live personnalisées, interactions boosts.
|
||||
|
||||
### **Fréquence Mises à Jour**
|
||||
|
||||
* Patchs : toutes les 2 semaines (bugfix, équilibrage).
|
||||
* Contenu : chaque mois (zones, quêtes, objets, événements).
|
||||
* Rapport mensuel : activité joueur, feedback, ajustements.
|
||||
230
archive/v0-mars-2025/Game Design Document (GDD) - TetaRdPG.md
Normal file
230
archive/v0-mars-2025/Game Design Document (GDD) - TetaRdPG.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# **Game Design Document (GDD) \- TetaRdPG**
|
||||
|
||||
## **1\. Vue d'ensemble**
|
||||
|
||||
### **Titre du projet : TetaRdPG**
|
||||
|
||||
### **Type de jeu : RPG textuel interactif intégré à une extension Twitch, de type "idle-RPG" avec progression persistante**
|
||||
|
||||
### **Plateformes : Site web \+ Extension Twitch (Navigateur)**
|
||||
|
||||
### **Vision**
|
||||
|
||||
Proposer une expérience communautaire ludique où les viewers influencent le jeu via Twitch, en créant et faisant évoluer leur personnage incarné. Le jeu repose sur la stratégie, la gestion de ressources (endurance, TetardCoin) et l'engagement à long terme.
|
||||
|
||||
## **2\. Objectifs principaux**
|
||||
|
||||
* Permettre aux viewers de Twitch d'interagir avec le stream via un jeu RPG.
|
||||
* Intégration des points de chaîne (TetardCoin) comme monnaie en jeu.
|
||||
* Liaison des comptes Twitch et site pour un suivi persistant.
|
||||
* Monétisation possible via :
|
||||
* Dons de Bits (Conversion : 1 Bit \= 1 TetardCoin)
|
||||
* Abonnements Prime / Lv.1-3 / Offres : bonus mensuel en TetardCoin (quantités à définir)
|
||||
* Achat de TetardCoin pour recharger l'endurance ou acheter des objets spéciaux
|
||||
* Fonctionnalités Twitch :
|
||||
* Affichage du statut "Live" dans l'extension et sur le site
|
||||
* Classements viewers (XP, Or, Succès)
|
||||
* Actions spéciales via récompenses personnalisées (boost, quête, duel)
|
||||
|
||||
## **3\. Système de progression**
|
||||
|
||||
### **3.1 Création de personnage (Incarné)**
|
||||
|
||||
* **Endurance** : Base 100 (recharge 10/h). Max : 150 via équipement.
|
||||
* **Activités** :
|
||||
* Combat : 10 endurance
|
||||
* Entraînement : 20/40/60 endurance
|
||||
* Quête : Coût/durée variables selon la difficulté
|
||||
* **Statistiques de base** (5 points à répartir) :
|
||||
* Force / Agilité / Intelligence / Chance / Vitalité (100 PV de base)
|
||||
* Récupération hors combat : 10% PV toutes les 15 min
|
||||
* Progression : Niveaux, entraînement, consommables (cap 101/stats)
|
||||
|
||||
### **3.2 Niveaux**
|
||||
|
||||
* XP via combats/quêtes : courbe exponentielle modérée
|
||||
* Niveau max : 100, puis "Niveau Beta" (progression symbolique)
|
||||
* Gain : \+5 points de stats / niveau
|
||||
* Déblocages :
|
||||
* Niv. 5 : Quêtes Moyennes
|
||||
* Niv. 10 : Forge
|
||||
* Niv. 15 : Boutique avancée
|
||||
* Niv. 20 : Quêtes Difficiles
|
||||
* Niv. 30 : Équipements épiques
|
||||
* Niv. 50 : Succès communautaires / quêtes d'élite
|
||||
|
||||
## **4\. Système de combat (Optimisé)**
|
||||
|
||||
Objectif : Créer des combats dynamiques même en tour par tour, valorisant la spécialisation et la prise de risque.
|
||||
|
||||
### **Système de sets d'équipement**
|
||||
|
||||
* Équipements communs à légendaires, liés à des sets thématiques.
|
||||
* 3+ pièces d'un même set \= bonus spécial cumulatif.
|
||||
|
||||
**Exemples** :
|
||||
|
||||
* Set des Brumes (Forêt Brumeuse) : \+10% esquive, \+5% résistance poison
|
||||
* Set Cristal Pur (Grottes Cristallines) : \+10% résistance magique, \+5% Intelligence
|
||||
* Set Gardien Ancestral (Ruines) : \+10% PV, \+5% Force
|
||||
* Set Prophétique (GIGABOSS) : \+5% toutes stats, régénération passive 1% PV/tour
|
||||
|
||||
### **Titres liés aux zones**
|
||||
|
||||
* Obtenus après X victoires/quêtes dans une zone.
|
||||
* Bonus actif uniquement dans la zone.
|
||||
|
||||
**Exemples** :
|
||||
|
||||
* Maître du Marais : \+10% dégâts (Marais du Têtard)
|
||||
* Chasseur de Brume : \+5% esquive (Forêt Brumeuse)
|
||||
* Défenseur des Ruines : \+10% PV max (Ruines)
|
||||
* Héros Prophétique : \+10% dégâts GIGABOSS, \+5% toutes stats
|
||||
|
||||
## **5\. Système de Succès**
|
||||
|
||||
### **Succès individuels**
|
||||
|
||||
* Catégories :
|
||||
* Progression / Combat / Zones / Équipements / Économie
|
||||
* Récompenses : Or, objets rares, titres honorifiques (profil)
|
||||
|
||||
### **Succès communautaires**
|
||||
|
||||
* Objectifs collectifs :
|
||||
* Ex : Tuer 10 000 monstres / Collecter 1M TetardCoin / Vaincre GIGABOSS communautaire
|
||||
* Récompenses : Boosts globaux (XP/loot), boutique spéciale, titres exclusifs
|
||||
|
||||
## **6\. Artisanat et Forge**
|
||||
|
||||
### **Forge d'équipement**
|
||||
|
||||
* Accès : Niv. 10+
|
||||
* Améliorations :
|
||||
* \+Stat (+1 à \+5)
|
||||
* Bonus set anticipé (2 pièces)
|
||||
* Effet spécial (critique, vol de vie)
|
||||
* Coût : Or \+ matériaux / TetardCoin (succès garanti)
|
||||
* Risque : Succès garanti (niv. 1-2), échec possible \+3 (perte)
|
||||
|
||||
### **Artisanat**
|
||||
|
||||
* Création : consommables, équipements spécifiques, améliorations
|
||||
* Requiert : recettes \+ matériaux \+ temps (temps réel/endurance)
|
||||
* Sources matériaux : loot, échanges joueurs, événements
|
||||
|
||||
Objectif : Renforcer l'autonomie, la collecte ciblée, la diversité de progression.
|
||||
|
||||
## **7\. Système Économique**
|
||||
|
||||
### **Monnaies**
|
||||
|
||||
* **Or** : combats/quêtes/succès ; achats standards (forge, consommables)
|
||||
* **TetardCoin** : via Twitch ; recharge endurance, objets exclusifs, forge/artisanat
|
||||
* **Matériaux** : loot ou artisanat ; pour forge/amélioration
|
||||
|
||||
### **Boutiques**
|
||||
|
||||
* Boutique de base (niv. 1\) : équipements communs/rares, potions simples
|
||||
* Boutique avancée (niv. 15+) : équipements épiques, recettes, matériaux
|
||||
* Boutique événementielle : objets thématiques, consommation limitée
|
||||
* Boutique Twitch : TetardCoin uniquement, objets esthétiques, boosts XP/loot
|
||||
|
||||
### **Échange entre joueurs (futur)**
|
||||
|
||||
* Marché communautaire : vente/achat objets/matériaux
|
||||
* Taxe : 5% Or
|
||||
* Limites : selon niveau joueur
|
||||
|
||||
### **Événements économiques**
|
||||
|
||||
* Promotions temporaires (ex : \-20% boutique avancée)
|
||||
* Objets exclusifs à durée limitée
|
||||
|
||||
Objectif : Créer une économie dynamique, valoriser l'engagement Twitch, stimuler l'interaction joueur.
|
||||
|
||||
## **8\. Événements communautaires et spéciaux**
|
||||
|
||||
### **Événements mondiaux**
|
||||
|
||||
* **Boss mondial** : GIGABOSS unique accessible à tous pendant 72h.
|
||||
|
||||
* Système de participation : chacun inflige des dégâts cumulés.
|
||||
* Récompenses : selon contribution (or, loot, titres)
|
||||
* **Semaine à thème** : focus sur une zone ou une activité.
|
||||
|
||||
* Bonus : XP doublée, loot spécial, quêtes exclusives
|
||||
* **Chasses communautaires** : tuer X monstres spécifiques ensemble.
|
||||
|
||||
* Suivi en temps réel via l'extension/site.
|
||||
|
||||
### **Mécaniques de participation**
|
||||
|
||||
* Tous les joueurs peuvent participer via leurs activités normales.
|
||||
* Progression visible (barres, classements).
|
||||
|
||||
### **Récompenses globales**
|
||||
|
||||
* Boost temporaire (ex : \+10% XP pour tous pendant 3 jours)
|
||||
* Accès à boutique exclusive post-événement
|
||||
* Objets ou titres commémoratifs (visibles sur profil)
|
||||
|
||||
Objectif : Renforcer la cohésion communautaire, dynamiser le jeu et valoriser les efforts collectifs.
|
||||
|
||||
## **9\. Système Social (Guildes et Alliances)**
|
||||
|
||||
### **Guildes de joueurs**
|
||||
|
||||
* **Création de guilde** :
|
||||
* Accessible à partir du niveau 20
|
||||
* Coût initial en or et TetardCoin
|
||||
* Nom personnalisé, blason, description
|
||||
* **Fonctionnalités** :
|
||||
* Chat de guilde (site/extension)
|
||||
* Coffre de guilde (partage de matériaux, or, objets)
|
||||
* **Bonus passifs** :
|
||||
* Niv. 1 : \+5% XP guilde
|
||||
* Niv. 2 : \+10% loot guilde
|
||||
* Niv. 3 : \+5% endurance max
|
||||
* **Quêtes de guilde** :
|
||||
* Objectifs collectifs hebdomadaires
|
||||
|
||||
### **Alliances entre guildes**
|
||||
|
||||
* Jusqu’à 3 guildes peuvent s’allier
|
||||
* Bonus événementiels partagés
|
||||
* Classements inter-guildes (PvE, quêtes, contribution économique)
|
||||
|
||||
### **Classement social**
|
||||
|
||||
* Top guildes : XP cumulée, quêtes, participation aux événements
|
||||
* Récompenses : titres collectifs, objets cosmétiques de guilde, accès boutique exclusive
|
||||
|
||||
Objectif : Favoriser la coopération, structurer la communauté et créer un sentiment d’appartenance durable.
|
||||
|
||||
## **10\. Lore et PNJ**
|
||||
|
||||
### **Univers de TetaRdPG**
|
||||
|
||||
Un monde aquatique ancien, dominé par les cycles de vie des Têtards et régi par les mystères du Têtarastafarisme, une religion prophétique centrée autour du "Têtard Prophétique". Les aventuriers incarnés explorent des marais, forêts, ruines et cavernes à la recherche de gloire, de pouvoir, et des vérités perdues.
|
||||
|
||||
### **Origines :**
|
||||
|
||||
* Le "Marais Originel" est le point de départ de toute vie têtardienne.
|
||||
* Les "GIGABOSS" sont des entités antiques, fragments d'une ancienne conscience unique divisée par le chaos.
|
||||
|
||||
### **Lieux majeurs :**
|
||||
|
||||
* **Village de Limo’Kroak** : point central, lieu de repos et d’échange.
|
||||
* **Temple du Prophète** : accessible niveau 50+, quêtes mystiques, lore avancé.
|
||||
* **Lac des Reflets** : lieu d’épreuves spirituelles, accès rare, objets uniques.
|
||||
|
||||
### **PNJ importants :**
|
||||
|
||||
* **Gorn, le Sage** : a vu des générations de Têtards, guide le joueur, donne quêtes clefs.
|
||||
* **Sarna, l’Alchimiste** : offre artisanat, recettes rares, potions avancées.
|
||||
* **Brugg, le Forgeron** : améliore les armes, gère la forge, défis spéciaux.
|
||||
* **Ombre-Têtard** : mystérieux guide vers les zones interdites, accès GIGABOSS.
|
||||
|
||||
Objectif : Offrir un monde riche et cohérent qui renforce l’immersion, stimule la curiosité, et donne du sens aux quêtes et combats.
|
||||
|
||||
23
archive/v0-mars-2025/Jalon & Responsabilité.md
Normal file
23
archive/v0-mars-2025/Jalon & Responsabilité.md
Normal file
@@ -0,0 +1,23 @@
|
||||
### **Jalons Principaux**
|
||||
|
||||
| Jalons | Délais Prévus | Livrables Clés |
|
||||
| ----- | ----- | ----- |
|
||||
| **Pré-production** | Semaine 1 à 4 | Mécanismes validés, wireframes, choix techniques |
|
||||
| **Sprint 1** (Twitch/Auth) | Semaine 5 à 6 | Auth Twitch, création perso, API de base |
|
||||
| **Sprint 2** (Combat/Base UI) | Semaine 7 à 8 | Combat basique, interface utilisateur |
|
||||
| **Sprint 3** (Artisanat & Forge) | Semaine 9 à 10 | Forge, craft, recettes |
|
||||
| **Sprint 4** (Succès/HoF) | Semaine 11 à 12 | Succès joueurs, classements |
|
||||
| **Tests Bêta Fermée** | Mois 4 | Bêta jouable, feedback intégrés |
|
||||
| **Lancement v1.0** | Mois 5 | Site et extension live |
|
||||
|
||||
### **Rôles & Responsabilités**
|
||||
|
||||
| Rôle | Responsable | Tâches Principales |
|
||||
| :---- | :---- | :---- |
|
||||
| **Chef de Projet** | \[Nom à définir\] | Planification, suivi, validation milestones |
|
||||
| **Développeur Backend** | \[Nom à définir\] | API, BDD, logique combat/endurance |
|
||||
| **Développeur Frontend** | \[Nom à définir\] | UI site/extension, récup Twitch, affichages |
|
||||
| **UX/UI Designer** | \[Nom à définir\] | Wireframes, UX extension/site |
|
||||
| **Spécialiste Twitch** | \[Nom à définir\] | Auth, récupération data Twitch, webhooks |
|
||||
| **Testeur QA** | \[Nom à définir\] | Tests fonctionnels, équilibrage, feedback utilisateur |
|
||||
|
||||
71
archive/v0-mars-2025/Roadmap de Développement.md
Normal file
71
archive/v0-mars-2025/Roadmap de Développement.md
Normal file
@@ -0,0 +1,71 @@
|
||||
## **Roadmap de Développement (Planning Global)**
|
||||
|
||||
### **Phase 1 : Pré-production (1 mois)**
|
||||
|
||||
* **Finalisation des mécanismes clés** : Documentation et validation des systèmes Endurance, Combat (formules), Progression (XP, niveaux), Artisanat/Forge.
|
||||
* **Spécifications techniques** :
|
||||
* Choix du stack : Frontend (React, Vue ?), Backend (Node.js, Django ?), BDD (MongoDB, PostgreSQL ?).
|
||||
* Architecture de l'application (API REST ? WebSocket pour events Twitch ?).
|
||||
* **Design de l’interface utilisateur** :
|
||||
* Wireframes pour extension Twitch (compacte, intuitive).
|
||||
* Wireframes site web : tableau de bord joueur, classements, marché.
|
||||
* **Monétisation** :
|
||||
* Conversion précise Bits → TetardCoin.
|
||||
* Valeurs bonus abonnements Prime/Lv1-Lv3 (ex: bonus mensuel TetardCoin).
|
||||
* Intégration des options d'achat d'objets avec TetardCoin.
|
||||
|
||||
### **Phase 2 : Développement Initial (2-3 mois)**
|
||||
|
||||
* **Intégration Twitch** :
|
||||
* Authentification OAuth Twitch.
|
||||
* Récupération des données : points de chaîne, status live, abonnements, bits.
|
||||
* **Système de création de personnage** :
|
||||
* Interface de création avec répartition stats initiales.
|
||||
* Gestion de l’endurance : récupération passive, dépense activités.
|
||||
* **Combats PvE de base** :
|
||||
* Tour par tour : Attaque, Défense, Objet, Fuite.
|
||||
* Calcul de dégâts, critiques, régénération.
|
||||
* Interface combat avec animation simple (texte/logs).
|
||||
* **Interface utilisateur** :
|
||||
* Tableau de bord : stats, inventaire, endurance.
|
||||
* Extension Twitch : interactions live, affichage succès.
|
||||
|
||||
### **Phase 3 : Contenus & Fonctionnalités avancées (2 mois)**
|
||||
|
||||
* **Artisanat & Forge** :
|
||||
* Interface de forge, amélioration équipements.
|
||||
* Création objets : recettes, matériaux, temps de craft.
|
||||
* **Systèmes de Succès** :
|
||||
* Suivi individuel (profil joueur).
|
||||
* Suivi communautaire (barres de progression, objectifs).
|
||||
* **Hall of Fame** :
|
||||
* Classements mensuels, badges, récompenses.
|
||||
* Affichage sur Twitch/site.
|
||||
* **Marché communautaire (alpha)** :
|
||||
* Vente/achat d'objets, interface simple.
|
||||
* Taxation 5% Or, limitations par niveau.
|
||||
* **Événements communautaires** :
|
||||
* GIGABOSS mondial (72h), suivi contributions.
|
||||
* Chasses collectives, semaines thématiques.
|
||||
|
||||
### **Phase 4 : Test et équilibrage (1 mois)**
|
||||
|
||||
* **Test technique** :
|
||||
* Debug API, test load Twitch/site.
|
||||
* Compatibilité navigateur.
|
||||
* **Test communautaire (bêta fermée)** :
|
||||
* Groupes pilotes (Twitch viewers).
|
||||
* Feedback gameplay, bugs.
|
||||
* **Équilibrage** :
|
||||
* Ajustement XP, Or, TetardCoin, difficulté combats.
|
||||
* Test économie de jeu (prix, récompenses).
|
||||
|
||||
### **Phase 5 : Lancement Public**
|
||||
|
||||
* **Lancement v1.0** : Extension Twitch et Site Web.
|
||||
* **Suivi communautaire** :
|
||||
* Patchs correctifs, ajouts contenu.
|
||||
* Événements spéciaux (lancement, premières chasses).
|
||||
* **Feuille de route post-lancement** :
|
||||
* Modules PVP, nouvelles zones (Lore \++), fonctionnalités sociales (guildes, alliances).
|
||||
|
||||
102
archive/v0-mars-2025/Synthèse 18_03_2025.md
Normal file
102
archive/v0-mars-2025/Synthèse 18_03_2025.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# **Synthèse du Projet TetaRdPG 18/03/2025**
|
||||
|
||||
## **1\. Vision et Objectifs**
|
||||
|
||||
**TetaRdPG** est un RPG textuel interactif, intégré à une extension Twitch. Le jeu propose une expérience communautaire immersive où les viewers influencent directement le gameplay via la plateforme Twitch.
|
||||
|
||||
* **Plateformes** : Extension Twitch \+ Site Web
|
||||
* **Type de Jeu** : Idle-RPG avec progression persistante
|
||||
* **Objectifs principaux** :
|
||||
* Intégration complète de Twitch (Statut live, classements viewers, interactions).
|
||||
* Utilisation des **TetardCoin** comme monnaie premium (obtenue via Twitch : Bits, abonnements).
|
||||
* Monétisation par dons, abonnements, achats de TetardCoin.
|
||||
* Expérience de progression longue, stratégie, gestion de ressources.
|
||||
|
||||
## **2\. Systèmes de Jeu**
|
||||
|
||||
### **2.1 Endurance**
|
||||
|
||||
* **Base** : 100 points, **max** : 150 (via équipements).
|
||||
* **Recharge passive** : 10 pts/heure (1 pt/6 min).
|
||||
* **Recharge active** :
|
||||
* 1 TetardCoin \= \+20 endurance.
|
||||
* Potions : \+25 / \+50 / \+100 endurance.
|
||||
* **Coûts** :
|
||||
* Combat : 10
|
||||
* Entraînement : 20/40/60
|
||||
* Quêtes : 15/30/50
|
||||
* Fuite : 5
|
||||
|
||||
### **2.2 Combat (PvE)**
|
||||
|
||||
* **Tour par tour dynamique** : Attaque / Défense / Objet / Fuite.
|
||||
* **Formules de dégâts** :
|
||||
* Mêlée : Arme \+ (Force x1.5)
|
||||
* Distance : Arme \+ (Agilité x1.5)
|
||||
* Magique : Arme \+ (Intelligence x1.5)
|
||||
* **Défense** : Réduction selon type d'armure \+ coefficient.
|
||||
* **Critique** : 5% \+ (Chance x0.2%), Dégâts x1.5
|
||||
* **Esquive** : 5% \+ (Chance x0.1%)
|
||||
* **Fin de combat** : XP, Or, objets, \+10% PV
|
||||
* **Défaite** : Retour auberge, \-50 endurance, perte d'or
|
||||
|
||||
### **2.3 Progression & XP**
|
||||
|
||||
* **XP nécessaire** : XP \= 100 x N^1.5
|
||||
* **Niveau max** : 100 \+ Niveau Beta (symbolique)
|
||||
* **Stats** : \+5 points par niveau à répartir (Force, Agilité, Intelligence, Chance, Vitalité)
|
||||
* **Déblocages** :
|
||||
* Niv 5 : Quêtes Moyennes
|
||||
* Niv 10 : Forge
|
||||
* Niv 15 : Boutique avancée
|
||||
* Niv 20 : Quêtes Difficiles
|
||||
* Niv 30 : Equipements épiques
|
||||
* Niv 50 : Succès communautaires
|
||||
|
||||
### **2.4 Artisanat & Forge**
|
||||
|
||||
* **Forge** :
|
||||
* Niv 1-2 : Succès garanti
|
||||
* Niv 3-5 : Risque (20%-40%), perte de matériaux
|
||||
* Coût max : 12 TetardCoin
|
||||
* **Artisanat** :
|
||||
* Temps : 15 min à 2h (accélérable via TetardCoin)
|
||||
* Endurance : 5 \+ (Rareté x3)
|
||||
* **Bonus de set** :
|
||||
* 2 pièces : \+3% stat associée
|
||||
* 3 pièces : \+10% \+ effets spéciaux
|
||||
|
||||
## **3\. Système Économique**
|
||||
|
||||
* **Monnaies** :
|
||||
* **Or** : gains classiques
|
||||
* **TetardCoin** : via Twitch (Bits, abonnements), achats, objets exclusifs
|
||||
* **Boutiques** :
|
||||
* Base (niv 1), Avancée (niv 15), Événementielle, Twitch-only
|
||||
* **Marché communautaire** (futur) : taxé à 5% Or
|
||||
|
||||
## **4\. Succès & Hall of Fame**
|
||||
|
||||
* **Succès individuels** : progression, combat, zones, équipement, économie
|
||||
* **Succès communautaires** : chasse, collecte TetardCoin, GIGABOSS
|
||||
* **Hall of Fame Twitch** : classements mensuels, badges, titres, objets rares
|
||||
* **Récompenses** : boosts, objets exclusifs, accès boutiques
|
||||
|
||||
## **5\. Univers & Lore**
|
||||
|
||||
* **Monde** : Aquatique, Têtarastafarisme, Têtard Prophétique.
|
||||
* **Zones** : Marais Originel, Village Limo'Kroak, Temple du Prophète, Lac des Reflets.
|
||||
* **PNJ** : Gorn (sage), Sarna (alchimiste), Brugg (forgeron), Ombre-Têtard.
|
||||
* **GIGABOSS** : boss communautaires, entités anciennes.
|
||||
|
||||
## **6\. Points à Finaliser**
|
||||
|
||||
* **Monétisation** : Valeurs exactes Bits/TetardCoin, abonnements Prime et Lv.1-3.
|
||||
* **PVP** : Aucune info \- clarifier si prévu ou non.
|
||||
* **Stack Technique** : Technologies à préciser.
|
||||
* **Lore dynamique** : Quêtes liées à la religion et aux zones.
|
||||
|
||||
## **7\. Synthèse Finale**
|
||||
|
||||
Le projet est **cohérent, complet et bien articulé**. Les systèmes sont solidement définis. Quelques ajustements et précisions permettront d'amorcer efficacement la **roadmap de développement**.
|
||||
|
||||
80
docs/economy-design.md
Normal file
80
docs/economy-design.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# TetaRdPG — Economy Design : TetardCoin
|
||||
|
||||
> Sprint 3 — Step 1 output
|
||||
> Date : 2026-03-17
|
||||
|
||||
---
|
||||
|
||||
## Taux de conversion
|
||||
|
||||
**10 Bits = 1 TetardCoin (TC)**
|
||||
|
||||
Justification : 100 Bits (cheer le plus courant sur Twitch) → 10 TC = 1 recharge endurance complète. Ni trop abondant (1:1 dévaluerait le TC immédiatement), ni trop rare (100:1 pénaliserait les petits cheers). Valeur implicite ~0,10 USD par TC, ancrée sur le cours Bits Twitch.
|
||||
|
||||
---
|
||||
|
||||
## Rewards Abonnés
|
||||
|
||||
| Tier | TC / mois |
|
||||
|------|-----------|
|
||||
| Prime | 30 TC |
|
||||
| T1 | 50 TC |
|
||||
| T2 | 120 TC |
|
||||
| T3 | 350 TC |
|
||||
|
||||
---
|
||||
|
||||
## Rewards Bits — Seuils de Cheers
|
||||
|
||||
Base : 10 Bits = 1 TC + prime de seuil
|
||||
|
||||
| Seuil | TC crédité | Prime | Note |
|
||||
|-------|-----------|-------|------|
|
||||
| 100 Bits | 10 TC | 0 | Pas de prime — évite le split-cheering |
|
||||
| 500 Bits | 55 TC | +5 TC | ~10% prime |
|
||||
| 1 000 Bits | 115 TC | +15 TC | ~15% prime |
|
||||
| 5 000 Bits | 575 TC | +75 TC | ~15% prime |
|
||||
|
||||
---
|
||||
|
||||
## Utilisations TC
|
||||
|
||||
| Usage | Coût TC | Description |
|
||||
|-------|---------|-------------|
|
||||
| Recharge endurance | 1 TC = +20 pts | Prime volume : 5 TC et 10 TC |
|
||||
| Cosmétiques Twitch | 20 – 150 TC | Titres, cadres, skins limités — rotation mensuelle |
|
||||
| Forge garantie | Max 12 TC | Supprime risque perte matériaux (20-40%) |
|
||||
| Tickets PvP | 5 TC = +3 tickets | Plafond +10 tickets/jour |
|
||||
| Artisanat accéléré | 1 TC = skip 15 min | Max 8 TC pour un craft de 2h |
|
||||
|
||||
---
|
||||
|
||||
## Sink Anti-Inflation
|
||||
|
||||
**Oui — sinks actifs**
|
||||
|
||||
Sinks primaires :
|
||||
- Endurance (consommation quotidienne si actif)
|
||||
- Forge garantie (usage situationnel fort)
|
||||
- Cosmétiques (rotation crée FOMO)
|
||||
- Artisanat accéléré (usage passif régulier)
|
||||
|
||||
Sink secondaire proposé :
|
||||
- Création de guilde : 50 TC
|
||||
- Upgrade guilde (3 niveaux) : 30 / 60 / 100 TC
|
||||
|
||||
---
|
||||
|
||||
## Différenciateur vs StreamElements / Streamlabs Points
|
||||
|
||||
StreamElements/Streamlabs = présence passive → points sans friction → aucune décision.
|
||||
|
||||
TetardCoin = engagement actif → arbitrages réels (forge vs endurance vs guilde) → économie avec tension.
|
||||
|
||||
**C'est la différence entre un programme de fidélité et un jeu.**
|
||||
|
||||
---
|
||||
|
||||
## Prochaines étapes → Step 2
|
||||
|
||||
Implémenter : entité TetardCoin (balance + historique), service de conversion Bits→TC, migrations DB, API endpoints (balance, earn, spend, history), tests invariants économiques.
|
||||
131
docs/engine-design.md
Normal file
131
docs/engine-design.md
Normal 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
432
docs/lore-bible.md
Normal 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
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
3165
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
184
frontend/src/App.css
Normal 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
74
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
frontend/src/api/client.ts
Normal file
60
frontend/src/api/client.ts
Normal 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' }),
|
||||
};
|
||||
93
frontend/src/api/endpoints.ts
Normal file
93
frontend/src/api/endpoints.ts
Normal 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
261
frontend/src/api/types.ts
Normal 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;
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
24
frontend/src/components/Bar.tsx
Normal file
24
frontend/src/components/Bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/CombatViews.tsx
Normal file
91
frontend/src/components/CombatViews.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
frontend/src/components/CreateCharacter.tsx
Normal file
68
frontend/src/components/CreateCharacter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
frontend/src/components/GuideDrawer.tsx
Normal file
185
frontend/src/components/GuideDrawer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/HudBar.tsx
Normal file
121
frontend/src/components/HudBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/Layout.tsx
Normal file
79
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/MonsterCard.tsx
Normal file
29
frontend/src/components/MonsterCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/Onboarding.tsx
Normal file
57
frontend/src/components/Onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/RarityBadge.tsx
Normal file
22
frontend/src/components/RarityBadge.tsx
Normal 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
76
frontend/src/constants.ts
Normal 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' },
|
||||
];
|
||||
49
frontend/src/context/AuthContext.tsx
Normal file
49
frontend/src/context/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
43
frontend/src/hooks/useGuideData.ts
Normal file
43
frontend/src/hooks/useGuideData.ts
Normal 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
157
frontend/src/index.css
Normal 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
110
frontend/src/lib/oauth.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
209
frontend/src/pages/AchievementsPage.tsx
Normal file
209
frontend/src/pages/AchievementsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/pages/AuthCallback.tsx
Normal file
74
frontend/src/pages/AuthCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
frontend/src/pages/CombatPage.tsx
Normal file
221
frontend/src/pages/CombatPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
frontend/src/pages/CraftPage.tsx
Normal file
159
frontend/src/pages/CraftPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
frontend/src/pages/DashboardPage.tsx
Normal file
307
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
frontend/src/pages/ForgePage.tsx
Normal file
200
frontend/src/pages/ForgePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
561
frontend/src/pages/GuidePage.tsx
Normal file
561
frontend/src/pages/GuidePage.tsx
Normal 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 où 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>
|
||||
);
|
||||
}
|
||||
177
frontend/src/pages/InventoryPage.tsx
Normal file
177
frontend/src/pages/InventoryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
frontend/src/pages/LoginPage.tsx
Normal file
77
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/pages/NotFoundPage.tsx
Normal file
29
frontend/src/pages/NotFoundPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
323
frontend/src/pages/QuestPage.tsx
Normal file
323
frontend/src/pages/QuestPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
frontend/src/pages/ShopPage.tsx
Normal file
155
frontend/src/pages/ShopPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
485
frontend/src/pages/TurnCombatPage.tsx
Normal file
485
frontend/src/pages/TurnCombatPage.tsx
Normal 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 +{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';
|
||||
}
|
||||
}
|
||||
275
frontend/src/pages/VillagePage.tsx
Normal file
275
frontend/src/pages/VillagePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
3465
package-lock.json
generated
3465
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -9,13 +9,18 @@
|
||||
"start:prod": "node dist/main",
|
||||
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
|
||||
"seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts",
|
||||
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts"
|
||||
"seed:items": "ts-node -r tsconfig-paths/register src/database/items-seed.ts",
|
||||
"seed:odyssee": "ts-node -r tsconfig-paths/register src/database/seed-odyssee.ts",
|
||||
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"migration:generate": "typeorm-ts-node-commonjs -d src/database/data-source.ts migration:generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.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/throttler": "^5.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
@@ -23,7 +28,7 @@
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"helmet": "^7.0.0",
|
||||
"pg": "^8.11.0",
|
||||
"mysql2": "^3.20.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"typeorm": "^0.3.20"
|
||||
@@ -34,9 +39,29 @@
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/cookie-parser": "^1.4.6",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
||||
43
src/achievement/achievement.controller.ts
Normal file
43
src/achievement/achievement.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
38
src/achievement/achievement.entity.ts
Normal file
38
src/achievement/achievement.entity.ts
Normal 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;
|
||||
}
|
||||
19
src/achievement/achievement.module.ts
Normal file
19
src/achievement/achievement.module.ts
Normal 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 {}
|
||||
123
src/achievement/achievement.service.ts
Normal file
123
src/achievement/achievement.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/achievement/player-achievement.entity.ts
Normal file
46
src/achievement/player-achievement.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -2,24 +2,39 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CharacterModule } from './character/character.module';
|
||||
import { MonsterModule } from './monster/monster.module';
|
||||
import { CombatModule } from './combat/combat.module';
|
||||
import { ItemModule } from './item/item.module';
|
||||
import { MaterialModule } from './material/material.module';
|
||||
import { CraftModule } from './craft/craft.module';
|
||||
import { ForgeModule } from './forge/forge.module';
|
||||
import { EconomyModule } from './economy/economy.module';
|
||||
import { TwitchModule } from './twitch/twitch.module';
|
||||
import { 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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
EventEmitterModule.forRoot(),
|
||||
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
type: 'mysql',
|
||||
url: config.get<string>('DATABASE_URL'),
|
||||
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',
|
||||
}),
|
||||
}),
|
||||
@@ -35,6 +50,19 @@ import { HealthController } from './common/health.controller';
|
||||
CharacterModule,
|
||||
MonsterModule,
|
||||
CombatModule,
|
||||
ItemModule,
|
||||
MaterialModule,
|
||||
CraftModule,
|
||||
ForgeModule,
|
||||
EconomyModule,
|
||||
TwitchModule,
|
||||
AchievementModule,
|
||||
CommunityModule,
|
||||
HallOfFameModule,
|
||||
ProfileModule,
|
||||
QuestModule,
|
||||
ShopModule,
|
||||
NpcModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -4,10 +4,11 @@ import {
|
||||
Get,
|
||||
Body,
|
||||
Res,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Response, Request } from 'express';
|
||||
@@ -30,6 +31,20 @@ export class AuthController {
|
||||
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')
|
||||
@UseGuards(AuthGuard)
|
||||
async getMe(@Req() req: Request & { user: User }) {
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { User } from '../user/user.entity';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthGuard } from './guards/auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([User]),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('SUPER_OAUTH_JWT_SECRET'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthGuard],
|
||||
exports: [AuthGuard, TypeOrmModule],
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -11,62 +9,54 @@ import { Response } from 'express';
|
||||
import { User } from '../user/user.entity';
|
||||
import { SetSessionDto } from './dto/set-session.dto';
|
||||
|
||||
// Payload émis par SuperOAuth
|
||||
interface SuperOAuthPayload {
|
||||
sub: string; // ID provider (Twitch ID, Discord ID…)
|
||||
provider: string; // 'twitch' | 'discord' | 'google' | 'github'
|
||||
username: string;
|
||||
avatar_url?: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
interface SuperOAuthUser {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
email: string | null;
|
||||
nickname: string;
|
||||
isActive: boolean;
|
||||
linkedProviders: string[];
|
||||
}
|
||||
|
||||
const COOKIE_NAME = 'session';
|
||||
const REFRESH_COOKIE_NAME = 'refresh_token';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly superOauthUrl: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'oauthId'>> {
|
||||
let payload: SuperOAuthPayload;
|
||||
|
||||
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é');
|
||||
) {
|
||||
this.superOauthUrl = this.configService.getOrThrow<string>('SUPER_OAUTH_URL');
|
||||
}
|
||||
|
||||
if (!payload.sub || !payload.provider || !payload.username) {
|
||||
throw new UnauthorizedException('Payload JWT incomplet');
|
||||
}
|
||||
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'superOauthId'>> {
|
||||
const oauthUser = await this.introspectToken(dto.token);
|
||||
|
||||
// Upsert user
|
||||
let user = await this.userRepository.findOne({
|
||||
where: { oauthId: payload.sub, provider: payload.provider },
|
||||
where: { superOauthId: oauthUser.id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = this.userRepository.create({
|
||||
oauthId: payload.sub,
|
||||
provider: payload.provider,
|
||||
username: payload.username,
|
||||
avatarUrl: payload.avatar_url ?? null,
|
||||
superOauthId: oauthUser.id,
|
||||
username: oauthUser.nickname,
|
||||
email: oauthUser.email,
|
||||
});
|
||||
} else {
|
||||
user.username = payload.username;
|
||||
user.avatarUrl = payload.avatar_url ?? null;
|
||||
user.username = oauthUser.nickname;
|
||||
user.email = oauthUser.email;
|
||||
}
|
||||
|
||||
await this.userRepository.save(user);
|
||||
|
||||
// Cookie httpOnly signé — valeur = UUID interne
|
||||
// Cookie httpOnly — session = UUID interne
|
||||
const isProduction = this.configService.get('NODE_ENV') === 'production';
|
||||
res.cookie('session', user.id, {
|
||||
res.cookie(COOKIE_NAME, user.id, {
|
||||
httpOnly: true,
|
||||
signed: true,
|
||||
secure: isProduction,
|
||||
@@ -74,16 +64,105 @@ export class AuthService {
|
||||
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;
|
||||
}
|
||||
|
||||
async getMe(user: User): Promise<Omit<User, 'oauthId'>> {
|
||||
const { oauthId: _, ...safeUser } = user;
|
||||
async refreshSession(res: Response, refreshToken: string): Promise<{ success: boolean }> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class SetSessionDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
jwt: string;
|
||||
token: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Request } from 'express';
|
||||
import { CharacterService } from './character.service';
|
||||
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||
import { DistributeStatsDto } from './dto/distribute-stats.dto';
|
||||
import { AuthGuard } from '../auth/guards/auth.guard';
|
||||
import { User } from '../user/user.entity';
|
||||
|
||||
@@ -37,4 +38,19 @@ export class CharacterController {
|
||||
getEndurance(@Req() req: Request & { user: 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,20 @@ import {
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Character } from './entities/character.entity';
|
||||
import { LevelThreshold } from './entities/level-threshold.entity';
|
||||
import { CreateCharacterDto } from './dto/create-character.dto';
|
||||
import { DistributeStatsDto } from './dto/distribute-stats.dto';
|
||||
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 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()
|
||||
export class CharacterService {
|
||||
@@ -21,6 +27,7 @@ export class CharacterService {
|
||||
private readonly characterRepository: Repository<Character>,
|
||||
@InjectRepository(LevelThreshold)
|
||||
private readonly levelThresholdRepository: Repository<LevelThreshold>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
// Pattern lazy calculation — pas de timer actif
|
||||
@@ -31,7 +38,7 @@ export class CharacterService {
|
||||
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 =
|
||||
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');
|
||||
}
|
||||
|
||||
const baseHp = 100 + (dto.vitalite - 1) * 10; // vitalité 1 = 100 HP, chaque point = +10
|
||||
const character = this.characterRepository.create({
|
||||
userId: user.id,
|
||||
name: dto.name,
|
||||
@@ -56,16 +64,37 @@ export class CharacterService {
|
||||
intelligence: dto.intelligence,
|
||||
chance: dto.chance,
|
||||
vitalite: dto.vitalite,
|
||||
hpMax: baseHp,
|
||||
hpCurrent: baseHp,
|
||||
enduranceSaved: 100,
|
||||
lastEnduranceTs: new Date(),
|
||||
enduranceMax: 100,
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findByUser(user: User): Promise<Character & { enduranceCurrent: number }> {
|
||||
return {
|
||||
...saved,
|
||||
enduranceCurrent: this.calculateEndurance(saved),
|
||||
xpToNextLevel: xpRequiredForLevel(saved.level),
|
||||
};
|
||||
}
|
||||
|
||||
async findByUser(user: User) {
|
||||
const character = await this.characterRepository.findOne({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
@@ -74,7 +103,11 @@ export class CharacterService {
|
||||
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(
|
||||
@@ -94,4 +127,106 @@ export class CharacterService {
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
18
src/character/dto/distribute-stats.dto.ts
Normal file
18
src/character/dto/distribute-stats.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -57,6 +57,13 @@ export class Character {
|
||||
@Column({ name: 'hp_max', default: 100 })
|
||||
hpMax: number;
|
||||
|
||||
// Mana du Courant (sorts — combat tour par tour)
|
||||
@Column({ name: 'mana_current', default: 50 })
|
||||
manaCurrent: number;
|
||||
|
||||
@Column({ name: 'mana_max', default: 50 })
|
||||
manaMax: number;
|
||||
|
||||
// Endurance — lazy calculation (pas de timer actif)
|
||||
@Column({ name: 'endurance_saved', default: 100 })
|
||||
enduranceSaved: number;
|
||||
@@ -71,6 +78,13 @@ export class Character {
|
||||
@Column({ name: 'stat_points', default: 0 })
|
||||
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' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../character/entities/character.entity';
|
||||
import { Monster } from '../monster/monster.entity';
|
||||
@@ -15,6 +16,7 @@ export class CombatLog {
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'character_id' })
|
||||
@Index()
|
||||
characterId: string;
|
||||
|
||||
@ManyToOne(() => Character)
|
||||
@@ -35,7 +37,7 @@ export class CombatLog {
|
||||
totalRounds: number;
|
||||
|
||||
// Stocker les rounds en JSON — lecture replay
|
||||
@Column({ name: 'rounds_data', type: 'jsonb' })
|
||||
@Column({ name: 'rounds_data', type: 'json' })
|
||||
roundsData: object;
|
||||
|
||||
@Column({ name: 'xp_earned', default: 0 })
|
||||
@@ -47,6 +49,12 @@ export class CombatLog {
|
||||
@Column({ name: 'level_up', default: false })
|
||||
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' })
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ export class CombatController {
|
||||
@Body() dto: StartCombatDto,
|
||||
@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);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ function statForAttackType(stats: CombatantStats): number {
|
||||
|
||||
export function calcPlayerDamage(player: CombatantStats, monsterDefense: number): number {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -181,9 +181,10 @@ export function applyXpGain(currentLevel: number, currentXp: number, xpEarned: n
|
||||
let xp = currentXp + xpEarned;
|
||||
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) {
|
||||
const required = xpRequiredForLevel(level + 1);
|
||||
const required = xpRequiredForLevel(level);
|
||||
if (xp >= required) {
|
||||
xp -= required;
|
||||
level++;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user