diff --git a/.github/workflows/check-branch-name.yml b/.github/workflows/check-branch-name.yml deleted file mode 100644 index aa12485..0000000 --- a/.github/workflows/check-branch-name.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Check branch name - -on: - pull_request: - -jobs: - check-branch-name: - runs-on: ubuntu-latest - - steps: - - name: Validate branch name - run: | - BRANCH_NAME="${GITHUB_HEAD_REF}" - - echo "🔎 Vérification du nom de la branche" - echo "➡️ Branche détectée : $BRANCH_NAME" - echo "" - - REGEX="^(main|dev|feat\/.+|feature\/.+|fix\/.+|hotfix\/.+)$" - - if [[ ! "$BRANCH_NAME" =~ $REGEX ]]; then - echo "❌ Nom de branche invalide" - echo "" - echo "👉 Branche détectée : $BRANCH_NAME" - echo "" - echo "📌 Conventions de nommage attendues :" - echo " - main" - echo " - dev" - echo " - feat/ma-feature ou feature/ma-feature" - echo " - fix/mon-correctif" - echo " - hotfix/incident-production" - echo "" - echo "🛠️ Action attendue :" - echo " - renomme ta branche localement" - echo " - puis pousse-la à nouveau avant de créer la PR" - echo "" - echo "ℹ️ Exemple :" - echo " git branch -m ancienne-branche feat/ma-feature" - echo " git push origin feat/ma-feature" - exit 1 - fi - - echo "✅ Nom de branche conforme aux conventions" - echo "🚀 Tu peux continuer avec la Pull Request" diff --git a/.github/workflows/check-branch-rules.yml b/.github/workflows/check-branch-rules.yml deleted file mode 100644 index 592cf63..0000000 --- a/.github/workflows/check-branch-rules.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Check branch rules - -on: - pull_request: - -jobs: - check-branch-rules: - runs-on: ubuntu-latest - - steps: - - name: Validate branch name and flow - run: | - SOURCE_BRANCH="${GITHUB_HEAD_REF}" - TARGET_BRANCH="${GITHUB_BASE_REF}" - - echo "🔎 Analyse de la Pull Request" - echo "➡️ Branche source : $SOURCE_BRANCH" - echo "➡️ Branche cible : $TARGET_BRANCH" - echo "" - - NAME_REGEX="^(main|dev|feat\/.+|feature\/.+|fix\/.+|hotfix\/.+)$" - - if [[ ! "$SOURCE_BRANCH" =~ $NAME_REGEX ]]; then - echo "❌ Nom de branche source invalide" - echo "" - echo "👉 Branche détectée : $SOURCE_BRANCH" - echo "👉 Formats autorisés :" - echo " - main" - echo " - dev" - echo " - feat/ma-feature ou feature/ma-feature" - echo " - fix/mon-correctif" - echo " - hotfix/incident-production" - echo "" - echo "🛠️ Action attendue : renomme ta branche avant de créer la PR." - exit 1 - fi - - if [[ ! "$TARGET_BRANCH" =~ $NAME_REGEX ]]; then - echo "❌ Nom de branche cible invalide" - echo "" - echo "👉 Branche cible détectée : $TARGET_BRANCH" - echo "👉 Seules les branches standards sont autorisées comme cible (main, dev)." - echo "" - echo "🛠️ Action attendue : modifie la branche cible de la PR." - exit 1 - fi - - if [[ "$SOURCE_BRANCH" =~ ^(feat\/|feature\/) && "$TARGET_BRANCH" != "dev" ]]; then - echo "❌ Règle de workflow non respectée" - echo "" - echo "👉 Une branche de feature doit être fusionnée dans dev." - echo "👉 Actuel : $SOURCE_BRANCH → $TARGET_BRANCH" - echo "" - echo "🛠️ Action attendue : change la cible de la PR vers dev." - exit 1 - fi - - if [[ "$SOURCE_BRANCH" =~ ^fix\/ && ! "$TARGET_BRANCH" =~ ^(feat\/|feature\/) ]]; then - echo "❌ Règle de workflow non respectée" - echo "" - echo "👉 Une branche fix sert à corriger une feature." - echo "👉 Elle doit donc cibler la branche feature correspondante." - echo "👉 Actuel : $SOURCE_BRANCH → $TARGET_BRANCH" - echo "" - echo "🛠️ Action attendue : cible la branche feat/* ou feature/* concernée." - exit 1 - fi - - if [[ "$SOURCE_BRANCH" =~ ^hotfix\/ && "$TARGET_BRANCH" != "main" ]]; then - echo "❌ Règle de workflow non respectée" - echo "" - echo "👉 Une branche hotfix corrige un problème en production." - echo "👉 Elle doit être fusionnée directement dans main." - echo "👉 Actuel : $SOURCE_BRANCH → $TARGET_BRANCH" - echo "" - echo "🛠️ Action attendue : change la cible de la PR vers main." - exit 1 - fi - - if [[ "$SOURCE_BRANCH" == "dev" && "$TARGET_BRANCH" != "main" ]]; then - echo "❌ Règle de workflow non respectée" - echo "" - echo "👉 La branche dev ne peut être fusionnée que dans main." - echo "👉 Actuel : dev → $TARGET_BRANCH" - echo "" - echo "🛠️ Action attendue : change la cible de la PR vers main." - exit 1 - fi - - echo "✅ Toutes les règles de branches sont respectées" - echo "🚀 La Pull Request peut être revue et fusionnée" diff --git a/.github/workflows/check-commit-message.yml b/.github/workflows/check-commit-message.yml deleted file mode 100644 index abb8694..0000000 --- a/.github/workflows/check-commit-message.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Check PR title - -on: - pull_request: - types: [opened, edited, synchronize] - -jobs: - check-pr-title: - runs-on: ubuntu-latest - - steps: - - name: Validate PR title - run: | - PR_TITLE="${{ github.event.pull_request.title }}" - - echo "🔎 Vérification du titre de la Pull Request" - echo "------------------------------------------" - echo "$PR_TITLE" - echo "" - - REGEX="^(feat|fix|hotfix|docs|refactor|test|chore)(\([a-zA-Z0-9_-]+\))?: .+" - - if [[ ! "$PR_TITLE" =~ $REGEX ]]; then - echo "❌ Titre de PR non conforme" - echo "" - echo "👉 Format attendu :" - echo " type(scope): description courte" - echo "" - echo "👉 Types autorisés : feat, fix, hotfix, docs, refactor, test, chore" - echo "" - echo "📌 Exemples valides :" - echo " docs(database): ajout du dictionnaire" - echo " fix(auth): correction login" - exit 1 - fi - - if ! echo "$PR_TITLE" | grep -qiE "(fix|fixes|closes|resolves) #[0-9]+"; then - echo "⚠️ Aucun lien avec une issue détecté" - echo "" - echo "👉 Bonne pratique recommandée :" - echo " ajoute dans le titre : Fixes #" - fi - - echo "✅ Titre de PR conforme" diff --git a/.github/workflows/check-docs-folder.yml b/.github/workflows/check-docs-folder.yml deleted file mode 100644 index 8081354..0000000 --- a/.github/workflows/check-docs-folder.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Check docs folder - -on: - pull_request: - -jobs: - check-docs-folder: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Verify /docs folder existence - run: | - echo "🔎 Vérification de la documentation du projet" - echo "" - - if [ ! -d "docs" ]; then - echo "❌ Dossier /docs manquant" - echo "" - echo "👉 Le projet doit contenir un dossier /docs à la racine du dépôt." - echo "" - echo "📚 Ce dossier est destiné à regrouper :" - echo " - le cahier des charges" - echo " - la documentation fonctionnelle" - echo " - la documentation technique" - echo "" - echo "🛠️ Action attendue :" - echo " 1. Crée le dossier : mkdir docs" - echo " 2. Ajoute au minimum un fichier .txt ou .md" - echo " 3. Commit et pousse les changements" - echo "" - exit 1 - fi - - echo "✅ Dossier /docs trouvé" - echo "📘 La documentation du projet est bien structurée" diff --git a/.github/workflows/check-required-files.yml b/.github/workflows/check-required-files.yml deleted file mode 100644 index 09ea766..0000000 --- a/.github/workflows/check-required-files.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Check required files - -on: - push: - branches: ["**"] - pull_request: - -jobs: - check-files: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Check required files - run: | - REQUIRED_FILES=( - "README.md" - "INSTALL.md" - "CHANGELOG.md" - "CONTRIBUTING.md" - "LICENSE" - ".env.example" - "SECURITY.md" - "CODE_OF_CONDUCT.md" - "requirements.txt" - ) - - MISSING=0 - - echo "🔍 Checking required files..." - for file in "${REQUIRED_FILES[@]}"; do - if [ ! -f "$file" ]; then - echo "❌ Missing file: $file" - MISSING=1 - else - echo "✅ Found: $file" - fi - done - - if [ $MISSING -ne 0 ]; then - echo "" - echo "🚨 One or more required files are missing." - exit 1 - fi - - echo "" - echo "🎉 All required files are present!" diff --git a/.github/workflows/close-issues-on-dev.yml b/.github/workflows/close-issues-on-dev.yml deleted file mode 100644 index 90f0f89..0000000 --- a/.github/workflows/close-issues-on-dev.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Close issues on dev merge - -on: - pull_request: - types: [closed] - branches: - - dev - -jobs: - close-issues: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - - steps: - - name: Close linked issues - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - const body = `${pr.title}\n${pr.body || ""}`; - - const issueRegex = /(fixes|closes|resolves)\s+#(\d+)/gi; - const matches = [...body.matchAll(issueRegex)]; - - if (matches.length === 0) { - console.log("No linked issues found."); - return; - } - - for (const match of matches) { - const issueNumber = match[2]; - console.log(`Closing issue #${issueNumber}`); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - state: "closed" - }); - } diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..264a494 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,36 @@ +name: Deploy to VPS + +on: + push: + branches: + - main + paths: + - 'worldboss-app/**' + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + script: | + cd /var/www + + # Pull du code + if [ ! -d "WorldBoss" ]; then + git clone https://github.com/${{ github.repository }} WorldBoss + else + git -C WorldBoss pull origin main + fi + + # Rebuild uniquement les services WorldBoss + docker compose build worldboss-bot + docker compose up -d --no-deps worldboss-bot worldboss-db worldboss-redis + + # Nettoyage des images orphelines + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c8465c --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# ── Environnement ───────────────────────────────────────────────────────────── +.env +.env.* +!.env.example + +# ── Dépendances ─────────────────────────────────────────────────────────────── +node_modules/ +.npm/ +.yarn/ + +# ── Logs ────────────────────────────────────────────────────────────────────── +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# ── Build / dist ────────────────────────────────────────────────────────────── +dist/ +build/ +out/ + +# ── Prisma ──────────────────────────────────────────────────────────────────── +# Garder schema.prisma et les migrations, ignorer la DB locale si utilisée +*.db +*.db-journal +*.sqlite +*.sqlite-journal + +# ── Docker ──────────────────────────────────────────────────────────────────── +# Garder les fichiers Docker mais ignorer les volumes locaux +docker-volumes/ + +# ── OS ──────────────────────────────────────────────────────────────────────── +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# ── Éditeurs ────────────────────────────────────────────────────────────────── +.vscode/settings.json +.idea/ +*.swp +*.swo +*~ + +# ── Tests / coverage ────────────────────────────────────────────────────────── +coverage/ +.nyc_output/ + +# ── Misc ────────────────────────────────────────────────────────────────────── +*.pem +*.key +*.cert diff --git a/docs/cahier_des_charges.txt b/docs/cahier_des_charges.txt deleted file mode 100644 index e69de29..0000000 diff --git a/docs/worldboss.md b/docs/worldboss.md new file mode 100644 index 0000000..96079f8 --- /dev/null +++ b/docs/worldboss.md @@ -0,0 +1,652 @@ +# WorldBoss — Design Document + +> Bot Discord RPG coopératif. Un serveur = une guilde. Les membres combattent ensemble le World Boss hebdomadaire, progressent en solo dans des donjons, et s'affrontent en PvP. + +--- + +## Sommaire + +1. [Architecture technique](#1-architecture-technique) +2. [Base de données](#2-base-de-données) +3. [Création du joueur](#3-création-du-joueur) +4. [Système de donjon solo](#4-système-de-donjon-solo) +5. [World Boss coopératif](#5-world-boss-coopératif) +6. [Déblocages par niveau](#6-déblocages-par-niveau) +7. [Shop](#7-shop) +8. [Progression narrative](#8-progression-narrative) +9. [Modes de jeu](#9-modes-de-jeu) +10. [Philosophie du game design](#10-philosophie-du-game-design) +11. [Système d'équipement](#11-système-déquipement) +12. [Stats & Formules de combat](#12-stats--formules-de-combat) +13. [Système de rareté & loot](#13-système-de-rareté--loot) +14. [Base d'items (MVP)](#14-base-ditems-mvp) +15. [Économie](#15-économie) +16. [Quêtes](#16-quêtes) +17. [Système PvP](#17-système-pvp) +18. [Guildes](#18-guildes) +19. [Classements & Hauts faits](#19-classements--hauts-faits) +20. [Flow global du jeu](#20-flow-global-du-jeu) +21. [Roadmap](#21-roadmap) + +--- + +## 1. Architecture technique + +| Composant | Techno | Rôle | +|---|---|---| +| Bot Discord | Node.js · discord.js v14 | Interface utilisateur + logique de jeu | +| Base de données | PostgreSQL | Persistance de toutes les données | +| ORM | Prisma v5 | Accès type-safe à la DB | +| Cache temps réel | Redis (ioredis v5) | État combat/donjon en session active | +| Workers | BullMQ v5 | Jobs async (combat, loot, donjons) | + +### Flux global + +``` +Interaction Discord (slash command / bouton) + ↓ +discord.js — interactionCreate.js + ↓ +Command handler → Service (combat, donjon, boss…) + ↓ +Engine (pure logic, no I/O) Redis (état temps réel) + ↓ +Prisma → PostgreSQL (persistance) + ↓ +Embed Discord (réponse au joueur) +``` + +### Structure du projet + +``` +worldboss-app/ +├── prisma/ +│ └── schema.prisma +├── src/ +│ ├── index.js # Entrée — charge commandes + events auto +│ ├── deploy-commands.js # Enregistrement slash commands Discord +│ ├── commands/ +│ │ ├── admin/ +│ │ │ └── setup.js # /setup — init channels (admin) +│ │ ├── boss/ +│ │ │ └── worldboss.js # /worldboss status|attack|leaderboard +│ │ ├── dungeon/ +│ │ │ └── dungeon.js # /dungeon +│ │ ├── inventory/ +│ │ │ ├── inventory.js # /inventory +│ │ │ └── equip.js # /equip +│ │ └── player/ +│ │ ├── start.js # /start +│ │ └── profile.js # /profile +│ ├── events/ +│ │ ├── ready.js # Seed items au démarrage +│ │ ├── interactionCreate.js # Route commandes + boutons +│ │ ├── guildCreate.js # Join serveur → init DB + channels Discord +│ │ ├── guildDelete.js # Quitte serveur → nettoyage DB +│ │ └── guildMemberAdd.js # Nouveau membre → message de bienvenue +│ ├── services/ +│ │ ├── player.service.js # CRUD joueur, XP, seed items +│ │ ├── inventory.service.js # Items, loadout +│ │ ├── combat.service.js # Combat donjon (orchestre engine + Redis) +│ │ ├── dungeon.service.js # Progression donjon (salles, statut) +│ │ ├── guild.service.js # Init serveur, channels Discord +│ │ └── worldboss.service.js # Boss coopératif par serveur +│ ├── engines/ +│ │ ├── combatEngine.js # Formules dégâts, initiative, tours (pure logic) +│ │ ├── dungeonEngine.js # Salles, ennemis, état (pure logic) +│ │ └── lootEngine.js # Distribution loot (pure logic) +│ ├── data/ +│ │ ├── items/ # Catalogue items (weapons, armors, helmets, boots, accessories, consumables) +│ │ ├── enemies/ # Templates ennemis par arc (arc1–arc8) +│ │ ├── skills/ # Skills actifs (offensive, defensive, support) +│ │ ├── passives/ # Passifs (offensive, defensive) +│ │ ├── abilities/ # Abilities ennemies par arc +│ │ └── dungeons.js # Définition donjons + alliés NPC +│ ├── cache/ +│ │ └── redis.js # Helpers Redis (combat TTL 30min, donjon 2h) +│ ├── db/ +│ │ └── prisma.js # Singleton PrismaClient +│ └── utils/ +│ ├── stats.js # Calcul stats joueur (HP, ATK, DEF, SPD, CRIT) +│ └── embed.js # Builders d'embeds Discord +``` + +--- + +## 2. Base de données + +Schéma complet dans [`prisma/schema.prisma`](../worldboss-app/prisma/schema.prisma). + +### Diagramme des relations + +``` +User ──────────────── UserItem (inventaire) + │ └── Item + ├── Loadout (équipement actif) + └── DungeonRun (historique donjons) + +Guild ─────────────── GuildChannels (IDs channels Discord) + └── WorldBoss ────── WorldBossParticipant (dégâts/hits par joueur) +``` + +### Tables principales + +| Table | Clé | Description | +|---|---|---| +| `User` | Discord user ID | Personnage global (niveau, XP, gold, HP courant) | +| `Item` | slug (ex: `sword_rusty`) | Catalogue d'items avec stats JSON et skills | +| `UserItem` | userId + itemId | Inventaire joueur (quantité) | +| `Loadout` | userId | Équipement actif (weapon, armor, helmet, boots, 2× accessory) | +| `DungeonRun` | autoincrement | Run actif ou terminé par joueur | +| `Guild` | Discord guild ID | Serveur enregistré (nom, owner) | +| `GuildChannels` | guildId | IDs catégorie + channels WorldBoss du serveur | +| `WorldBoss` | guildId | Boss hebdomadaire du serveur (HP, ATK, DEF, phase, statut) | +| `WorldBossParticipant` | bossId + userId | Dégâts cumulés + nb de frappes par joueur | + +### Cache Redis + +``` +combat:{userId} → état du combat en cours (TTL 30 min) +dungeon:{userId} → session de donjon (TTL 2 h) +``` + +--- + +## 3. Création du joueur + +Commande `/start` — crée un personnage lié au serveur (userId + guildId). + +- Niveau 1, stats de départ : 120 HP · 12 ATK · 6 DEF · 10 SPD +- **Aucun item de départ** — les items s'obtiennent via les donjons et le marché +- Accès immédiat : Donjon Solo + World Boss du serveur + +--- + +## 4. Système de donjon solo + +Commande `/dungeon` — combat salle par salle avec boutons d'action. + +3 donjons disponibles dans le MVP (arc Catacombes) — voir [dungeon.md](worldboss/dungeon.md) pour le détail complet. + +| Donjon | Niveau | Salles | Récompense | +|---|---|---|---| +| Les Catacombes | 1 | skeleton / skeleton_archer / skeleton×2+archer | — | +| Les Catacombes Pt.2 | 2 | skeleton+archer / skeleton_mage / skeleton+mage | — | +| Les Catacombes Pt.3 | 3 | skeleton+mage / skeleton+archer / 4 ennemis + Aldric | Débloque le marché | + +### Actions en combat + +| Action | Effet | +|---|---| +| ⚔️ Attaquer | Attaque normale (×1.0) + déclenche tous les passifs équipés | +| 🔥 Skill | Selon skill de l'item (multiplicateur + effet spécial, cooldown) | +| 🎒 Consommable | Select menu — choisir un item de l'inventaire | +| 🏃 Fuir | 40% de base + bonus SPD relatif | + +### Sessions Redis (TTL 2h) + +- `combat:{characterId}` — HP, ennemis, buffs/DoTs, cooldowns, consommables +- `dungeon:{characterId}` — salle courante, ennemis combattus, progression + +### Loot donjon + +Chaque ennemi contribue **un candidat** à une pool d'items (tiré depuis sa table de loot). À la fin du donjon, **un seul item** est tiré au sort parmi tous les candidats. + +--- + +## 5. World Boss coopératif + +Commande `/worldboss` — boss partagé par serveur, réinitialisé chaque semaine. + +### Sous-commandes + +| Commande | Description | +|---|---| +| `/worldboss status` | Affiche HP, phase, ATK, DEF, nombre de participants | +| `/worldboss attack` | Attaque le boss (utilise les stats du personnage) | +| `/worldboss leaderboard` | Top 10 des participants par dégâts | + +### Phases de combat + +| Phase | Condition | Multiplicateur ATK boss | +|---|---|---| +| Normal | HP > 30% | ×1.0 | +| Enragé | HP ≤ 30% | ×1.25 | +| Berserk | HP ≤ 15% | ×1.5 | + +### Scaling par niveau + +À chaque reset, si le boss a été vaincu, il monte d'un niveau : + +``` +HP max = 5000 × 1.4^(niveau − 1) +ATK = 80 × 1.2^(niveau − 1) +DEF = 40 × 1.2^(niveau − 1) +``` + +### Boss thématiques par palier + +| Niveaux | Biome | Noms | +|---|---|---| +| 1–3 | Catacombes | Seigneur des Ossements → Liche Écarlate → Roi Squelette Maudit | +| 4–6 | Forêt Corrompue | Sylvanide Corrompu → Chasseur Maudit → Esprit de la Forêt Noire | +| 7+ | Citadelle des Ténèbres | Seigneur des Ténèbres | + +### Setup automatique (guildCreate) + +Quand le bot rejoint un serveur, il crée automatiquement : +- Une catégorie **WorldBoss** +- `📜 wb-annonces` — infos, bienvenues, résultats +- `⚔️ wb-raid` — attaques du boss +- `🍺 wb-taverne` — marchand, quêtes (futur) + +Et stocke les IDs en base (`GuildChannels`). + +--- + +## 6. Déblocages par niveau + +| Niveau | Contenu débloqué | +|---|---| +| 1 | Donjon Solo 1 + World Boss | +| 2 | Donjon Solo 2 | +| 3 | Donjon Solo 3 (+ marché si terminé) | +| 5 | Donjon coopératif 3v3 · Arc 2 | +| 10 | Infinite Dungeon · Arc 3 | +| 15 | Arc 4 | +| 20 | Arc 5 | +| 25 | Arc 6 | +| 32 | Arc 7 | +| 40 | Arc 8 | +| 50 | PvP ranked + Guild Wars | + +--- + +## 7. Shop + +Débloqué au niveau 2. Stock limité, refresh toutes les 6h. Items scalés selon le niveau du joueur. Prix en gold. + +> Le shop complète le donjon — il ne le remplace pas. + +--- + +## 8. Progression narrative + +### Chapitre 1 — Les Squelettes (lvl 1–5) +Biome : Catacombes / Cryptes. Boss : **Roi des Squelettes** — invoque des renforts à 50% HP. + +### Chapitre 2 — Les Morts-Vivants (lvl 6–10) +Biome : Marais maudit / Forêt corrompue. Boss : **Le Nécromancien** — ressuscite les ennemis vaincus une fois. + +### Chapitre 3 — Retour au Château (lvl 11–15) +Biome : Château royal. Boss : **L'Assassin du Roi** — phase 2 enrage après la mort du roi. Révélation : l'Empire du Désert commandite l'assassinat. + +### Chapitre 4 — Le Désert de l'Empire (lvl 16–20) +Biome : Désert brûlant / Ruines ensablées. Boss : **Le Général de l'Empire** — vagues de soldats. + +### Chapitre 5 — Les Profondeurs (lvl 21–25) +Biome : Mines / Cité engloutie. Boss : **Le Gardien Ancien** — colosse à points faibles. + +### Chapitre 6 — L'Arc Steampunk (lvl 26–32) +Biome : Cité industrielle / Usines à vapeur. Boss : **Le Maître Ingénieur** — multi-phases, tourelles. + +### Chapitre 7 — L'Arc Médiéval (lvl 33–40) +Biome : Forteresses / Villages assiégés. Boss : **Le Seigneur de Guerre Éternel** — résurrection à 20% HP. + +### Chapitre 8 — L'Arc Céleste (lvl 41–50) +Biome : Cités flottantes / Temples célestes. Boss final : **L'Empereur des Sables** — 3 phases. Débloque le PvP. + +--- + +## 9. Modes de jeu + +| Mode | Condition | Description | +|---|---|---| +| Solo | dès lvl 1 | Donjons progressifs, histoire principale | +| World Boss | dès lvl 1 | Raid coopératif hebdomadaire par serveur | +| Coopératif 3v3+ | lvl 5 | Donjons plus difficiles, synergies d'équipe | +| Infinite Dungeon | lvl 10 | Salles en boucle, difficulté croissante, run à la mort | +| PvP | lvl 50 | 1v1 / 2v2 / 3v3 / 4v4, casual + ranked ELO | + +--- + +## 10. Philosophie du game design + +- **Builds libres** via équipement uniquement — pas de classe fixe +- Le niveau débloque du **contenu**, pas des stats majeures +- **Roguelike** : donjons rejouables, loot à chaque run +- **Progression narrative** forte par paliers +- **PvP endgame** comme objectif long terme +- **Coopération** liée à la mécanique Discord (serveur = guilde) +- Jeu gratuit — cosmétiques optionnels payants (skins, titres, effets) + +--- + +## 11. Système d'équipement + +| Slot | Rôle | +|---|---| +| Arme | ATK · skill actif · passif offensif | +| Armure | HP · DEF · passif défensif | +| Casque | DEF · ATK · CRIT | +| Bottes | SPD · DEF | +| Accessoire 1 | HP · CRIT · skill / passif | +| Accessoire 2 | HP · CRIT · skill / passif | + +Chaque item équipé peut apporter : +- Des **bonus de stats** (`stats: { atk, def, hp, spd, crit, critMult }`) +- Un ou plusieurs **skills actifs** (cooldown, once per combat) +- Un ou plusieurs **passifs** (déclenchés sur chaque attaque basique) + +Un joueur peut avoir simultanément plusieurs skills et passifs provenant de slots différents. + +--- + +## 12. Stats & Formules de combat + +### Stats de base (lvl 1, sans équipement) + +| Stat | Valeur lvl 1 | Scaling | +|---|---|---| +| HP | 120 | +20/niveau | +| ATK | 12 | +2/niveau | +| DEF | 6 | +1/niveau | +| SPD | 10 | +1 tous les 5 niveaux | +| CRIT% | 0% | Via équipement uniquement | +| CRIT_MULT | 1.5× | Via équipement uniquement | + +### Formules + +``` +rawDmg = ATK × skillMultiplier × (100 / (100 + DEF)) +finalDmg = rawDmg × critMult (si critique, sinon ×1) +isCrit = random(0,100) < CRIT% +``` + +Implémenté dans `src/engines/combatEngine.js` — fonctions `rawDamage()` et `applyCrit()`. + +### Initiative + +``` +Initiative = SPD + Random(0, SPD × 0.1) +``` + +Léger RNG, SPD reste dominant. + +### Effets additionnels + +- **DOT** (poison, brûlure) : X dégâts/tour pendant N tours +- **Shield** : absorbe X dégâts avant les HP +- **Stun** : l'ennemi passe son tour +- **Buffs/Debuffs** : multiplicateurs temporaires (décroissent chaque tour) + +### Ajustement PvP + +``` +Dégâts PvP = Dégâts normaux × 0.75 +``` + +Évite les one-shots, favorise la stratégie. + +--- + +## 13. Système de rareté & loot + +| Rareté | Couleur | Contenu typique | +|---|---|---| +| Commun | ⚪ | Stats simples, aucun effet | +| Rare | 🔵 | Stats + 1 passif ou skill de base | +| Épique | 🟣 | Stats solides + skill offensif | +| Légendaire | 🟠 | Stats hautes + skill + passif unique (build-defining) | + +### Mécanique de loot donjon + +À la fin d'un donjon complet : +1. Chaque ennemi contribue **un candidat** à la pool (tiré aléatoirement depuis sa table `loot[]`) +2. **Un seul item** est sélectionné au hasard dans toute la pool +3. L'item est ajouté à l'inventaire du joueur + +Plus il y a d'ennemis dans le run → plus la pool est diversifiée → plus de chances d'obtenir un item rare. + +--- + +## 14. Base d'items + +Catalogue complet dans `src/data/items/`. 69 items répartis en 6 fichiers. + +### Armes — 28 items + +| Item | Rareté | Stats | Skill | Passif | +|---|---|---|---|---| +| `sword_rusty` | Commun | +3 ATK | — | — | +| `sword_iron` | Commun | +6 ATK | — | — | +| `bow_short` | Commun | +5 ATK, +1 SPD | — | — | +| `staff_wood` | Commun | +4 ATK | — | — | +| `sword_steel` | Rare | +10 ATK, +2% CRIT | — | — | +| `axe_iron` | Rare | +12 ATK | — | — | +| `staff_fire` | Rare | +6 ATK | `firebolt` | — | +| `bow_long` | Rare | +8 ATK, +2 SPD | — | — | +| `staff_bone` | Rare | +7 ATK | `bone_bolt` | — | +| `dagger_shadow` | Épique | +9 ATK, +5% CRIT | — | `poison_dot` | +| `sword_ancient` | Épique | +14 ATK | — | `bleed` | +| `crossbow_steel` | Épique | +12 ATK, +3 SPD | — | — | +| `ring_shadow` | Épique | skill uniquement | `shadow_burst` | — | +| `scimitar_gold` | Épique | +13 ATK, +3% CRIT | — | — | +| `staff_desert` | Épique | +10 ATK | `sand_storm` | — | +| `whip_venom` | Épique | +11 ATK | — | `poison_dot` | +| `rifle_steam` | Épique | +15 ATK, +2 SPD | — | — | +| `sword_pharaoh` | Légendaire | +18 ATK, +5% CRIT | `royal_smite` | `cursed_strike` | +| `staff_royal` | Légendaire | +14 ATK | `thunder_bolt` | `fire_dot` | +| `sword_hell` | Légendaire | +22 ATK, +8% CRIT | `hellstrike` | `fire_dot` | +| `staff_infernal` | Légendaire | +18 ATK | `inferno_blast` | `fire_dot` | +| `axe_infernal` | Légendaire | +25 ATK | — | `bleed` | + +### Armures — 31 items (Arc 1–8) + +Exemples représentatifs : + +| Item | Arc | Rareté | Stats | +|---|---|---|---| +| `cloth_simple` | 1 | Commun | +5 HP, +1 DEF | +| `cloth_leather` | 1 | Commun | +10 HP, +2 DEF | +| `robe_cloth` | 1 | Commun | +8 HP, +1 DEF, +1 ATK | +| `armor_iron` | 2 | Rare | +25 HP, +6 DEF | +| `robe_mage` | 2 | Rare | +15 HP, +3 DEF, +3 ATK | +| `desert_robe` | 5 | Épique | +35 HP, +8 DEF, +2 ATK | +| `steam_armor` | 6 | Épique | +50 HP, +12 DEF | +| `royal_armor` | 7 | Légendaire | +65 HP, +18 DEF, +3 ATK | +| `infernal_plate` | 8 | Légendaire | +100 HP, +30 DEF | + +### Casques — 10 items + +| Item | Rareté | Stats notables | +|---|---|---| +| `helmet_broken` | Commun | +2 DEF | +| `helmet_iron` | Rare | +5 DEF, +1 ATK | +| `circlet_bone` | Rare | +3 DEF, +2 ATK | +| `hood_shadow` | Épique | +4 DEF, +3 ATK, +3% CRIT | +| `turban_desert` | Épique | +5 DEF, +2 ATK, +2 SPD | +| `goggles_steam` | Épique | +6 DEF, +4 ATK | +| `crown_pharaoh` | Légendaire | +8 DEF, +6 ATK, +5% CRIT | +| `helm_infernal` | Légendaire | +10 DEF, +8 ATK, +5% CRIT | + +### Bottes — 8 items + +| Item | Rareté | Stats notables | +|---|---|---| +| `boots_leather` | Commun | +2 SPD | +| `boots_iron` | Rare | +3 SPD, +1 DEF | +| `boots_shadow` | Épique | +5 SPD, +2 ATK | +| `sandals_desert` | Épique | +5 SPD, +1 DEF | +| `boots_steam` | Épique | +6 SPD, +2 DEF | +| `boots_infernal` | Légendaire | +10 SPD, +4 ATK | + +### Accessoires — 12 items + +| Item | Rareté | Stats | Skill | Passif | +|---|---|---|---|---| +| `ring_wood` | Commun | +5 HP | — | — | +| `ring_power` | Rare | +3 ATK, +3% CRIT | — | — | +| `amulet_bone` | Rare | +10 HP, +2 ATK | — | — | +| `talisman_old` | Rare | +8 HP | `soin` | — | +| `ring_shadow` | Épique | +5 ATK, +5% CRIT | `shadow_burst` | — | +| `scarab_beetle` | Épique | +8 ATK, +3% CRIT | — | — | +| `ankh_pharaoh` | Légendaire | +15 HP, +5 ATK | `divine_heal` | — | +| `pendant_soul` | Légendaire | +20 HP, +8 ATK | `soul_drain` | `life_steal` | + +### Consommables — 16 items + +| Item | Effet | Hors combat | Infini | +|---|---|---|---| +| `potion_heal` | +30 HP | ✅ | — | +| `potion_mana` | +15 HP | ✅ | — | +| `elixir_berserk` | ATK +15 pendant 3 tours | — | — | +| `elixir_iron` | DEF +10 pendant 3 tours | — | — | +| `bomb` | 25 dégâts AoE | — | — | +| `smoke_bomb` | Stun 1 tour | — | — | +| `antidote` | Supprime tous les DoTs | ✅ | — | +| `scroll_fire` | 40 dégâts feu | — | — | +| `throwing_knife` | 15 dégâts directs | — | — | +| `potion_ap` | +3 PA | ✅ | — | + +--- + +## 15. Économie + +- **Gold** gagné en donjons, quêtes, PvP +- **Shop** : achat avec gold, stock limité, refresh 6h +- **Loot** : drop après salles et boss (choix guidé après boss) +- Pas de crafting dans le MVP + +--- + +## 16. Quêtes + +- Journalières et hebdomadaires +- Types : tuer X ennemis, finir un donjon, blesser le boss, gagner en PvP +- Récompenses : gold, XP, items + +--- + +## 17. Système PvP + +| Format | Mode | +|---|---| +| 1v1 | Ranked + Casual | +| 2v2 | Casual | +| 3v3 | Ranked + Casual | +| 4v4 | Casual / Guild Wars | + +- Matchmaking ELO pour le ranked +- Saisons avec reset partiel et récompenses cosmétiques +- Équilibrage PvP distinct du PvE (×0.75) + +--- + +## 18. Guildes + +- 1 serveur Discord = 1 guilde (table `Guild`) +- Progression collective (niveau, XP, victoires) +- Bonus passifs pour les membres +- Channels dédiés créés automatiquement (`GuildChannels`) +- Guild Wars inter-serveurs (planifié Phase 4) + +--- + +## 19. Classements & Hauts faits + +**Joueurs** : niveau · gear score · victoires PvP/ELO · record Infinite Dungeon · plus gros crit · dégâts World Boss + +**Guildes** : XP totale · victoires PvP · classement Guild Wars · boss vaincus + +**Achievements** : atteindre lvl X · finir un donjon sans mourir · top dégâts boss · gagner 100 PvP… + +**Rôles Discord automatiques** · Titres : "Tueur de Dragons", "Champion des Arènes"… + +--- + +## 20. Flow global du jeu + +``` +/start → Personnage lvl 1 + équipement de base + → Accès : Donjon Solo (Chapitre 1) + World Boss du serveur + +Lvl 2 → Shop débloqué + +Lvl 5 → Boss : Roi des Squelettes + Donjon coopératif 3v3 débloqué + Chapitre 2 (Marais maudit) + +Lvl 10 → Boss : Nécromancien + Infinite Dungeon débloqué + Chapitre 3 (Château royal) + +Lvl 15 → Boss : Assassin du Roi · Révélation Empire du Désert + +Lvl 20 → Boss : Général de l'Empire · Chapitre 5 + +Lvl 25 → Boss : Gardien Ancien · Arc Steampunk + +Lvl 32 → Boss : Maître Ingénieur · Arc Médiéval + +Lvl 40 → Boss : Seigneur de Guerre Éternel · Arc Céleste + +Lvl 50 → Boss final : Empereur des Sables + PvP ranked + Guild Wars débloqués +``` + +--- + +## 21. Roadmap + +### Phase 1 — MVP *(en cours)* + +- [x] Système joueur (création, niveau, XP, gold, régénération HP 2/min) +- [x] 69 items répartis en 6 types + système de rarité +- [x] Inventaire, loadout (6 slots), équipement/déséquipement +- [x] Système de skills actifs avec cooldowns +- [x] Système de passifs déclenchés sur attaque basique +- [x] Consommables en combat (select menu) et hors combat +- [x] Vente d'items au marchant (10% prix, 1 PA requis) +- [x] Combat tour par tour multi-ennemis (initiative, DoTs, buffs, stun) +- [x] 3 donjons solo (arc Catacombes, lvl 1–3) +- [x] Alliés NPC en combat (Aldric le Marchand — donjon 3) +- [x] Loot donjon (pool d'un item par ennemi, tirage unique) +- [x] Marché débloqué après donjon 3 complété +- [x] 37 ennemis sur 8 arcs (arc1–arc8) +- [x] 31 abilities ennemies sur 6 arcs +- [x] 20 skills joueur (offensifs, défensifs, support) +- [x] 7 passifs joueur (offensifs, défensifs) +- [x] World Boss coopératif par serveur (phases, scaling, leaderboard) +- [x] Système de marché (enchères BullMQ + vente marchant) +- [x] Points d'action (PA) par action économique +- [x] Setup automatique serveur (catégorie + channels + DB) +- [x] Message de bienvenue nouveaux membres + +### Phase 2 + +- [ ] Donjons arcs 2–8 (bandit, désert, steampunk, pharaon, enfers) +- [ ] Donjons coopératifs 3v3 +- [ ] Reset hebdomadaire automatique World Boss (cron BullMQ) +- [ ] Quêtes journalières / hebdomadaires + +### Phase 3 + +- [ ] PvP 1v1 +- [ ] Leaderboard global +- [ ] Quêtes journalières/hebdomadaires +- [ ] Hauts faits + rôles Discord automatiques + +### Phase 4 + +- [ ] Arènes 3v3 / 4v4 +- [ ] Guild Wars inter-serveurs +- [ ] Ranked ELO + saisons +- [ ] Infinite Dungeon + +### Phase 5 + +- [ ] Chapitres 3–8 +- [ ] Sets d'équipement (bonus 2/4 pièces) +- [ ] Cosmétiques premium +- [ ] Saisons PvP avec reset diff --git a/docs/worldboss/architecture.md b/docs/worldboss/architecture.md new file mode 100644 index 0000000..309834f --- /dev/null +++ b/docs/worldboss/architecture.md @@ -0,0 +1,182 @@ +# Architecture technique — WorldBoss + +## Stack + +| Couche | Technologie | Version | +|---|---|---| +| Runtime | Node.js | 18+ | +| Discord | discord.js | v14.14 | +| ORM | Prisma | v5.10 | +| Base de données | PostgreSQL | 16+ | +| Cache | Redis · ioredis | v5.3 | +| Queue | BullMQ | v5.4 | +| Dev | nodemon | v3.1 | + +--- + +## Flux d'une interaction Discord + +``` +Utilisateur tape /dungeon + │ + ▼ +discord.js reçoit l'InteractionCreate + │ + ▼ +interactionCreate.js → cherche la commande dans client.commands + │ + ▼ +commands/dungeon/dungeon.js + ├── characterExists(userId, guildId) (player.service) + ├── getCharacter(userId, guildId) (player.service → DB) + └── startOrResumeDungeon(interaction, character.id) + ├── getDungeonState(characterId) (Redis) + ├── getAvailableDungeons(characterId) (DB) + └── interaction.reply(embed + boutons) +``` + +--- + +## Résolution du personnage + +Toutes les commandes et boutons commencent par résoudre `(userId, guildId)` → `character.id` : + +```js +// Dans les commandes +const character = await getCharacter(interaction.user.id, interaction.guildId); + +// Dans interactionCreate.js (boutons) +const characterId = await resolveCharacterId(interaction); +// → prisma.character.findUnique({ where: { userId_guildId: { ... } } }) +``` + +Les services n'acceptent que des `characterId` (Int) — jamais de Discord user ID directement. + +--- + +## Structure des dossiers + +``` +src/ +├── index.js # Client Discord, chargement auto commandes + events +├── deploy-commands.js # Enregistre les slash commands via l'API Discord +│ +├── commands/ # Sous-dossiers auto-découverts au démarrage +│ ├── admin/ # /setup +│ ├── dungeon/ # /dungeon +│ ├── inventory/ # /inventory +│ └── player/ # /start /profile +│ +├── events/ # Un fichier = un événement Discord +│ ├── clientReady.js # ClientReady (once) +│ ├── interactionCreate.js +│ ├── guildCreate.js # GuildCreate — init serveur +│ ├── guildDelete.js # GuildDelete — nettoyage DB +│ └── guildMemberAdd.js # GuildMemberAdd — bienvenue +│ +├── services/ # Orchestration (DB + cache + engine) +│ ├── player.service.js # getCharacter, characterExists, createCharacter, addXp, addGold +│ ├── inventory.service.js # getInventory, equipItem, grantItem, sellItem +│ ├── actionPoints.service.js +│ ├── combat.service.js +│ ├── dungeon.service.js +│ ├── market.service.js +│ ├── guild.service.js +│ ├── infoPanel.service.js +│ └── wiki.service.js +│ +├── engines/ # Logique pure (aucun I/O — testable sans DB) +│ ├── combatEngine.js +│ ├── dungeonEngine.js +│ └── lootEngine.js +│ +├── data/ # Données statiques (items, ennemis) +│ ├── items.js +│ └── enemies.js +│ +├── cache/ +│ └── redis.js # Helpers get/set/del avec préfixes de clé +│ +├── db/ +│ └── prisma.js # Singleton PrismaClient +│ +└── utils/ + ├── stats.js # computeStats(character, loadout) → { hp, atk, def, spd, crit, critMult } + └── embed.js # Builders d'embeds réutilisables +``` + +--- + +## Séparation des responsabilités + +| Couche | Règle | +|---|---| +| **Commands** | Résout `(userId, guildId)` → `character`, appelle un service, formate la réponse | +| **Services** | Acceptent un `characterId` (Int). Orchestrent DB, engine, Redis | +| **Engines** | Fonctions pures. Aucun `require('../db/...')` ni `require('../cache/...')` autorisé | +| **Data** | Constantes en mémoire. Seeded en DB au démarrage via `player.service.ensureItemsSeeded()` | + +--- + +## Chargement automatique des commandes + +`index.js` scanne tous les sous-dossiers de `src/commands/` : + +```js +const commandsRoot = path.join(__dirname, 'commands'); +const commandDirs = fs.readdirSync(commandsRoot) + .map((sub) => path.join(commandsRoot, sub)) + .filter((p) => fs.statSync(p).isDirectory()); +``` + +Chaque fichier de commande exporte `{ data: SlashCommandBuilder, execute: async (interaction) => {} }`. + +--- + +## Intents Discord + +```js +new Client({ + intents: [ + GatewayIntentBits.Guilds, // Événements guild + channels + GatewayIntentBits.GuildMembers, // GuildMemberAdd (bienvenue) + ], +}) +``` + +`GuildMembers` est un intent privilegié — à activer dans le portail développeur Discord. + +--- + +## Redis — Convention de clés + +| Clé | Contenu | TTL | +|---|---|---| +| `combat:{characterId}` | État du combat en cours (JSON) | 30 min | +| `dungeon:{characterId}` | Session de donjon (JSON) | 2 h | + +Les clés sont indexées par `characterId` (Int) et non par Discord user ID — un joueur peut avoir des sessions de combat simultanées sur deux serveurs différents sans collision. + +--- + +## Variables d'environnement + +```env +DISCORD_TOKEN= # Token du bot +DISCORD_CLIENT_ID= # Application ID +DATABASE_URL= # postgresql://user:pass@host:5432/worldboss +REDIS_URL= # redis://localhost:6379 +``` + +--- + +## Scripts npm + +| Commande | Action | +|---|---| +| `npm run dev` | Démarre avec nodemon (hot reload) | +| `npm start` | Démarre en production | +| `npm run deploy` | Enregistre les slash commands | +| `npm run db:migrate` | Crée et applique une migration Prisma | +| `npm run db:generate` | Régénère le client Prisma après modif schema | +| `npm run db:push` | Push schema sans migration (dev rapide) | diff --git a/docs/worldboss/changelog.md b/docs/worldboss/changelog.md new file mode 100644 index 0000000..5464c9e --- /dev/null +++ b/docs/worldboss/changelog.md @@ -0,0 +1,69 @@ +# Changelog — WorldBoss + +--- + +## [0.2.0] — 2026-05-03 + +### Ajouté + +#### World Boss coopératif +- **`WorldBoss`** et **`WorldBossParticipant`** : nouveaux modèles Prisma pour le boss hebdomadaire par serveur +- **`/worldboss status`** : affiche HP, phase (normal/enragé/berserk), stats, participants +- **`/worldboss attack`** : attaque le boss avec les stats du personnage. Formules cohérentes avec le moteur de donjon (rawDamage + applyCrit de combatEngine.js). Riposte du boss appliquée aux HP du joueur +- **`/worldboss leaderboard`** : top 10 participants par dégâts cumulés + hits +- **Phases de combat** : normal (>30% HP), enragé (≤30%, ATK ×1.25), berserk (≤15%, ATK ×1.5) +- **Scaling par niveau** : HP ×1.4/niveau, ATK et DEF ×1.2/niveau (courbe exponentielle) +- **7 boss thématiques** avec nom et lore selon le palier (Catacombes → Forêt Corrompue → Citadelle des Ténèbres) +- **`resetBoss(guildId)`** : reset hebdomadaire — monte le niveau si le boss a été vaincu +- **Transaction DB atomique** : mise à jour HP boss + contribution joueur en une seule transaction Prisma + +#### Setup automatique des serveurs +- **`Guild`** et **`GuildChannels`** : nouveaux modèles Prisma pour les serveurs +- **`guildCreate.js`** : event — init DB + création automatique catégorie + 3 channels Discord (`wb-annonces`, `wb-raid`, `wb-taverne`) + embed de bienvenue +- **`guildDelete.js`** : event — suppression cascade en DB (Guild → GuildChannels + WorldBoss + Participants) +- **`guildMemberAdd.js`** : event — message de bienvenue dans `wb-annonces` avec avatar et rappel des commandes +- **`/setup`** : commande admin pour recréer les channels manuellement (idempotent) +- **`guild.service.js`** : service avec `ensureGuildInDb()`, `initGuildChannels()`, `updateGuildChannels()`, `removeGuildFromDb()` +- **`worldboss.service.js`** : service avec `getGuildBoss()`, `attackBoss()`, `resetBoss()`, `getBossLeaderboard()` + +### Modifié + +- **`src/index.js`** : ajout intent `GuildMembers` + découverte automatique de tous les sous-dossiers de `src/commands/` (plus de liste manuelle) +- **`prisma/schema.prisma`** : ajout de 4 modèles (`Guild`, `GuildChannels`, `WorldBoss`, `WorldBossParticipant`) + +--- + +## [0.1.0] — Initial (MVP donjon solo) + +### Ajouté + +#### Système joueur +- **`/start`** : création de personnage avec équipement de départ (Épée rouillée, Vêtements simples, 3× Potion de soin) +- **`/profile`** : stats, niveau, XP, équipement actif +- Seed automatique des items au démarrage (`ready.js` → `ensureItemsSeeded()`) + +#### Inventaire & équipement +- **`/inventory`** : liste des items avec quantités et loadout actif +- **`/equip `** : équiper un item dans le slot approprié +- Loadout 6 slots : arme, armure, casque, bottes, 2× accessoire + +#### Donjon solo +- **`/dungeon`** : démarre ou reprend le Chapitre 1 — Catacombes (3 salles) +- Boutons d'action : ⚔️ Attaquer · ✨ Skill · 💊 Potion · 🏃 Fuir +- Session Redis avec TTL 30min (combat) et 2h (donjon) +- Mécanique boss : Roi des Squelettes invoque des renforts à 50% HP (+5 ATK) + +#### Moteur de combat +- `combatEngine.js` : `rawDamage()`, `applyCrit()`, `resolveTurn()`, `rollInitiative()` +- DOTs (poison), buffs temporaires, stun +- Système de fuite (40% base + avantage SPD) + +#### Data +- 200+ items définis dans `src/data/items.js` (armes, armures, casques, bottes, accessoires, consommables) +- Ennemis et donjons dans `src/data/enemies.js` +- Raretés : common, rare, epic, legendary + +#### Infrastructure +- PostgreSQL + Prisma v5 (User, Item, UserItem, Loadout, DungeonRun) +- Redis (ioredis) + BullMQ +- discord.js v14 avec slash commands diff --git a/docs/worldboss/combat.md b/docs/worldboss/combat.md new file mode 100644 index 0000000..be6314f --- /dev/null +++ b/docs/worldboss/combat.md @@ -0,0 +1,229 @@ +# Système de combat — WorldBoss + +Le moteur de combat est implémenté dans `src/engines/combatEngine.js`. C'est du code pur (aucun I/O) — utilisé pour les donjons solo et le World Boss coopératif. + +--- + +## Formules de base + +### Dégâts bruts + +``` +rawDmg = ATK × skillMultiplier × (100 / (100 + DEF)) +``` + +La réduction par DEF est **asymptotique** — elle ne peut jamais atteindre 0. + +| DEF | Réduction | +|---|---| +| 0 | 0% | +| 25 | 20% | +| 50 | 33% | +| 100 | 50% | +| 200 | 67% | + +### Coup critique + +``` +isCrit = random(0, 100) < CRIT% +finalDmg = rawDmg × critMult (si crit) +finalDmg = rawDmg (sinon) +``` + +`critMult` par défaut : **1.5×**. Modifiable par équipement. + +### Exemple complet + +``` +Joueur : ATK = 50, CRIT% = 10%, critMult = 1.5 +Ennemi : DEF = 30 +Skill : ×1.8 (firebolt) + +rawDmg = 50 × 1.8 × (100 / 130) ≈ 69.2 → 69 +isCrit → oui (10% de chance) +finalDmg = 69 × 1.5 = 104 +``` + +--- + +## Initiative + +``` +Initiative = SPD + Random(0, SPD × 0.1) +``` + +Le joueur avec la plus haute initiative agit en premier. Si l'ennemi est **stun**, son initiative est 0 — il passe toujours après. + +--- + +## Résolution d'un tour + +Fonction `resolveTurn(state, playerAction, targetIndex)` dans `combatEngine.js`. + +``` +1. Appliquer DoTs joueur (poison, brûlure…) → réduire turns restants +2. Décrémenter buffs joueur → supprimer buff expiré et retirer le bonus +3. Décrémenter cooldowns skills +4. Calculer initiative joueur vs ennemi le plus rapide +5. Si joueur en premier : + ├── Joueur agit sur la cible choisie + └── Chaque ennemi vivant contre-attaque (si joueur encore en vie) + Sinon : + ├── Chaque ennemi vivant contre-attaque + └── Joueur agit (si encore en vie) +6. Alliés NPC agissent (après joueur + ennemis) +7. Vérifier mort joueur / tous ennemis morts +``` + +--- + +## Actions du joueur + +### Attaque basique (`attack`) + +- Dégâts : `rawDmg × 1.0 + crit` +- Déclenche tous les **passifs actifs** du joueur après chaque coup porté + +### Skill (`skill_`) + +- Chaque skill a un multiplicateur de dégâts et un effet optionnel +- Soumis aux **cooldowns** (tours) et à la limite `oncePerCombat` + +| Skill | Multiplicateur | Effet | Cooldown | +|---|---|---|---| +| `firebolt` | ×1.8 | — | 2 tours | +| `bone_bolt` | ×1.5 | DoT os_froid 3 dmg/2 tours | 3 tours | +| `shadow_burst` | ×2.2 | — | 3 tours | +| `ice_lance` | ×1.6 | Stun ennemi | 3 tours | +| `thunder_bolt` | ×1.7 | — | 3 tours | +| `soul_rend` | ×1.5 | Saignement 5 dmg/3 tours | 3 tours | +| `power_slash` | ×2.0 | — | 3 tours | +| `royal_smite` | ×2.2 | — | 3 tours | +| `hellstrike` | ×2.5 | DoT feu 5 dmg/3 tours | 4 tours | +| `inferno_blast` | ×2.0 | — | 4 tours | +| `soul_drain` | ×1.8 | Soin 30% des dégâts infligés | 3 tours | +| `sand_storm` | ×1.4 | DEF ennemi −5 permanent | 3 tours | +| `soin` | — | +20 HP joueur | 1 fois/combat | +| `divine_heal` | — | +50 HP joueur | 1 fois/combat | +| `second_wind` | — | +25% HP max | CD 5 tours | +| `iron_skin` | — | DEF +15 pendant 4 tours | CD 4 tours | +| `barrier` | — | DEF +30 pendant 3 tours | CD 5 tours | +| `battle_cry` | — | ATK +10 pendant 3 tours | CD 4 tours | +| `quicken` | — | SPD +8 pendant 3 tours | CD 4 tours | +| `resurrection` | — | Soin 30% HP (si mort) | 1 fois/combat | + +### Consommable (`item_`) + +Les consommables sont sélectionnés via un **select menu** pendant le combat. Seuls les items avec `type: 'consumable'` et un effet compatible sont affichés (les `restore_ap` sont exclus). + +| Type d'effet | Comportement | +|---|---| +| `heal` | +N HP joueur (capped maxHp) | +| `damage` | N dégâts à la cible (ou tous si `aoe: true`) | +| `stun` | Ennemi stun N tours | +| `buff` | Stat +valeur pendant N tours | +| `dot` | DoT sur l'ennemi N dmg/tour pendant N tours | +| `cure_dot` | Supprime tous les DoTs du joueur | + +Les items `infiniteUse: true` ont une quantité sentinel `-1` et ne sont jamais consommés. + +### Fuite (`flee`) + +``` +fleeChance = 0.4 + (SPD_joueur / (SPD_joueur + SPD_ennemi)) × 0.3 +``` + +En cas de succès : session de combat et de donjon supprimées, `DungeonRun` marqué `failed`. + +--- + +## Passifs du joueur + +Les passifs sont déclenchés automatiquement après **chaque attaque basique** réussie. + +| Passif | Source | Effet | +|---|---|---| +| `fire_dot` | Arme / Accessoire | DoT feu 2 dmg/tour × 2 tours sur l'ennemi (refresh si déjà actif) | +| `poison_dot` | Arme | DoT poison 1 dmg/tour × 3 tours | +| `bleed` | Arme | DoT saignement 3 dmg/tour × 3 tours | +| `cursed_strike` | Arme | DEF ennemi −2 permanent | +| `regeneration` | Accessoire | +3 HP joueur | +| `life_steal` | Accessoire | +5 HP joueur | +| `frost_shield` | Accessoire | DEF +3 pendant 2 tours (si non actif) | + +--- + +## IA ennemie + +À chaque tour, l'ennemi choisit aléatoirement : + +| Choix | Probabilité | Action | +|---|---|---| +| 0 | 1/3 | Attaque normale | +| 1 | 1/3 | Utilise son **ability** (si définie) | +| 2 | 1/3 | Repos → +15% HP max | + +Les ennemis ont aussi des **DoTs ennemis** traités en début de leur tour. + +--- + +## IA alliée (NPC) + +| Choix | Probabilité | Action | +|---|---|---| +| Attaque | 2/3 | Frappe un ennemi vivant aléatoire | +| Repos | 1/3 | +15% HP max allié | + +L'allié agit **après** le joueur et les ennemis. + +--- + +## Abilities ennemies (exemples) + +| Ability | Arc | Effet | +|---|---|---| +| `precise_shot` | Arc 1 | Ignore 50% DEF | +| `fireball` | Arc 1 | ×1.8 + ignore 30% DEF | +| `shield_bash` | Arc 1 | Dégâts + stun | +| `war_cry` | Arc 1 | ATK ennemi +6 | +| `quick_strike` | Arc 3 | ATK pleine sans réduction DEF | +| `frenzy_strike` | Arc 4 | 2 attaques | +| `curse` | Arc 4 | DoT 8 dmg/3 tours + DEF joueur −3 | +| `life_drain` | Arc 8 | ×1.2 + soin 50% dégâts | +| `inferno` | Arc 8 | ×2.5 + DoT 20 dmg/3 tours | +| `charm` | Arc 8 | SPD joueur −100 pendant 1 tour (ennemi passe en premier) | + +--- + +## Stats du joueur + +Calculées dans `src/utils/stats.js` — fonction `computeStats(character, loadout)`. + +Stats de base par niveau : + +``` +HP = 120 + (level − 1) × 20 +ATK = 12 + (level − 1) × 2 +DEF = 6 + (level − 1) × 1 +SPD = 10 + floor((level − 1) / 5) +``` + +Les items équipés ajoutent leurs bonus (`stats.hp`, `stats.atk`, etc.). Les items avec `skill` ou `passive` alimentent `activeSkills[]` et `activePassives[]`. + +### Régénération HP hors combat + +``` +HP régénérés = 2 HP / minute +``` + +Calculé lazily à partir du champ `hpUpdatedAt` (timestamp de dernière mise à jour HP). + +--- + +## World Boss — phases de combat + +| Phase | Condition HP | Multiplicateur ATK boss | +|---|---|---| +| Normal | > 30% | ×1.0 | +| Enragé | ≤ 30% | ×1.25 | +| Berserk | ≤ 15% | ×1.5 | diff --git a/docs/worldboss/commands.md b/docs/worldboss/commands.md new file mode 100644 index 0000000..3833bb3 --- /dev/null +++ b/docs/worldboss/commands.md @@ -0,0 +1,148 @@ +# Commandes Discord — WorldBoss + +Toutes les commandes sont des **slash commands** (discord.js v14). + +--- + +## Restrictions par channel + +| Channel | Commandes autorisées | +|---|---| +| `wb-commandes` | Toutes sauf `/dungeon` | +| `wb-donjon` | `/dungeon` uniquement | +| `wb-general` | Aucune commande bot | +| `wb-info` | Lecture seule — aucune commande | + +--- + +## Commandes joueur + +### `/start` + +Crée le personnage du joueur **sur ce serveur**. + +- Vérifie qu'il n'a pas déjà de personnage sur ce serveur (`Character` avec `userId + guildId`) +- Crée (ou réutilise) l'entrée `User` Discord, puis crée le `Character` lié à la guild +- Donne l'équipement de départ : Épée rouillée, Vêtements simples, 3× Potion de soin +- Crée le `Loadout` par défaut (épée + armure équipés) + +> Un même joueur peut utiliser `/start` sur plusieurs serveurs — il obtient un personnage indépendant sur chacun. + +**Fichier** : `src/commands/player/start.js` + +--- + +### `/profile` + +Affiche les stats et l'équipement du personnage **sur ce serveur**. + +- Niveau, XP (barre de progression), gold +- HP courants +- Équipement actif (tous les slots) +- Stats calculées (ATK, DEF, SPD, CRIT%) +- Points d'action (PA) disponibles + +**Fichier** : `src/commands/player/profile.js` + +--- + +### `/inventory` + +Affiche l'inventaire complet et le loadout actif du personnage **sur ce serveur**. + +- Liste tous les `CharacterItem` avec quantité +- Met en valeur les items équipés +- Boutons pour équiper / vendre chaque item + +**Fichier** : `src/commands/inventory/inventory.js` + +--- + +### `/dungeon` + +*(uniquement dans `wb-donjon`)* + +Affiche la liste des donjons disponibles pour le personnage sous forme de **boutons**. + +**Règles d'affichage :** + +| Donjon | Niveau requis | Condition supplémentaire | +|---|---|---| +| Donjon 1 | Lvl 1 | — | +| Donjon 2 | Lvl 2 | Avoir terminé Donjon 1 | +| Donjon 3 | Lvl 3 | Avoir terminé Donjon 2 | +| ... | ... | ... | + +Quand le joueur clique sur un bouton → lance le donjon correspondant. + +Si une session de donjon est déjà active en Redis → reprend là où il s'était arrêté. + +**Fichier** : `src/commands/dungeon/dungeon.js` + +--- + +## Commandes admin + +### `/setup` + +*(Réservé administrateurs — `PermissionFlagsBits.Administrator`)* + +Recrée ou met à jour les channels WorldBoss du serveur. + +- Crée la catégorie **WorldBoss** si absente +- Crée `wb-info`, `wb-general`, `wb-commandes`, `wb-donjon` si absents +- Met à jour les IDs en DB (`GuildChannels`) +- Répond en éphémère + +**Fichier** : `src/commands/admin/setup.js` + +--- + +## Interactions boutons + +Les boutons ne sont pas des slash commands mais sont gérés dans `src/events/interactionCreate.js`. + +| customId | Déclencheur | Handler | +|---|---|---| +| `combat_attack` / `combat_skill` / `combat_flee` / `combat_item_*` | Combat en cours | `handleCombatButton` | +| `loot_0` / `loot_1` | Choix de loot après victoire | `handleLootChoice` | +| `dungeon_next` | Avancer dans le donjon | `handleDungeonNext` | +| `dungeon_start:{chapter}` | Lancer un donjon spécifique | `startDungeon` | +| `dungeon_abandon` | Abandonner la session active | Redis delete + sélection | +| `market_merchant:{itemId}` | Vente au marchant | `sellToMerchant` | +| `market_bid:{listingId}` | Ouvrir le modal d'enchère | showModal | +| `market_buyout:{listingId}` | Achat direct | `finaliseBuyout` | +| `unequip:{slot}` | Déséquiper un slot | `unequipSlot` | + +Tous les handlers boutons résolvent `(userId, guildId)` → `characterId` avant d'agir. + +--- + +## Format d'un fichier de commande + +```js +module.exports = { + data: new SlashCommandBuilder() + .setName('nom') + .setDescription('Description'), + + async execute(interaction) { + const userId = interaction.user.id; + const guildId = interaction.guildId; + // Résoudre le personnage avant tout appel service + const character = await getCharacter(userId, guildId); + }, +}; +``` + +Les commandes avec interactions longues doivent appeler `deferReply()` avant tout appel async. + +--- + +## Déploiement des commandes + +```bash +npm run deploy +``` + +Les commandes globales peuvent prendre jusqu'à 1h à se propager. Pour un déploiement instantané en dev, utiliser `Routes.applicationGuildCommands(clientId, guildId)` dans `deploy-commands.js`. diff --git a/docs/worldboss/database.md b/docs/worldboss/database.md new file mode 100644 index 0000000..3b84909 --- /dev/null +++ b/docs/worldboss/database.md @@ -0,0 +1,206 @@ +# Base de données — WorldBoss + +Schéma source : [`worldboss-app/prisma/schema.prisma`](../../worldboss-app/prisma/schema.prisma) + +Provider : **PostgreSQL**. ORM : **Prisma v5**. + +--- + +## Diagramme des relations + +``` +User (identité Discord) + └── Character (n) ──→ Guild + ├── CharacterItem (n) ──→ Item + ├── Loadout (1) + └── DungeonRun (n) + +Guild + └── GuildChannels (1) + +MarketListing + ├── seller ──→ Character + └── bidder ──→ Character +``` + +--- + +## Tables + +### `User` + +Identité Discord d'un joueur. N'existe qu'une fois par utilisateur, toutes guildes confondues. Ne contient aucune stat de jeu. + +| Colonne | Type | Description | +|---|---|---| +| `id` | String PK | Discord user ID | +| `username` | String | Tag Discord | +| `createdAt` | DateTime | | +| `updatedAt` | DateTime | | + +--- + +### `Character` + +Personnage d'un joueur **sur un serveur Discord spécifique**. Un joueur peut avoir autant de personnages que de serveurs où il est présent. + +Clé unique : `(userId, guildId)`. + +| Colonne | Type | Défaut | Description | +|---|---|---|---| +| `id` | Int PK | autoincrement | | +| `userId` | String FK | | → `User.id` | +| `guildId` | String FK | | → `Guild.id` | +| `level` | Int | 1 | Niveau actuel | +| `xp` | Int | 0 | XP cumulée | +| `gold` | Int | 0 | Or disponible | +| `hp` | Int | 120 | HP courants | +| `actionPoints` | Int | 10 | PA disponibles | +| `actionPointsUpdatedAt` | DateTime | now() | Référence pour le calcul de recharge | +| `createdAt` | DateTime | now() | | +| `updatedAt` | DateTime | updatedAt | | + +--- + +### `Item` + +Catalogue de tous les items (seed depuis `src/data/items.js` au démarrage). + +| Colonne | Type | Description | +|---|---|---| +| `id` | String PK | Slug identifiant (ex: `sword_rusty`, `potion_heal`) | +| `name` | String | Nom affiché | +| `type` | String | `weapon` · `armor` · `helmet` · `boots` · `accessory` · `consumable` | +| `rarity` | String | `common` · `rare` · `epic` · `legendary` | +| `statsJson` | Json | Bonus de stats : `{ atk, def, hp, spd, crit, critMult }` | +| `skillJson` | Json? | Skill conféré : `{ name, mult, oncePerCombat, heal }` | +| `effectJson` | Json? | Effets passifs ou DOT | +| `price` | Int | Prix en or (shop) | +| `levelRequired` | Int | Niveau minimum pour équiper | + +--- + +### `CharacterItem` + +Inventaire d'un personnage. Junction entre `Character` et `Item`. + +| Colonne | Type | Description | +|---|---|---| +| `id` | Int PK | | +| `characterId` | Int FK | → `Character.id` | +| `itemId` | String FK | → `Item.id` | +| `quantity` | Int | Quantité possédée | + +Contrainte unique : `(characterId, itemId)`. + +--- + +### `Loadout` + +Équipement actif d'un personnage (un seul loadout par personnage). + +| Colonne | Type | Description | +|---|---|---| +| `id` | Int PK | | +| `characterId` | Int FK unique | → `Character.id` | +| `weaponId` | String? | ID item équipé en arme | +| `armorId` | String? | ID item équipé en armure | +| `helmetId` | String? | ID item équipé en casque | +| `bootsId` | String? | ID item équipé en bottes | +| `accessory1Id` | String? | ID accessoire 1 | +| `accessory2Id` | String? | ID accessoire 2 | + +--- + +### `DungeonRun` + +Suivi d'une run de donjon (active ou terminée). + +| Colonne | Type | Défaut | Description | +|---|---|---|---| +| `id` | Int PK | | | +| `characterId` | Int FK | | → `Character.id` | +| `chapter` | Int | | Numéro de chapitre | +| `currentRoom` | Int | 1 | Salle actuelle | +| `totalRooms` | Int | | Nombre total de salles | +| `status` | String | `active` | `active` · `completed` · `failed` | +| `lootJson` | Json | `[]` | Items collectés pendant la run | +| `createdAt` | DateTime | now() | | +| `updatedAt` | DateTime | updatedAt | | + +--- + +### `Guild` + +Serveur Discord enregistré. + +| Colonne | Type | Description | +|---|---|---| +| `id` | String PK | Discord guild ID | +| `name` | String | Nom du serveur | +| `ownerId` | String | Discord ID du propriétaire | +| `createdAt` | DateTime | | +| `updatedAt` | DateTime | | + +--- + +### `GuildChannels` + +IDs des channels Discord créés par le bot pour ce serveur. + +| Colonne | Type | Description | +|---|---|---| +| `id` | Int PK | | +| `guildId` | String FK unique | → `Guild.id` | +| `categoryId` | String? | ID catégorie "WorldBoss" | +| `infoChannelId` | String? | `wb-info` | +| `generalChannelId` | String? | `wb-general` | +| `dungeonChannelId` | String? | `wb-donjon` | +| `marketChannelId` | String? | `wb-marchand` | + +--- + +### `MarketListing` + +Annonce sur le marché d'un serveur (enchère ou vente marchant). + +| Colonne | Type | Défaut | Description | +|---|---|---|---| +| `id` | Int PK | | | +| `guildId` | String | | Serveur concerné | +| `sellerId` | Int FK | | → `Character.id` du vendeur | +| `itemId` | String FK | | → `Item.id` | +| `quantity` | Int | 1 | | +| `type` | String | | `auction` · `merchant` | +| `startPrice` | Int | | Prix de départ | +| `buyoutPrice` | Int? | | Prix d'achat direct | +| `currentBid` | Int? | | Meilleure enchère actuelle | +| `bidderId` | Int? FK | | → `Character.id` de l'enchérisseur | +| `messageId` | String? | | Discord message ID de l'embed | +| `status` | String | `active` | `active` · `sold` · `expired` · `cancelled` | +| `expiresAt` | DateTime? | | | + +--- + +## Migrations + +```bash +# Créer et appliquer une migration +npx prisma migrate dev --name + +# Régénérer le client après modification du schéma +npx prisma generate + +# Pousser le schéma sans migration (développement rapide) +npx prisma db push +``` + +--- + +## Notes de conception + +- **Character par serveur** : un joueur a un personnage distinct par serveur Discord (guild). Rien n'est partagé entre serveurs — ni gold, ni inventaire, ni progression. Le seul lien cross-serveur prévu est le PvP inter-guildes (Phase 4). +- **Cascade delete** : toutes les relations enfants utilisent `onDelete: Cascade`. Supprimer une `Guild` supprime ses `GuildChannels` et tous les `Character` associés (avec leur inventaire, loadout, donjons). +- **statsJson** sur `Item` : évite de multiplier les colonnes nullables. Le champ est désérialisé dans `utils/stats.js`. +- **lootJson** sur `DungeonRun` : liste des IDs d'items collectés pendant la run, appliqués à l'inventaire salle par salle. +- **MarketListing** : le marché est local au serveur. `sellerId` et `bidderId` sont des `Character.id` (Int), pas des Discord user IDs — garantit qu'on vend/achète avec le personnage du bon serveur. diff --git a/docs/worldboss/dungeon.md b/docs/worldboss/dungeon.md new file mode 100644 index 0000000..4a0212d --- /dev/null +++ b/docs/worldboss/dungeon.md @@ -0,0 +1,144 @@ +# Système de donjon solo — WorldBoss + +Les donjons solo sont accessibles dans le channel `wb-donjon` via la commande `/dungeon`. + +--- + +## Accès et progression + +La commande `/dungeon` affiche les donjons disponibles sous forme de **boutons**. Un donjon est accessible si le joueur a le **niveau requis**. + +| Donjon | Niveau requis | Récompense spéciale | +|---|---|---| +| Donjon 1 — Les Catacombes | Lvl 1 | — | +| Donjon 2 — Les Catacombes Pt.2 | Lvl 2 | — | +| Donjon 3 — Les Catacombes Pt.3 | Lvl 3 | Débloque le marché (`wb-market`) | + +Un donjon peut être rejoué indéfiniment. Le statut `✅` indique qu'il a déjà été complété au moins une fois. + +Si le joueur relance `/dungeon` pendant une session active → peut reprendre ou abandonner. + +--- + +## Donjon 1 — Les Catacombes (Lvl 1) + +*Des squelettes réanimés rôdent dans ces ruines oubliées...* + +| Salle | Ennemis | Description | +|---|---|---| +| 1 | `skeleton` | Une salle poussiéreuse. Un squelette se retourne lentement vers vous. | +| 2 | `skeleton_archer` | Un couloir étroit. Une flèche siffle avant même que vous voyiez la silhouette. | +| 3 | `skeleton` + `skeleton_archer` | Grande salle voûtée. Un squelette avance pendant que l'archer vise depuis l'ombre. | + +--- + +## Donjon 2 — Les Catacombes Pt.2 (Lvl 2) + +*Les squelettes sont de plus en plus nombreux et agressifs dans les profondeurs...* + +| Salle | Ennemis | Description | +|---|---|---| +| 1 | `skeleton` + `skeleton_archer` | Salle couverte de crânes. Le squelette attaque pendant que l'archer tire depuis une galerie en hauteur. | +| 2 | `skeleton_mage` | Couloir étroit. Une boule de feu traverse l'obscurité. | +| 3 | `skeleton` + `skeleton_mage` | Corniche étroite au-dessus d'un puits sans fond. Deux squelettes avancent, leurs os grinçant. | + +--- + +## Donjon 3 — Les Catacombes Pt.3 (Lvl 3) + +*Un marchand isolé s'est réfugié à l'hôtel, cerné par des squelettes. Il a besoin d'aide...* + +| Salle | Ennemis | Allié | Description | +|---|---|---|---| +| 1 | `skeleton` + `skeleton_mage` | — | Entrée de l'hôtel. Un squelette avance pendant que le mage lève les bras pour invoquer. | +| 2 | `skeleton` + `skeleton_archer` | — | Hall de l'hôtel. Le squelette se précipite pendant que l'archer vise depuis la mezzanine. | +| 3 | `skeleton` + `skeleton_mage` + `skeleton_archer` + `skeleton_knight` | **Aldric le Marchand** | Bar de l'hôtel. Quatre silhouettes encerclent Aldric — il se joint au combat ! | + +### Allié NPC — Aldric le Marchand 🧑‍💼 + +| Stat | Valeur | +|---|---| +| HP | 45 | +| ATK | 8 | +| DEF | 3 | +| SPD | 5 | + +Aldric attaque chaque tour (IA simple : 2/3 attaque, 1/3 repos). + +### Récompense donjon 3 + +- Or : entre **30 et 60** 🪙 +- **Débloque le marché** (`wb-market`) — achat/vente d'items entre joueurs +- Message d'Aldric : *"Tu m'as sauvé la mise, ami. Ma boutique te sera désormais ouverte."* + +--- + +## Mécanique de salle + +### Entre les salles + +Après chaque salle terminée (tous ennemis vaincus) : +- **+15% HP max** récupérés (minimum 5 HP) +- Si **level up** pendant la salle → HP restaurés à 100% (le level up prime) + +### Mort / Fuite + +- **Mort** : HP mis à 1, session perdue, `DungeonRun` marqué `failed` +- **Fuite** : HP conservés (min 1), session perdue, `DungeonRun` marqué `failed` + +### Fin du donjon + +- `DungeonRun` marqué `completed` +- Or attribué (par salle ou récompense fixe pour donjon 3) +- Loot : chaque ennemi contribue **un candidat** à une pool ; **un seul item** est tiré au sort dans la pool + +--- + +## Session de donjon (Redis) + +| Clé Redis | Contenu | TTL | +|---|---|---| +| `dungeon:{characterId}` | État complet JSON (salle courante, ennemis combattus) | 2h | +| `combat:{characterId}` | État de combat JSON (HP, buffs, DoTs, cooldowns) | 2h | + +--- + +## Résolution d'un tour + +Fonction `resolveTurn(state, playerAction, targetIndex)` dans `combatEngine.js`. + +``` +1. Appliquer DoTs joueur (poison, brûlure…) +2. Décrémenter buffs joueur +3. Décrémenter cooldowns de skills +4. Calculer initiative : SPD + Random(0, SPD × 0.1) +5. Si joueur en premier : + └── Joueur agit → ennemis vivants contre-attaquent (ordre SPD) + Sinon : + └── Ennemis contre-attaquent → joueur agit (si encore en vie) +6. Alliés agissent (après joueur + ennemis) +7. Vérifier mort joueur / tous ennemis morts +``` + +### Actions disponibles + +| Action | Identifiant | Effet | +|---|---|---| +| Attaque | `combat_attack:N` | rawDmg × 1.0 + crit + passifs | +| Skill | `combat_skill_:N` | rawDmg × mult (voir table skills) | +| Consommable | select menu `combat_consumable` | Selon effet de l'item | +| Fuite | `combat_flee` | 40–70% de réussite selon SPD | + +--- + +## Récompenses XP par ennemi (arc 1) + +| Ennemi | XP | Or (min–max) | +|---|---|---| +| `skeleton` | 12 | 1–3 | +| `skeleton_archer` | 15 | 1–4 | +| `skeleton_mage` | 18 | 2–5 | +| `skeleton_knight` | 25 | 3–7 | +| `skeleton_warlord` | 35 | 4–8 | +| `skeleton_king` | 45 | 6–10 | +| `necromancer` | 55 | 5–10 | diff --git a/docs/worldboss/getting-started.md b/docs/worldboss/getting-started.md new file mode 100644 index 0000000..d68486c --- /dev/null +++ b/docs/worldboss/getting-started.md @@ -0,0 +1,139 @@ +# Getting Started — WorldBoss + +Guide pour lancer le bot en local ou en production. + +--- + +## Prérequis + +- Node.js 18+ +- PostgreSQL 15+ +- Redis 7+ +- Un compte développeur Discord et une application bot + +--- + +## Installation + +```bash +cd worldboss-app +npm install +``` + +--- + +## Configuration + +Créer un fichier `.env` à la racine de `worldboss-app/` : + +```env +DISCORD_TOKEN=ton_token_bot +CLIENT_ID=ton_application_id +GUILD_ID=id_serveur_test # optionnel — pour déploiement local des commandes + +DATABASE_URL=postgresql://user:password@localhost:5432/worldboss +REDIS_URL=redis://localhost:6379 +``` + +--- + +## Base de données + +```bash +# Appliquer le schéma (première fois ou après modification) +npm run db:migrate + +# Ou en développement rapide (sans historique de migration) +npm run db:push + +# Régénérer le client Prisma +npm run db:generate +``` + +--- + +## Enregistrer les commandes Discord + +```bash +npm run deploy +``` + +Les commandes globales peuvent prendre jusqu'à 1h à être disponibles. +Pour le développement, définir `GUILD_ID` dans `.env` et adapter `deploy-commands.js` pour un déploiement immédiat sur un seul serveur. + +--- + +## Démarrer le bot + +```bash +# Développement (hot reload) +npm run dev + +# Production +npm start +``` + +--- + +## Ajouter le bot à un serveur + +1. Aller sur [discord.com/developers](https://discord.com/developers/applications) +2. Sélectionner l'application → **OAuth2 → URL Generator** +3. Scopes : `bot` + `applications.commands` +4. Permissions requises : + - `Manage Channels` — pour créer la catégorie + channels + - `Send Messages` + `Embed Links` — pour les réponses + - `Read Message History` + - `Use Application Commands` +5. Copier le lien et l'ouvrir dans le navigateur + +Dès que le bot rejoint, l'event `GuildCreate` crée automatiquement les channels et initialise la DB. + +--- + +## Vérification + +Une fois le bot lancé et rejoint sur le serveur : + +1. Une catégorie **WorldBoss** est créée avec 3 channels +2. Un embed de bienvenue apparaît dans `wb-annonces` +3. Taper `/start` → personnage créé +4. Taper `/worldboss status` → boss visible +5. Taper `/dungeon` → donjon démarré + +--- + +## Structure du projet + +``` +worldboss-app/ +├── .env ← à créer (non commité) +├── package.json +├── prisma/ +│ └── schema.prisma +└── src/ + └── ... ← voir docs/worldboss/architecture.md +``` + +--- + +## Intents Discord — attention + +Le bot utilise l'intent `GuildMembers` (privilegié). Il faut l'activer manuellement dans le portail développeur : + +**Applications → ton bot → Bot → Privileged Gateway Intents → Server Members Intent → ON** + +Sans ça, l'event `GuildMemberAdd` ne se déclenche pas. + +--- + +## Commandes utiles + +| Commande | Usage | +|---|---| +| `npm run dev` | Démarrer en dev avec hot reload | +| `npm run deploy` | Enregistrer les slash commands | +| `npm run db:migrate` | Appliquer les migrations Prisma | +| `npm run db:push` | Push schema sans migration | +| `npx prisma studio` | Interface web pour inspecter la DB | +| `npx prisma format` | Formater le schema.prisma | diff --git a/docs/worldboss/guild-setup.md b/docs/worldboss/guild-setup.md new file mode 100644 index 0000000..c48aefa --- /dev/null +++ b/docs/worldboss/guild-setup.md @@ -0,0 +1,163 @@ +# Setup automatique des serveurs — WorldBoss + +Quand le bot démarre ou rejoint un nouveau serveur Discord, il vérifie et initialise automatiquement la structure de channels. + +--- + +## Événements Discord concernés + +| Événement | Fichier | Déclencheur | +|---|---|---| +| `ClientReady` | `src/events/clientReady.js` | Bot démarre — vérifie tous les serveurs | +| `GuildCreate` | `src/events/guildCreate.js` | Bot ajouté à un nouveau serveur | +| `GuildDelete` | `src/events/guildDelete.js` | Bot retiré ou serveur supprimé | +| `GuildMemberAdd` | `src/events/guildMemberAdd.js` | Nouveau membre rejoint le serveur | + +--- + +## Flux ClientReady — vérification au démarrage + +Au démarrage du bot, pour **chaque serveur** où il est présent : + +``` +Bot connecté + │ + ▼ +Pour chaque guild : + ensureGuildInDb(guild) + │ + ▼ + initGuildChannels(guild) ← crée ce qui manque, ne touche pas à l'existant + │ + ▼ + updateGuildChannels(guildId, channelIds) +``` + +Ce flux est **idempotent** : si les channels existent déjà, rien n'est recréé. + +--- + +## Flux GuildCreate + +``` +Bot rejoint le serveur + │ + ▼ +ensureGuildInDb(guild) + └── prisma.guild.upsert() + ├── create Guild + GuildChannels (vide) + └── update name + ownerId si déjà existant + │ + ▼ +initGuildChannels(guild) ← même logique que ClientReady + │ + ▼ +Envoie embed de bienvenue dans wb-info +``` + +--- + +## Channels créés + +Tous créés sous la catégorie **WorldBoss**. + +| Channel | Nom Discord | Permissions | Usage | +|---|---|---|---| +| Info bot | `wb-info` | Lecture seule pour tous | Statut bot, classements, infos générales | +| Général | `wb-general` | Tout le monde peut écrire | Tchat libre lié au jeu | +| Commandes | `wb-commandes` | Tout le monde peut écrire | Toutes les commandes sauf `/dungeon` | +| Donjon | `wb-donjon` | Tout le monde peut écrire | Uniquement `/dungeon` | + +> Channel futur (non implémenté dans le MVP) : `wb-marchand` (marché entre joueurs, débloqué après boss donjon lvl 3). + +--- + +## Flux GuildDelete + +``` +Bot retiré du serveur (ou serveur supprimé) + │ + ▼ +removeGuildFromDb(guild.id) + └── prisma.guild.delete() + └── Cascade : GuildChannels + Character (+ CharacterItem, Loadout, DungeonRun) +``` + +Les channels Discord ne sont **pas** supprimés côté Discord (le bot n'a plus les permissions). + +--- + +## Flux GuildMemberAdd + +``` +Nouveau membre rejoint + │ + ▼ +Lire Guild + GuildChannels depuis DB + │ + ▼ +Si wb-info existe + └── Envoie embed de bienvenue avec : + - Mention du membre + - Rappel de /start pour créer son personnage sur ce serveur + - Rappel des channels disponibles + - Avatar du membre en thumbnail +``` + +--- + +## Commande manuelle : /setup + +Un administrateur peut relancer l'initialisation à tout moment : + +``` +/setup +``` + +- Recrée uniquement ce qui manque (catégorie, channels) +- Met à jour les IDs en DB +- Répond en éphémère (visible uniquement par l'admin) +- Idempotent + +--- + +## Données stockées en DB + +``` +Guild { + id: "1234567890" + name: "Mon Serveur" + ownerId: "9876543210" +} + +GuildChannels { + guildId: "1234567890" + categoryId: "1111111111" + infoChannelId: "2222222222" + generalChannelId: "3333333333" + dungeonChannelId: "4444444444" + marketChannelId: null ← débloqué plus tard +} + +User { + id: "111222333" ← Discord user ID (partagé entre serveurs) + username: "Rusty" +} + +Character { + userId: "111222333" + guildId: "1234567890" + level: 5 + gold: 320 + hp: 140 + ... ← stats et progression propres à ce serveur +} +``` + +--- + +## Permissions requises pour le bot + +- `Manage Channels` — créer catégorie et channels +- `View Channel` + `Send Messages` + `Embed Links` — dans tous les channels créés +- `GuildMembers` intent (privilegié) — pour l'événement GuildMemberAdd diff --git a/docs/worldboss/roadmap.md b/docs/worldboss/roadmap.md new file mode 100644 index 0000000..b319d04 --- /dev/null +++ b/docs/worldboss/roadmap.md @@ -0,0 +1,121 @@ +# Roadmap — WorldBoss + +--- + +## MVP — Jouable solo donjon lvl 1 à 3 *(en cours)* + +Objectif : un bot jouable sur un serveur, avec les donjons solo du chapitre 1 (3 salles + boss), la structure de channels automatique, et le World Boss coopératif de base. + +### Système joueur +- [x] Création de personnage (`/start`) — un personnage par serveur Discord +- [x] Niveau, XP, gold, HP — propres à chaque serveur (pas de partage cross-serveur) +- [x] Items de base + inventaire + loadout 6 slots +- [x] Stats calculées (ATK, DEF, SPD, CRIT) +- [x] Points d'action (PA) avec recharge temporelle + +### Système de combat +- [x] Moteur de combat tour par tour (rawDamage, crit, initiative, DOT, buffs) +- [x] Sessions Redis (combat 30min, donjon 2h) — resumable, clés par `characterId` + +### Donjon solo — Chapitre 1 + +Structure : **3 salles** progressives, déblocables par niveau + progression. + +#### Salle 1 — 1v1 +- Combat contre 1 monstre (gobelin / squelette) +- Mécanique de base : attaque, skill, potion, fuite +- Récompense : XP + or + +#### Salle 2 — 1v2 +- Combat simultané contre 2 monstres +- Le joueur choisit sa cible à chaque tour +- Les 2 ennemis contre-attaquent chacun leur tour +- Récompense : XP + or + +#### Salle 3 (Boss) — Roi des Squelettes +- Boss avec mécanique spéciale : à 50% HP, +5 ATK permanent +- Débloque le lvl 3 et le channel `wb-marchand` (chat uniquement, pas encore implémenté) +- Récompense : XP + or + loot garanti + +#### Progression donjons +- [ ] Affichage liste donjons sous forme de boutons dans `wb-donjon` +- [ ] Donjon 1 visible dès lvl 1 +- [ ] Donjon 2 visible si lvl 2 ET donjon 1 terminé +- [ ] Donjon 3 visible si lvl 3 ET donjon 2 terminé + +### Setup serveur +- [x] Setup automatique au démarrage (vérification de tous les serveurs) +- [x] Channels MVP : `wb-info` (lecture seule), `wb-general`, `wb-commandes`, `wb-donjon` +- [x] Message de bienvenue nouveaux membres +- [x] Commande admin `/setup` + +### World Boss +- [x] World Boss coopératif par serveur (phases, scaling, leaderboard) + +--- + +## Phase 2 — Économie & Contenu + +- [ ] **Marchand** — channel `wb-marchand` actif après le boss donjon 3 : vente/achat entre joueurs (enchères), achat direct, stock marchand refresh toutes les heures +- [ ] **Loot complet** — tables de drop par salle, choix parmi 2–3 items après boss +- [ ] **Reset automatique World Boss** — cron BullMQ (lundi 00h00 UTC) +- [ ] **Chapitres 4–6** (suite de la progression donjon solo) + +--- + +## Phase 3 — Social & Guilde + +- [ ] **Quêtes journalières / hebdomadaires** +- [ ] **Leaderboard global** inter-serveurs +- [ ] **Hauts faits** + rôles Discord automatiques +- [ ] **PvP 1v1** entre joueurs du même serveur + +--- + +## Phase 4 — Multi & PvP inter-serveurs + +Débloqué à partir du donjon solo lvl 7. + +- [ ] **Primes (donjon multi 4v4)** — channel `wb-primes` +- [ ] **PvP inter-serveurs 4v4** — 4 personnages d'une guild affrontent 4 personnages d'une autre guild + - Chaque participant est un `Character` de sa guild respective + - Aucune stat n'est partagée cross-serveur : on joue avec son personnage local + - Le lien cross-serveur se fait uniquement au niveau de la session de combat + +--- + +## Phase 5 — Endgame + +- [ ] Chapitres 7–12 (Château → Désert → Profondeurs → Steampunk → Médiéval → Céleste) +- [ ] Sets d'équipement (bonus 2 et 4 pièces) +- [ ] Saisons PvP + cosmétiques premium +- [ ] Dashboard web (stats, leaderboard, profil hors Discord) + +--- + +## Backlog (idées non planifiées) + +- Guildes nommées (nom, bannière, description) +- Marchands itinérants en donjon (event aléatoire) +- Défis quotidiens spéciaux (boss aléatoire le weekend) +- Système de prestige (reset volontaire lvl 50) +- Mode spectateur (observer un combat en cours) +- Notifications DM (boss à 20% HP) + +--- + +## Dépendances + +``` +MVP (donjon 1→3 + channels + World Boss) + │ + ├── Phase 2 (marchand + loot + donjons 4→6) + │ │ + │ └── Phase 3 (social + guilde + PvP serveur) + │ │ + │ └── Phase 4 (multi + PvP inter-serveurs) ← débloqué lvl 7 + │ │ + │ └── Phase 5 (endgame) + │ + └── [Backlog] indépendant des phases +``` diff --git a/docs/worldboss/worldboss-feature.md b/docs/worldboss/worldboss-feature.md new file mode 100644 index 0000000..6dc079b --- /dev/null +++ b/docs/worldboss/worldboss-feature.md @@ -0,0 +1,156 @@ +# Feature : World Boss coopératif + +Le World Boss est la fonctionnalité centrale côté serveur. Chaque guilde (serveur Discord) a son propre boss hebdomadaire que tous les membres peuvent attaquer ensemble. + +--- + +## Concept + +- **Un boss par serveur** — stocké dans `WorldBoss` lié à `Guild` +- **Reset hebdomadaire** — le boss est réinitialisé chaque semaine (manuel ou cron BullMQ en Phase 2) +- **Progression** — si la guilde a vaincu le boss, il monte d'un niveau au prochain reset +- **Leaderboard** — classement des dégâts par joueur pour ce cycle + +--- + +## Commandes + +| Commande | Description | +|---|---| +| `/worldboss status` | Voir HP, phase, stats du boss | +| `/worldboss attack` | Attaquer (une fois par invocation) | +| `/worldboss leaderboard` | Top 10 contributeurs | + +--- + +## Phases de combat + +Le boss devient plus dangereux à mesure qu'il perd des HP. + +| Phase | Seuil HP | Multiplicateur ATK | Couleur embed | +|---|---|---|---| +| Normal | > 30% | ×1.0 | Rouge sombre | +| Enragé | ≤ 30% | ×1.25 | Orange | +| Berserk | ≤ 15% | ×1.5 | Rouge vif | + +La phase est recalculée en temps réel à chaque attaque et persistée dans `WorldBoss.phase`. + +--- + +## Scaling par niveau + +Chaque reset, si le boss a été vaincu, son niveau augmente : + +``` +HP max = 5000 × 1.4^(level − 1) +ATK = 80 × 1.2^(level − 1) +DEF = 40 × 1.2^(level − 1) +``` + +Exemples : + +| Niveau | HP max | ATK | DEF | +|---|---|---|---| +| 1 | 5 000 | 80 | 40 | +| 2 | 7 000 | 96 | 48 | +| 3 | 9 800 | 115 | 58 | +| 5 | 19 200 | 166 | 83 | +| 7+ | > 37 000 | 239+ | 120+ | + +--- + +## Boss thématiques + +Le nom et le lore changent selon le niveau, par paliers de 3 : + +| Niveaux | Biome | Nom | Lore | +|---|---|---|---| +| 1 | Catacombes | Seigneur des Ossements | Il régnait sur les catacombes bien avant que votre guilde ne les foule. | +| 2 | Catacombes | Liche Écarlate | La mort ne lui suffit plus — il dévore l'âme de ses victimes. | +| 3 | Catacombes | Roi Squelette Maudit | Sa couronne maudite rayonne d'une sombre magie immémoriale. | +| 4 | Forêt Corrompue | Sylvanide Corrompu | La forêt elle-même pleure sous son étreinte vénéneuse. | +| 5 | Forêt Corrompue | Chasseur Maudit | Il traque ses proies depuis des siècles, jamais rassasié. | +| 6 | Forêt Corrompue | Esprit de la Forêt Noire | Jadis gardien bienveillant, désormais dévoré par la rage. | +| 7+ | Citadelle des Ténèbres | Seigneur des Ténèbres | L'entité ultime qui tisse la corruption à travers les royaumes. | + +--- + +## Formule de dégâts + +Cohérente avec le moteur de donjon solo (`combatEngine.js`) : + +``` +rawDmg = playerATK × (100 / (100 + bossDefense)) +finalDmg = rawDmg × critMult (si critique) +bossHit = bossATK_effectif × (100 / (100 + playerDEF)) +``` + +`bossATK_effectif` = `boss.attack × phaseMultiplier`. + +--- + +## Transaction DB par attaque + +```js +await prisma.$transaction([ + prisma.worldBoss.update({ + where: { guildId }, + data: { currentHp: newHp, phase, status, defeatedAt }, + }), + prisma.worldBossParticipant.upsert({ + where: { bossId_userId: { bossId, userId } }, + create: { bossId, userId, damage: finalDmg, hits: 1 }, + update: { damage: { increment: finalDmg }, hits: { increment: 1 } }, + }), +]); +``` + +La transaction garantit la cohérence HP / participation même en cas d'attaques simultanées. + +--- + +## Reset (Phase 2 — cron BullMQ) + +La fonction `resetBoss(guildId)` dans `worldboss.service.js` : + +1. Lit le boss actuel +2. Si `status = 'defeated'` → `newLevel = level + 1`, sinon `newLevel = level` +3. Calcule les nouveaux stats avec la formule de scaling +4. Met à jour le boss : nouveau nom/lore, HP, ATK, DEF, phase → `normal`, status → `alive` +5. Supprime tous les `WorldBossParticipant` (nouveau cycle) + +À automatiser avec un job BullMQ planifié tous les lundis à 00h00 UTC. + +--- + +## Embed visual + +### `/worldboss status` + +``` +👹 Seigneur des Ossements — Niveau 1 +*Il régnait sur les catacombes bien avant que votre guilde ne les foule.* + +⚔️ Phase normale + +❤️ Points de Vie +🟩🟩🟩🟩🟩🟩🟩🟩🟩⬛ 4 200 / 5 000 HP + +⚔️ Attaque 🛡️ Défense 👥 Participants + 80 40 7 +``` + +### `/worldboss attack` + +``` +⚔️ Assaut sur le World Boss + +Tu frappes Seigneur des Ossements pour 143 dégâts 💥 CRITIQUE ! +Il riposte et t'inflige 67 dégâts. + +👹 Boss HP +🟨🟨🟨🟨🟨⬛⬛⬛⬛⬛ 2 100 / 5 000 HP + +💚 Vos HP ⚔️ Dégâts 💢 Reçus + 53 143 67 +``` diff --git a/worldboss-app/.env.example b/worldboss-app/.env.example new file mode 100644 index 0000000..d5329d3 --- /dev/null +++ b/worldboss-app/.env.example @@ -0,0 +1,9 @@ +DISCORD_TOKEN=your_discord_bot_token_here +DISCORD_CLIENT_ID=your_discord_client_id_here +# Local dev +# DATABASE_URL=postgresql://user:password@localhost:5432/worldboss +# REDIS_URL=redis://localhost:6379 + +# Production (Docker — utiliser les noms de containers) +DATABASE_URL=postgresql://worldboss:PASSWORD@worldboss-db:5432/worldboss +REDIS_URL=redis://worldboss-redis:6379 diff --git a/worldboss-app/deployment/Dockerfile b/worldboss-app/deployment/Dockerfile new file mode 100644 index 0000000..075279c --- /dev/null +++ b/worldboss-app/deployment/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY prisma ./prisma +RUN npx prisma generate + +COPY src ./src + +CMD ["sh", "-c", "npx prisma migrate deploy && node src/index.js"] diff --git a/worldboss-app/deployment/docker-compose.prod.yml b/worldboss-app/deployment/docker-compose.prod.yml new file mode 100644 index 0000000..7607a53 --- /dev/null +++ b/worldboss-app/deployment/docker-compose.prod.yml @@ -0,0 +1,41 @@ +# Snippet à intégrer dans /var/www/docker-compose.yml sur le VPS +# Ajouter les services ci-dessous dans la section `services:` + +# ------------------------------------------------------- +# WORLDBOSS — Discord RPG Bot (Node.js + PostgreSQL + Redis) +# Pas d'interface HTTP, pas d'entrée nginx nécessaire +# ------------------------------------------------------- + + worldboss-bot: + build: + context: ./WorldBoss/worldboss-app + dockerfile: deployment/Dockerfile + container_name: worldboss-bot + env_file: + - ./WorldBoss/worldboss-app/.env + depends_on: + - worldboss-db + - worldboss-redis + restart: unless-stopped + + worldboss-db: + image: postgres:16-alpine + container_name: worldboss-db + restart: always + volumes: + - ./data/worldboss/postgres:/var/lib/postgresql/data + environment: + POSTGRES_USER: worldboss + POSTGRES_PASSWORD: ${WORLDBOSS_DB_PASSWORD} + POSTGRES_DB: worldboss + expose: + - "5432" + + worldboss-redis: + image: redis:7-alpine + container_name: worldboss-redis + restart: always + volumes: + - ./data/worldboss/redis:/data + expose: + - "6379" diff --git a/worldboss-app/package-lock.json b/worldboss-app/package-lock.json new file mode 100644 index 0000000..8495200 --- /dev/null +++ b/worldboss-app/package-lock.json @@ -0,0 +1,1061 @@ +{ + "name": "worldboss", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "worldboss", + "version": "1.0.0", + "dependencies": { + "@prisma/client": "^5.10.2", + "bullmq": "^5.4.2", + "discord.js": "^14.14.1", + "dotenv": "^16.4.5", + "ioredis": "^5.3.2", + "prisma": "^5.10.2" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bullmq": { + "version": "5.76.5", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.5.tgz", + "integrity": "sha512-2OKJP2+ckc+TygsWdxxeZYYgM9xYnVXgIAx+perflhamZ6FEBu/cSrvpqM8++fJI5OgsIFLfxA9UO7BDZ74Inw==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "1.11.12", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", + "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/worldboss-app/package.json b/worldboss-app/package.json new file mode 100644 index 0000000..f440f1f --- /dev/null +++ b/worldboss-app/package.json @@ -0,0 +1,26 @@ +{ + "name": "worldboss", + "version": "1.0.0", + "description": "WorldBoss — Discord RPG Bot", + "main": "src/index.js", + "type": "commonjs", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "deploy": "node src/deploy-commands.js", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev" + }, + "dependencies": { + "@prisma/client": "^5.10.2", + "bullmq": "^5.4.2", + "discord.js": "^14.14.1", + "dotenv": "^16.4.5", + "ioredis": "^5.3.2", + "prisma": "^5.10.2" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/worldboss-app/prisma/migrations/20260505174218_character_per_guild/migration.sql b/worldboss-app/prisma/migrations/20260505174218_character_per_guild/migration.sql new file mode 100644 index 0000000..49bed58 --- /dev/null +++ b/worldboss-app/prisma/migrations/20260505174218_character_per_guild/migration.sql @@ -0,0 +1,165 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Character" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "level" INTEGER NOT NULL DEFAULT 1, + "xp" INTEGER NOT NULL DEFAULT 0, + "gold" INTEGER NOT NULL DEFAULT 0, + "hp" INTEGER NOT NULL DEFAULT 120, + "actionPoints" INTEGER NOT NULL DEFAULT 10, + "actionPointsUpdatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Character_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Item" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "type" TEXT NOT NULL, + "rarity" TEXT NOT NULL, + "statsJson" JSONB NOT NULL DEFAULT '{}', + "skillJson" JSONB, + "effectJson" JSONB, + "price" INTEGER NOT NULL DEFAULT 0, + "levelRequired" INTEGER NOT NULL DEFAULT 1, + + CONSTRAINT "Item_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CharacterItem" ( + "id" SERIAL NOT NULL, + "characterId" INTEGER NOT NULL, + "itemId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CharacterItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Loadout" ( + "id" SERIAL NOT NULL, + "characterId" INTEGER NOT NULL, + "weaponId" TEXT, + "armorId" TEXT, + "helmetId" TEXT, + "bootsId" TEXT, + "accessory1Id" TEXT, + "accessory2Id" TEXT, + + CONSTRAINT "Loadout_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DungeonRun" ( + "id" SERIAL NOT NULL, + "characterId" INTEGER NOT NULL, + "chapter" INTEGER NOT NULL, + "currentRoom" INTEGER NOT NULL DEFAULT 1, + "totalRooms" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "lootJson" JSONB NOT NULL DEFAULT '[]', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DungeonRun_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Guild" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Guild_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GuildChannels" ( + "id" SERIAL NOT NULL, + "guildId" TEXT NOT NULL, + "categoryId" TEXT, + "infoChannelId" TEXT, + "generalChannelId" TEXT, + "dungeonChannelId" TEXT, + "marketChannelId" TEXT, + + CONSTRAINT "GuildChannels_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MarketListing" ( + "id" SERIAL NOT NULL, + "guildId" TEXT NOT NULL, + "sellerId" INTEGER NOT NULL, + "itemId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL DEFAULT 1, + "type" TEXT NOT NULL, + "startPrice" INTEGER NOT NULL, + "buyoutPrice" INTEGER, + "currentBid" INTEGER, + "bidderId" INTEGER, + "messageId" TEXT, + "status" TEXT NOT NULL DEFAULT 'active', + "expiresAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MarketListing_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Character_userId_guildId_key" ON "Character"("userId", "guildId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CharacterItem_characterId_itemId_key" ON "CharacterItem"("characterId", "itemId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Loadout_characterId_key" ON "Loadout"("characterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GuildChannels_guildId_key" ON "GuildChannels"("guildId"); + +-- AddForeignKey +ALTER TABLE "Character" ADD CONSTRAINT "Character_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Character" ADD CONSTRAINT "Character_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CharacterItem" ADD CONSTRAINT "CharacterItem_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CharacterItem" ADD CONSTRAINT "CharacterItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Loadout" ADD CONSTRAINT "Loadout_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DungeonRun" ADD CONSTRAINT "DungeonRun_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GuildChannels" ADD CONSTRAINT "GuildChannels_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketListing" ADD CONSTRAINT "MarketListing_sellerId_fkey" FOREIGN KEY ("sellerId") REFERENCES "Character"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketListing" ADD CONSTRAINT "MarketListing_bidderId_fkey" FOREIGN KEY ("bidderId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/worldboss-app/prisma/migrations/20260505185109_remove_dungeon_run/migration.sql b/worldboss-app/prisma/migrations/20260505185109_remove_dungeon_run/migration.sql new file mode 100644 index 0000000..fa7495c --- /dev/null +++ b/worldboss-app/prisma/migrations/20260505185109_remove_dungeon_run/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the `DungeonRun` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "DungeonRun" DROP CONSTRAINT "DungeonRun_characterId_fkey"; + +-- DropTable +DROP TABLE "DungeonRun"; diff --git a/worldboss-app/prisma/migrations/20260505191656_add_dungeon_run_hp_regen/migration.sql b/worldboss-app/prisma/migrations/20260505191656_add_dungeon_run_hp_regen/migration.sql new file mode 100644 index 0000000..9245fa0 --- /dev/null +++ b/worldboss-app/prisma/migrations/20260505191656_add_dungeon_run_hp_regen/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "Character" ADD COLUMN "hpUpdatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateTable +CREATE TABLE "DungeonRun" ( + "id" SERIAL NOT NULL, + "characterId" INTEGER NOT NULL, + "chapter" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DungeonRun_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "DungeonRun" ADD CONSTRAINT "DungeonRun_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/worldboss-app/prisma/migrations/migration_lock.toml b/worldboss-app/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/worldboss-app/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/worldboss-app/prisma/schema.prisma b/worldboss-app/prisma/schema.prisma new file mode 100644 index 0000000..0e69e00 --- /dev/null +++ b/worldboss-app/prisma/schema.prisma @@ -0,0 +1,141 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// Identité Discord — existe indépendamment des serveurs +model User { + id String @id // Discord user ID + username String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + characters Character[] +} + +// Personnage d'un joueur sur un serveur Discord spécifique +model Character { + id Int @id @default(autoincrement()) + userId String + guildId String + level Int @default(1) + xp Int @default(0) + gold Int @default(0) + hp Int @default(120) + hpUpdatedAt DateTime @default(now()) + actionPoints Int @default(10) + actionPointsUpdatedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + items CharacterItem[] + loadout Loadout? + dungeonRuns DungeonRun[] + marketListings MarketListing[] @relation("MarketSeller") + marketBids MarketListing[] @relation("MarketBidder") + + @@unique([userId, guildId]) +} + +model Item { + id String @id // e.g. 'sword_rusty' + name String + type String // weapon | armor | helmet | boots | accessory | consumable + rarity String // common | rare | epic | legendary + statsJson Json @default("{}") + skillJson Json? + effectJson Json? + price Int @default(0) + levelRequired Int @default(1) + + characterItems CharacterItem[] +} + +model CharacterItem { + id Int @id @default(autoincrement()) + characterId Int + itemId String + quantity Int @default(1) + createdAt DateTime @default(now()) + + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + item Item @relation(fields: [itemId], references: [id]) + + @@unique([characterId, itemId]) +} + +model Loadout { + id Int @id @default(autoincrement()) + characterId Int @unique + weaponId String? + armorId String? + helmetId String? + bootsId String? + accessory1Id String? + accessory2Id String? + + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) +} + +model DungeonRun { + id Int @id @default(autoincrement()) + characterId Int + chapter Int + status String @default("active") // active | completed | failed + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) +} + +// ── Guildes (serveurs Discord) ──────────────────────────────────────────────── + +model Guild { + id String @id // Discord guild ID + name String + ownerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + channels GuildChannels? + characters Character[] +} + +model GuildChannels { + id Int @id @default(autoincrement()) + guildId String @unique + categoryId String? + infoChannelId String? + generalChannelId String? + dungeonChannelId String? + marketChannelId String? + + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) +} + +model MarketListing { + id Int @id @default(autoincrement()) + guildId String + sellerId Int + itemId String + quantity Int @default(1) + type String // "auction" | "merchant" + startPrice Int + buyoutPrice Int? + currentBid Int? + bidderId Int? + messageId String? // Discord message ID pour éditer l'embed + status String @default("active") // active | sold | expired | cancelled + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + seller Character @relation("MarketSeller", fields: [sellerId], references: [id]) + bidder Character? @relation("MarketBidder", fields: [bidderId], references: [id]) +} diff --git a/worldboss-app/src/cache/redis.js b/worldboss-app/src/cache/redis.js new file mode 100644 index 0000000..c1fea36 --- /dev/null +++ b/worldboss-app/src/cache/redis.js @@ -0,0 +1,77 @@ +'use strict'; + +const Redis = require('ioredis'); + +const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', { + maxRetriesPerRequest: null, + enableReadyCheck: false, + lazyConnect: true, +}); + +redis.on('error', (err) => { + console.error('[Redis] Connection error:', err.message); +}); + +redis.on('connect', () => { + console.log('[Redis] Connected'); +}); + +// ── Helper: combat state ──────────────────────────────────────────────────── +const COMBAT_TTL = 60 * 30; // 30 minutes +const LOOT_TTL = 60 * 5; // 5 minutes + +async function getCombatState(userId) { + const raw = await redis.get(`combat:${userId}`); + return raw ? JSON.parse(raw) : null; +} + +async function setCombatState(userId, state) { + await redis.set(`combat:${userId}`, JSON.stringify(state), 'EX', COMBAT_TTL); +} + +async function deleteCombatState(userId) { + await redis.del(`combat:${userId}`); +} + +// ── Helper: pending loot (after combat victory) ───────────────────────────── +async function getPendingLoot(characterId) { + const raw = await redis.get(`loot:${characterId}`); + return raw ? JSON.parse(raw) : null; +} + +async function setPendingLoot(characterId, options) { + await redis.set(`loot:${characterId}`, JSON.stringify(options), 'EX', LOOT_TTL); +} + +async function deletePendingLoot(characterId) { + await redis.del(`loot:${characterId}`); +} + +// ── Helper: dungeon state ─────────────────────────────────────────────────── +const DUNGEON_TTL = 60 * 60 * 2; // 2 hours + +async function getDungeonState(characterId) { + const raw = await redis.get(`dungeon:${characterId}`); + return raw ? JSON.parse(raw) : null; +} + +async function setDungeonState(characterId, state) { + await redis.set(`dungeon:${characterId}`, JSON.stringify(state), 'EX', DUNGEON_TTL); +} + +async function deleteDungeonState(characterId) { + await redis.del(`dungeon:${characterId}`); +} + +module.exports = { + redis, + getCombatState, + setCombatState, + deleteCombatState, + getPendingLoot, + setPendingLoot, + deletePendingLoot, + getDungeonState, + setDungeonState, + deleteDungeonState, +}; diff --git a/worldboss-app/src/commands/admin/setup.js b/worldboss-app/src/commands/admin/setup.js new file mode 100644 index 0000000..96e44dc --- /dev/null +++ b/worldboss-app/src/commands/admin/setup.js @@ -0,0 +1,45 @@ +'use strict'; + +const { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } = require('discord.js'); +const { + ensureGuildInDb, + initGuildChannels, + updateGuildChannels, +} = require('../../services/guild.service'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('setup') + .setDescription('(Admin) Initialise ou réinitialise les channels WorldBoss du serveur.') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + + async execute(interaction) { + if (!interaction.guildId) { + return interaction.reply({ content: '❌ Commande serveur uniquement.', flags: MessageFlags.Ephemeral }); + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + await ensureGuildInDb(interaction.guild); + const channelIds = await initGuildChannels(interaction.guild); + await updateGuildChannels(interaction.guildId, channelIds); + + const embed = new EmbedBuilder() + .setTitle('✅ Serveur configuré !') + .setDescription( + 'Les channels ont été créés ou remis à jour.\n\n' + + `📌 Info : <#${channelIds.infoChannelId}>\n` + + `💬 Général : <#${channelIds.generalChannelId}>\n` + + `⚔️ Battle : <#${channelIds.dungeonChannelId}>\n` + + `🪙 Marché : <#${channelIds.marketChannelId}>`, + ) + .setColor(0x27ae60); + + return interaction.editReply({ embeds: [embed] }); + } catch (err) { + console.error('[Setup] Erreur:', err.message); + return interaction.editReply({ content: `❌ Erreur lors du setup : ${err.message}` }); + } + }, +}; diff --git a/worldboss-app/src/commands/dungeon/dungeon.js b/worldboss-app/src/commands/dungeon/dungeon.js new file mode 100644 index 0000000..82bfed9 --- /dev/null +++ b/worldboss-app/src/commands/dungeon/dungeon.js @@ -0,0 +1,35 @@ +'use strict'; + +const { SlashCommandBuilder, MessageFlags } = require('discord.js'); +const { characterExists, getCharacter } = require('../../services/player.service'); +const { startOrResumeDungeon } = require('../../services/dungeon.service'); +const { errorEmbed } = require('../../utils/embed'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('dungeon') + .setDescription('Lance ou reprend un donjon solo.'), + + async execute(interaction) { + const userId = interaction.user.id; + const guildId = interaction.guildId; + + try { + if (!(await characterExists(userId, guildId))) { + return interaction.reply({ + embeds: [errorEmbed('Vous devez créer un personnage avec `/start` avant de jouer.')], + flags: MessageFlags.Ephemeral, + }); + } + + const character = await getCharacter(userId, guildId); + await startOrResumeDungeon(interaction, character.id); + } catch (err) { + console.error('[/dungeon]', err); + return interaction.reply({ + embeds: [errorEmbed('Erreur lors du lancement du donjon.')], + flags: MessageFlags.Ephemeral, + }); + } + }, +}; diff --git a/worldboss-app/src/commands/inventory/inventory.js b/worldboss-app/src/commands/inventory/inventory.js new file mode 100644 index 0000000..fd3c22a --- /dev/null +++ b/worldboss-app/src/commands/inventory/inventory.js @@ -0,0 +1,46 @@ +'use strict'; + +const { SlashCommandBuilder, MessageFlags } = require('discord.js'); +const { characterExists, getCharacter } = require('../../services/player.service'); +const { getInventory, getOrCreateLoadout } = require('../../services/inventory.service'); +const { buildInventoryMessage, errorEmbed } = require('../../utils/embed'); +const { getAP } = require('../../services/actionPoints.service'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('inventory') + .setDescription('Affiche ton inventaire et ton équipement actuel.'), + + async execute(interaction) { + const userId = interaction.user.id; + const guildId = interaction.guildId; + + try { + if (!(await characterExists(userId, guildId))) { + return interaction.reply({ + embeds: [errorEmbed('Vous n\'avez pas encore de personnage sur ce serveur. Utilisez `/start` pour commencer !')], + flags: MessageFlags.Ephemeral, + }); + } + + const character = await getCharacter(userId, guildId); + const characterId = character.id; + + const [charItems, loadout, ap] = await Promise.all([ + getInventory(characterId), + getOrCreateLoadout(characterId), + getAP(characterId), + ]); + + const { embed, rows } = buildInventoryMessage(character, charItems, loadout, ap); + + return interaction.reply({ embeds: [embed], components: rows, flags: MessageFlags.Ephemeral }); + } catch (err) { + console.error('[/inventory]', err); + return interaction.reply({ + embeds: [errorEmbed('Erreur lors de la récupération de l\'inventaire.')], + flags: MessageFlags.Ephemeral, + }); + } + }, +}; diff --git a/worldboss-app/src/commands/player/profile.js b/worldboss-app/src/commands/player/profile.js new file mode 100644 index 0000000..4f05e21 --- /dev/null +++ b/worldboss-app/src/commands/player/profile.js @@ -0,0 +1,54 @@ +'use strict'; + +const { SlashCommandBuilder, MessageFlags } = require('discord.js'); +const { getCharacter, characterExists, computeRegenedHp } = require('../../services/player.service'); +const { computeStats, xpRequired } = require('../../utils/stats'); +const { buildProfileEmbed, errorEmbed } = require('../../utils/embed'); +const { getAP } = require('../../services/actionPoints.service'); +const { prisma } = require('../../db/prisma'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('profile') + .setDescription('Affiche ton profil et tes statistiques.'), + + async execute(interaction) { + const userId = interaction.user.id; + const guildId = interaction.guildId; + + try { + if (!(await characterExists(userId, guildId))) { + return interaction.reply({ + embeds: [errorEmbed('Vous n\'avez pas encore de personnage sur ce serveur. Utilisez `/start` pour commencer !')], + flags: MessageFlags.Ephemeral, + }); + } + + const character = await getCharacter(userId, guildId); + const [ap] = await Promise.all([getAP(character.id)]); + const stats = computeStats(character, character.loadout); + const xpReq = xpRequired(character.level); + const maxHp = stats.hp; + + // Apply passive HP regen and persist if changed + const regenedHp = computeRegenedHp(character.hp, character.hpUpdatedAt, maxHp); + if (regenedHp !== character.hp) { + await prisma.character.update({ + where: { id: character.id }, + data: { hp: regenedHp, hpUpdatedAt: new Date() }, + }); + character.hp = regenedHp; + } + + const embed = buildProfileEmbed(character, stats, character.loadout, xpReq, interaction, ap); + + return interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } catch (err) { + console.error('[/profile]', err); + return interaction.reply({ + embeds: [errorEmbed('Erreur lors de la récupération du profil.')], + flags: MessageFlags.Ephemeral, + }); + } + }, +}; diff --git a/worldboss-app/src/commands/player/start.js b/worldboss-app/src/commands/player/start.js new file mode 100644 index 0000000..1c428b1 --- /dev/null +++ b/worldboss-app/src/commands/player/start.js @@ -0,0 +1,97 @@ +'use strict'; + +const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); +const { characterExists, createCharacter } = require('../../services/player.service'); +const { computeStats, xpRequired } = require('../../utils/stats'); +const { errorEmbed } = require('../../utils/embed'); + +const SEP = '┄'.repeat(32); + +module.exports = { + data: new SlashCommandBuilder() + .setName('start') + .setDescription('Crée ton personnage et commence l\'aventure !'), + + async execute(interaction) { + const userId = interaction.user.id; + const guildId = interaction.guildId; + const username = interaction.user.username; + + try { + if (await characterExists(userId, guildId)) { + return interaction.reply({ + embeds: [errorEmbed('Tu as déjà un personnage sur ce serveur ! Utilise `/profile` pour voir tes stats.')], + flags: MessageFlags.Ephemeral, + }); + } + + const { character, loadout } = await createCharacter(userId, username, guildId); + const stats = computeStats(character, loadout); + const xpReq = xpRequired(character.level); + + const embed = new EmbedBuilder() + .setTitle('⚔️ WorldBoss — Ton aventure commence !') + .setDescription( + `> Bienvenue, **${username}** !\n` + + '> Tu viens de créer ton personnage et de recevoir ton équipement de départ.\n' + + '> Explore les donjons, bats des ennemis et monte en puissance.\n' + + `\`${SEP}\``, + ) + .setColor(0x2ecc71) + .setThumbnail(interaction.user.displayAvatarURL({ size: 128 })) + .addFields( + { + name: '🧑 Personnage', + value: [ + `> Nom **${username}**`, + `> Niveau **${character.level}**`, + `> XP **0** / ${xpReq}`, + `> Or **${character.gold}** 🪙`, + ].join('\n'), + inline: true, + }, + { + name: '📊 Statistiques', + value: [ + `> ❤️ HP **${stats.hp}**`, + `> ⚔️ ATK **${stats.atk}**`, + `> 🛡️ DEF **${stats.def}**`, + `> 💨 SPD **${stats.spd}**`, + `> 🎯 CRIT **${stats.crit}%**`, + ].join('\n'), + inline: true, + }, + { name: '​', value: `\`${SEP}\``, inline: false }, + { + name: '🎒 Équipement de départ', + value: [ + '> ⚪ **Épée rouillée** — Arme · `+3 ATK` · ✅ équipée', + '> ⚪ **Vêtements simples** — Armure · `+5 HP +1 DEF` · ✅ équipés', + '> 🧪 **Potion de soin ×3** — Consommable · `restaure 30 HP`', + ].join('\n'), + inline: false, + }, + { name: '​', value: `\`${SEP}\``, inline: false }, + { + name: '📖 Commandes disponibles', + value: [ + '> `/profile` — Voir ton profil complet', + '> `/inventory` — Gérer ton inventaire', + '> `/dungeon` — Lancer une expédition', + ].join('\n'), + inline: false, + }, + ) + .setFooter({ text: 'WorldBoss • Bonne chance, aventurier !' }) + .setTimestamp(); + + return interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + } catch (err) { + console.error('[/start]', err); + return interaction.reply({ + embeds: [errorEmbed('Une erreur est survenue lors de la création de ton personnage.')], + flags: MessageFlags.Ephemeral, + }); + } + }, +}; diff --git a/worldboss-app/src/data/abilities/arc1_catacombes.js b/worldboss-app/src/data/abilities/arc1_catacombes.js new file mode 100644 index 0000000..d643bab --- /dev/null +++ b/worldboss-app/src/data/abilities/arc1_catacombes.js @@ -0,0 +1,65 @@ +'use strict'; + +module.exports = { + precise_shot: { + name: 'Tir précis', + emoji: '🏹', + resolve(enemy, player, logs) { + const raw = enemy.atk * (100 / (100 + player.def * 0.5)); + const dmg = Math.max(1, Math.round(raw)); + player.hp -= dmg; + logs.push(`🏹 **${enemy.name}** utilise **Tir précis** et inflige **${dmg}** dégâts (ignore 50% de votre DEF).`); + }, + }, + + fireball: { + name: 'Boule de feu', + emoji: '🔥', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 1.8); + const defReduction = Math.floor(player.def * 0.7); + const dmg = Math.max(1, base - defReduction); + player.hp -= dmg; + logs.push(`🔥 **${enemy.name}** lance une **Boule de feu** et inflige **${dmg}** dégâts (ignore 30% DEF).`); + }, + }, + + shield_bash: { + name: 'Coup de bouclier', + emoji: '🛡️', + resolve(enemy, player, logs) { + const dmg = Math.max(1, enemy.atk - player.def); + player.hp -= dmg; + player.stunned = true; + logs.push(`🛡️ **${enemy.name}** utilise **Coup de bouclier** et inflige **${dmg}** dégâts. Vous êtes étourdi pour 1 tour !`); + }, + }, + + war_cry: { + name: 'Cri de guerre', + emoji: '⚔️', + resolve(enemy, _player, logs) { + enemy.atk += 6; + logs.push(`⚔️ **${enemy.name}** pousse un **Cri de guerre** ! Son ATK augmente de 6 (total : ${enemy.atk}).`); + }, + }, + + bone_shield: { + name: 'Bouclier d\'os', + emoji: '🦴', + resolve(enemy, _player, logs) { + enemy.def += 8; + logs.push(`🦴 **${enemy.name}** invoque un **Bouclier d'os** ! Sa DEF augmente de 8 (total : ${enemy.def}).`); + }, + }, + + raise_skeletons: { + name: 'Invoquer des squelettes', + emoji: '💀', + resolve(enemy, _player, logs) { + const heal = 30; + enemy.hp = Math.min(enemy.maxHp, enemy.hp + heal); + logs.push(`💀 **${enemy.name}** **Invoque des squelettes** ! Il récupère **${heal}** HP (HP : ${enemy.hp}/${enemy.maxHp}).`); + }, + }, +}; diff --git a/worldboss-app/src/data/abilities/arc2_chateau.js b/worldboss-app/src/data/abilities/arc2_chateau.js new file mode 100644 index 0000000..41fcff3 --- /dev/null +++ b/worldboss-app/src/data/abilities/arc2_chateau.js @@ -0,0 +1,34 @@ +'use strict'; + +module.exports = { + power_strike: { + name: 'Frappe puissante', + emoji: '💥', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 1.5); + const dmg = Math.max(1, base - player.def); + player.hp -= dmg; + logs.push(`💥 **${enemy.name}** utilise **Frappe puissante** et inflige **${dmg}** dégâts.`); + }, + }, + + heal: { + name: 'Soin', + emoji: '💚', + resolve(enemy, _player, logs) { + const healAmt = Math.floor(enemy.maxHp * 0.25); + enemy.hp = Math.min(enemy.maxHp, enemy.hp + healAmt); + logs.push(`💚 **${enemy.name}** se soigne de **${healAmt}** HP (HP : ${enemy.hp}/${enemy.maxHp}).`); + }, + }, + + rally: { + name: 'Ralliement', + emoji: '🚩', + resolve(enemy, _player, logs) { + enemy.atk += 5; + enemy.def += 3; + logs.push(`🚩 **${enemy.name}** se **Rallie** ! ATK +5 (${enemy.atk}), DEF +3 (${enemy.def}).`); + }, + }, +}; diff --git a/worldboss-app/src/data/abilities/arc3_bandits.js b/worldboss-app/src/data/abilities/arc3_bandits.js new file mode 100644 index 0000000..7f28b6d --- /dev/null +++ b/worldboss-app/src/data/abilities/arc3_bandits.js @@ -0,0 +1,48 @@ +'use strict'; + +module.exports = { + quick_strike: { + name: 'Frappe rapide', + emoji: '⚡', + resolve(enemy, player, logs) { + const dmg = Math.max(1, enemy.atk); + player.hp -= dmg; + logs.push(`⚡ **${enemy.name}** utilise **Frappe rapide** et inflige **${dmg}** dégâts (ignore toute la DEF).`); + }, + }, + + steal: { + name: 'Vol', + emoji: '👜', + resolve(enemy, player, logs) { + const dmg = Math.max(1, enemy.atk - player.def); + player.hp -= dmg; + logs.push(`👜 **${enemy.name}** tente de **Voler** et inflige **${dmg}** dégâts dans la mêlée.`); + }, + }, + + smash: { + name: 'Écrasement', + emoji: '🔨', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 1.4); + const dmg = Math.max(1, base - player.def); + player.hp -= dmg; + const debuffVal = 4; + player.def -= debuffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'def', value: -debuffVal, turns: 2 }]; + logs.push(`🔨 **${enemy.name}** utilise **Écrasement** ! Inflige **${dmg}** dégâts et réduit votre DEF de ${debuffVal} pour 2 tours (DEF : ${player.def}).`); + }, + }, + + intimidate: { + name: 'Intimidation', + emoji: '😤', + resolve(enemy, player, logs) { + const debuffVal = 5; + player.atk -= debuffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'atk', value: -debuffVal, turns: 2 }]; + logs.push(`😤 **${enemy.name}** vous **Intimide** ! Votre ATK est réduite de ${debuffVal} pour 2 tours (ATK : ${player.atk}).`); + }, + }, +}; diff --git a/worldboss-app/src/data/abilities/arc4_desert.js b/worldboss-app/src/data/abilities/arc4_desert.js new file mode 100644 index 0000000..a6255eb --- /dev/null +++ b/worldboss-app/src/data/abilities/arc4_desert.js @@ -0,0 +1,82 @@ +'use strict'; + +module.exports = { + shadow_step: { + name: 'Pas de l\'ombre', + emoji: '🌑', + resolve(enemy, player, logs) { + let dmg; + if (enemy.spd > player.spd) { + const base = Math.round(enemy.atk * 1.3); + const defReduction = Math.floor(player.def * 0.5); + dmg = Math.max(1, base - defReduction); + logs.push(`🌑 **${enemy.name}** utilise **Pas de l'ombre** (plus rapide que vous) et inflige **${dmg}** dégâts (ignore 50% DEF).`); + } else { + dmg = Math.max(1, Math.round(enemy.atk * 1.3) - player.def); + logs.push(`🌑 **${enemy.name}** utilise **Pas de l'ombre** et inflige **${dmg}** dégâts.`); + } + player.hp -= dmg; + }, + }, + + frenzy_strike: { + name: 'Frappe frénétique', + emoji: '🌀', + resolve(enemy, player, logs) { + const dmg1 = Math.max(1, enemy.atk - player.def); + player.hp -= dmg1; + const dmg2 = Math.max(1, enemy.atk - player.def); + player.hp -= dmg2; + logs.push(`🌀 **${enemy.name}** utilise **Frappe frénétique** : 2 attaques de **${dmg1}** + **${dmg2}** dégâts (total : ${dmg1 + dmg2}).`); + }, + }, + + shadow_strike: { + name: 'Frappe de l\'ombre', + emoji: '🗡️', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 1.6); + const defReduction = Math.floor(player.def * 0.6); + const isCrit = Math.random() < 0.3; + let dmg = Math.max(1, base - defReduction); + if (isCrit) dmg = Math.floor(dmg * 1.5); + player.hp -= dmg; + logs.push(`🗡️ **${enemy.name}** utilise **Frappe de l'ombre** et inflige **${dmg}** dégâts (ignore 40% DEF${isCrit ? ', CRITIQUE !' : ''}).`); + }, + }, + + curse: { + name: 'Malédiction', + emoji: '🔮', + resolve(enemy, player, logs) { + const dot = { id: 'curse', label: 'Malédiction', value: 8, turns: 3 }; + const existing = (player.dots ?? []).findIndex((d) => d.id === 'curse'); + if (existing !== -1) { + player.dots[existing] = dot; + } else { + player.dots = [...(player.dots ?? []), dot]; + } + const debuffVal = 3; + player.def -= debuffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'def', value: -debuffVal, turns: 3 }]; + logs.push(`🔮 **${enemy.name}** lance une **Malédiction** ! Vous subissez 8 dégâts/tour pendant 3 tours et votre DEF est réduite de ${debuffVal} (DEF : ${player.def}).`); + }, + }, + + poison_sting: { + name: 'Piqûre empoisonnée', + emoji: '🐍', + resolve(enemy, player, logs) { + const dmg = Math.max(1, enemy.atk - player.def); + player.hp -= dmg; + const dot = { id: 'poison', label: 'Poison', value: 6, turns: 4 }; + const existing = (player.dots ?? []).findIndex((d) => d.id === 'poison'); + if (existing !== -1) { + player.dots[existing] = dot; + } else { + player.dots = [...(player.dots ?? []), dot]; + } + logs.push(`🐍 **${enemy.name}** utilise **Piqûre empoisonnée** : **${dmg}** dégâts + poison (6 dégâts/tour, 4 tours).`); + }, + }, +}; diff --git a/worldboss-app/src/data/abilities/arc6_steampunk.js b/worldboss-app/src/data/abilities/arc6_steampunk.js new file mode 100644 index 0000000..6f09ddc --- /dev/null +++ b/worldboss-app/src/data/abilities/arc6_steampunk.js @@ -0,0 +1,83 @@ +'use strict'; + +module.exports = { + steam_blast: { + name: 'Explosion de vapeur', + emoji: '💨', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 1.4); + const dmg = Math.max(1, base - player.def); + player.hp -= dmg; + logs.push(`💨 **${enemy.name}** utilise **Explosion de vapeur** et inflige **${dmg}** dégâts.`); + }, + }, + + oil_slick: { + name: 'Nappe d\'huile', + emoji: '🛢️', + resolve(enemy, player, logs) { + const debuffVal = 5; + player.spd -= debuffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'spd', value: -debuffVal, turns: 3 }]; + logs.push(`🛢️ **${enemy.name}** déverse une **Nappe d'huile** ! Votre SPD est réduite de ${debuffVal} pour 3 tours (SPD : ${player.spd}).`); + }, + }, + + overheat: { + name: 'Surchauffe', + emoji: '🌡️', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 2.0); + const dmg = Math.max(1, base - player.def); + player.hp -= dmg; + const selfDmg = Math.floor(enemy.maxHp * 0.1); + enemy.hp -= selfDmg; + logs.push(`🌡️ **${enemy.name}** utilise **Surchauffe** ! Inflige **${dmg}** dégâts mais se blesse pour **${selfDmg}** HP (HP : ${enemy.hp}/${enemy.maxHp}).`); + }, + }, + + steam_shield: { + name: 'Bouclier de vapeur', + emoji: '🛡️', + resolve(enemy, _player, logs) { + enemy.def += 12; + logs.push(`🛡️ **${enemy.name}** active son **Bouclier de vapeur** ! Sa DEF augmente de 12 (total : ${enemy.def}).`); + }, + }, + + shield_wall: { + name: 'Mur de boucliers', + emoji: '🏰', + resolve(enemy, _player, logs) { + enemy.def += 10; + logs.push(`🏰 **${enemy.name}** forme un **Mur de boucliers** ! Sa DEF augmente de 10 (total : ${enemy.def}).`); + }, + }, + + royal_command: { + name: 'Commandement royal', + emoji: '👑', + resolve(enemy, _player, logs) { + enemy.atk += 8; + logs.push(`👑 **${enemy.name}** lance un **Commandement royal** ! Son ATK augmente de 8 (total : ${enemy.atk}).`); + }, + }, + + hellfire: { + name: 'Feu infernal', + emoji: '🔥', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 2.0); + const dmg = Math.max(1, base - player.def); + player.hp -= dmg; + const dot = { id: 'hellfire', label: 'Feu Infernal', value: 15, turns: 3 }; + const existing = (player.dots ?? []).findIndex((d) => d.id === 'hellfire'); + if (existing !== -1) { + player.dots[existing] = dot; + } else { + player.dots = [...(player.dots ?? []), dot]; + } + logs.push(`🔥 **${enemy.name}** déchaîne le **Feu Infernal** ! **${dmg}** dégâts + 15 dégâts/tour pendant 3 tours.`); + }, + }, +}; diff --git a/worldboss-app/src/data/abilities/arc8_enfers.js b/worldboss-app/src/data/abilities/arc8_enfers.js new file mode 100644 index 0000000..596d22c --- /dev/null +++ b/worldboss-app/src/data/abilities/arc8_enfers.js @@ -0,0 +1,83 @@ +'use strict'; + +module.exports = { + life_drain: { + name: 'Drain de vie', + emoji: '🩸', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 1.2); + const dmg = Math.max(1, base - player.def); + player.hp -= dmg; + const healAmt = Math.floor(dmg * 0.5); + enemy.hp = Math.min(enemy.maxHp, enemy.hp + healAmt); + logs.push(`🩸 **${enemy.name}** utilise **Drain de vie** ! Inflige **${dmg}** dégâts et se soigne de **${healAmt}** HP (HP : ${enemy.hp}/${enemy.maxHp}).`); + }, + }, + + wail: { + name: 'Hurlement', + emoji: '😱', + resolve(enemy, player, logs) { + const debuffVal = 8; + player.atk -= debuffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'atk', value: -debuffVal, turns: 3 }]; + logs.push(`😱 **${enemy.name}** pousse un **Hurlement** ! Votre ATK est réduite de ${debuffVal} pour 3 tours (ATK : ${player.atk}).`); + }, + }, + + fire_breath: { + name: 'Souffle de feu', + emoji: '🐉', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 1.7); + const dmg = Math.max(1, base - player.def); + player.hp -= dmg; + const dot = { id: 'fire_breath', label: 'Brûlure Draconique', value: 10, turns: 2 }; + const existing = (player.dots ?? []).findIndex((d) => d.id === 'fire_breath'); + if (existing !== -1) { + player.dots[existing] = dot; + } else { + player.dots = [...(player.dots ?? []), dot]; + } + logs.push(`🐉 **${enemy.name}** crache un **Souffle de feu** ! **${dmg}** dégâts + Brûlure Draconique (10 dégâts/tour, 2 tours).`); + }, + }, + + charm: { + name: 'Charme', + emoji: '💕', + resolve(enemy, player, logs) { + const debuffVal = -100; + player.spd += debuffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'spd', value: debuffVal, turns: 1 }]; + logs.push(`💕 **${enemy.name}** vous **Charme** ! Vous êtes subjugué et ne pouvez pas agir ce tour (SPD temporairement réduite).`); + }, + }, + + hell_cry: { + name: 'Cri infernal', + emoji: '😈', + resolve(enemy, _player, logs) { + enemy.atk += 15; + logs.push(`😈 **${enemy.name}** pousse un **Cri infernal** ! Son ATK augmente de 15 (total : ${enemy.atk}).`); + }, + }, + + inferno: { + name: 'Inferno', + emoji: '🌋', + resolve(enemy, player, logs) { + const base = Math.round(enemy.atk * 2.5); + const dmg = Math.max(1, base - player.def); + player.hp -= dmg; + const dot = { id: 'inferno', label: 'Inferno', value: 20, turns: 3 }; + const existing = (player.dots ?? []).findIndex((d) => d.id === 'inferno'); + if (existing !== -1) { + player.dots[existing] = dot; + } else { + player.dots = [...(player.dots ?? []), dot]; + } + logs.push(`🌋 **${enemy.name}** déchaîne l'**Inferno** ! **${dmg}** dégâts + Inferno (20 dégâts/tour, 3 tours).`); + }, + }, +}; diff --git a/worldboss-app/src/data/abilities/index.js b/worldboss-app/src/data/abilities/index.js new file mode 100644 index 0000000..100374b --- /dev/null +++ b/worldboss-app/src/data/abilities/index.js @@ -0,0 +1,19 @@ +'use strict'; + +const arc1 = require('./arc1_catacombes'); +const arc2 = require('./arc2_chateau'); +const arc3 = require('./arc3_bandits'); +const arc4 = require('./arc4_desert'); +const arc6 = require('./arc6_steampunk'); +const arc8 = require('./arc8_enfers'); + +const ABILITIES = { + ...arc1, + ...arc2, + ...arc3, + ...arc4, + ...arc6, + ...arc8, +}; + +module.exports = { ABILITIES }; diff --git a/worldboss-app/src/data/dungeons.js b/worldboss-app/src/data/dungeons.js new file mode 100644 index 0000000..ee2ab1c --- /dev/null +++ b/worldboss-app/src/data/dungeons.js @@ -0,0 +1,142 @@ +'use strict'; + +const DUNGEONS = { + 1: { + id: 1, + name: 'Les Catacombes', + lore: 'Des squelettes réanimés rôdent dans ces ruines oubliées...', + levelRequired: 1, + rooms: [ + { + room: 1, + enemies: ['skeleton'], + description: 'Une salle poussiéreuse éclairée par une torche vacillante. Un squelette se retourne lentement vers vous.', + }, + { + room: 2, + enemies: ['skeleton_archer'], + description: 'Un couloir étroit. Une flèche siffle dans l\'obscurité avant même que vous ne voyiez la silhouette.', + }, + { + room: 3, + enemies: ['skeleton', 'skeleton_archer'], + description: 'Une grande salle voûtée. Un squelette avance vers vous pendant que son archer vise depuis l\'ombre.', + }, + ], + }, + 2: { + id: 2, + name: 'Les Catacombes - Part 2', + lore: 'Les squelettes sont de plus en plus nombreux et agressifs dans les profondeurs des catacombes...', + levelRequired: 2, + rooms: [ + { + room: 1, + enemies: ['skeleton', 'skeleton_mage'], + description: 'Vous entrez dans une salle plus grande, les murs sont couverts de crânes. Un squelette vous attaque pendant que son archer tire depuis une galerie en hauteur.', + }, + { + room: 2, + enemies: ['skeleton_mage', 'skeleton_archer'], + description: 'Un couloir étroit. Une boule de feu traverse l\'obscurité avant même que vous ne voyez la silhouette.', + }, + { + room: 3, + enemies: ['skeleton', 'skeleton_archer'], + description: 'Une corniche étroite au-dessus d\'un puits sans fond. Deux squelettes avancent vers vous, leurs os grinçant à chaque pas.', + }, + ], + }, + 3: { + id: 3, + name: 'Les Catacombes - Part 3', + lore: 'Un marchand isolé s\'est réfugié à l\'hôtel, cerné par des squelettes. Il a besoin d\'aide pour s\'en sortir vivant...', + levelRequired: 3, + rooms: [ + { + room: 1, + enemies: ['skeleton', 'skeleton_mage'], + description: 'L\'entrée de l\'hôtel. Un squelette décharné avance sur vous tandis qu\'un mage osseux lève les bras, prêt à invoquer.', + }, + { + room: 2, + enemies: ['skeleton', 'skeleton_archer'], + description: 'Le hall de l\'hôtel. Derrière le comptoir, un squelette se précipite vers vous pendant qu\'un archer vise depuis la mezzanine.', + }, + { + room: 3, + enemies: ['skeleton', 'skeleton_mage', 'skeleton_archer', 'skeleton_knight'], + description: 'Le bar de l\'hôtel. Quatre silhouettes osseuses encerclent un marchand tremblant dans un coin. *"Au secours !"* crie Aldric en vous apercevant — il se joint au combat !', + ally: 'merchant_aldric', + }, + ], + reward: { + unlockMarket: true, + message: '🏪 **Aldric** te serre la main avec gratitude.\n*"Tu m\'as sauvé la mise, ami. Ma boutique te sera désormais ouverte — et je ferai passer le mot au marché."*\n\n**Le marché est maintenant débloqué !**', + }, + }, + 4: { + id: 4, + name: 'Les Catacombes - Part 4', + lore: 'Les squelettes sont de plus en plus nombreux et agressifs dans les profondeurs des catacombes...', + levelRequired: 4, + rooms: [ + { + room: 1, + enemies: ['skeleton_knight', 'skeleton_archer'], + description: 'L\'entrée de l\'hôtel. Un squelette décharné avance sur vous tandis qu\'un mage osseux lève les bras, prêt à invoquer.', + }, + { + room: 2, + enemies: ['skeleton_knight', 'skeleton_knight'], + description: 'Le hall de l\'hôtel. Derrière le comptoir, un squelette se précipite vers vous pendant qu\'un archer vise depuis la mezzanine.', + }, + { + room: 3, + enemies: ['skeleton_warlord', 'skeleton_mage', 'skeleton_archer', 'skeleton_knight'], + description: 'Le bar de l\'hôtel. Quatre silhouettes osseuses encerclent un marchand tremblant dans un coin. *"Au secours !"* crie Aldric en vous apercevant — il se joint au combat !', + + }, + ], + }, + 5: { + id: 5, + name: 'Les Catacombes - Part 5', + lore: 'Les squelettes sont de plus en plus nombreux et agressifs dans les profondeurs des catacombes...', + levelRequired: 5, + rooms: [ + { + room: 1, + enemies: ['skeleton_knight', 'skeleton_archer'], + description: 'L\'entrée de l\'hôtel. Un squelette décharné avance sur vous tandis qu\'un mage osseux lève les bras, prêt à invoquer.', + }, + { + room: 2, + enemies: ['skeleton_warlord', 'skeleton_knight'], + description: 'Le hall de l\'hôtel. Derrière le comptoir, un squelette se précipite vers vous pendant qu\'un archer vise depuis la mezzanine.', + }, + { + room: 3, + enemies: ['skeleton_warlord', 'skeleton_mage', 'skeleton_archer', 'skeleton_king'], + description: 'Le bar de l\'hôtel. Quatre silhouettes osseuses encerclent un marchand tremblant dans un coin. *"Au secours !"* crie Aldric en vous apercevant — il se joint au combat !', + + }, + ], + }, +}; + +// NPC allies definitions +const ALLIES = { + merchant_aldric: { + id: 'merchant_aldric', + name: 'Aldric le Marchand', + hp: 45, + maxHp: 45, + atk: 8, + def: 3, + spd: 5, + emoji: '🧑‍💼', + }, +}; + +module.exports = { DUNGEONS, ALLIES }; diff --git a/worldboss-app/src/data/enemies/arc1_catacombes.js b/worldboss-app/src/data/enemies/arc1_catacombes.js new file mode 100644 index 0000000..541f5b7 --- /dev/null +++ b/worldboss-app/src/data/enemies/arc1_catacombes.js @@ -0,0 +1,111 @@ +'use strict'; + +// Arc 1 - Catacombes (niveaux 1-3) +// Stats: HP 40-150, ATK 8-25, DEF 1-10, SPD 4-12, XP 10-60, Gold 1-10 + +module.exports = { + skeleton: { + id: 'skeleton', + name: 'Squelette', + hp: 50, + maxHp: 50, + atk: 10, + def: 3, + spd: 6, + crit: 0, + xp: 12, + ability: null, + gold: { min: 1, max: 3 }, + loot: ['sword_rusty', 'helmet_broken', 'ring_wood', 'potion_heal'], + }, + + skeleton_archer: { + id: 'skeleton_archer', + name: 'Archer Squelette', + hp: 40, + maxHp: 40, + atk: 14, + def: 2, + spd: 12, + crit: 5, + xp: 18, + ability: 'precise_shot', + gold: { min: 1, max: 3 }, + loot: ['bow_wooden', 'cloth_simple', 'potion_heal'], + }, + + skeleton_mage: { + id: 'skeleton_mage', + name: 'Mage Squelette', + hp: 35, + maxHp: 35, + atk: 18, + def: 1, + spd: 8, + crit: 5, + xp: 22, + ability: 'fireball', + gold: { min: 2, max: 4 }, + loot: ['staff_bone', 'robe_cloth', 'ring_power', 'potion_mana'], + }, + + skeleton_knight: { + id: 'skeleton_knight', + name: 'Chevalier Squelette', + hp: 90, + maxHp: 90, + atk: 13, + def: 8, + spd: 4, + crit: 0, + xp: 28, + ability: 'shield_bash', + gold: { min: 3, max: 6 }, + loot: ['sword_steel', 'iron_armor', 'potion_heal'], + }, + + skeleton_warlord: { + id: 'skeleton_warlord', + name: 'Chef de guerre Squelette', + hp: 120, + maxHp: 120, + atk: 18, + def: 9, + spd: 5, + crit: 5, + xp: 45, + ability: 'war_cry', + gold: { min: 5, max: 8 }, + loot: ['axe_heavy', 'iron_armor', 'potion_heal', 'potion_mana', 'amulet_bone'], + }, + + skeleton_king: { + id: 'skeleton_king', + name: 'Roi Squelette', + hp: 150, + maxHp: 150, + atk: 22, + def: 7, + spd: 9, + crit: 8, + xp: 55, + ability: 'bone_shield', + gold: { min: 6, max: 10 }, + loot: ['sword_steel', 'iron_armor', 'potion_heal', 'potion_mana', 'circlet_bone', 'talisman_old'], + }, + + necromancer: { + id: 'necromancer', + name: 'Nécromancien', + hp: 80, + maxHp: 80, + atk: 16, + def: 3, + spd: 7, + crit: 5, + xp: 38, + ability: 'raise_skeletons', + gold: { min: 4, max: 7 }, + loot: ['book_fire', 'magic_robe', 'potion_mana', 'staff_bone', 'ring_power'], + }, +}; diff --git a/worldboss-app/src/data/enemies/arc2_chateau.js b/worldboss-app/src/data/enemies/arc2_chateau.js new file mode 100644 index 0000000..fcbd708 --- /dev/null +++ b/worldboss-app/src/data/enemies/arc2_chateau.js @@ -0,0 +1,51 @@ +'use strict'; + +// Arc 2 - Château maudit (niveaux 3-5) +// Stats: HP 80-180, ATK 14-22, DEF 5-12, SPD 4-12, XP 25-55, Gold 3-10 + +module.exports = { + castle_warrior: { + id: 'castle_warrior', + name: 'Guerrier du château', + hp: 100, + maxHp: 100, + atk: 16, + def: 9, + spd: 5, + crit: 0, + xp: 30, + ability: 'power_strike', + gold: { min: 3, max: 6 }, + loot: ['sword_steel', 'iron_armor', 'potion_heal'], + }, + + castle_priest: { + id: 'castle_priest', + name: 'Prêtre du château', + hp: 80, + maxHp: 80, + atk: 14, + def: 5, + spd: 6, + crit: 0, + xp: 35, + ability: 'heal', + gold: { min: 3, max: 6 }, + loot: ['staff_bone', 'robe_cloth', 'potion_mana', 'amulet_bone'], + }, + + castle_captain: { + id: 'castle_captain', + name: 'Capitaine du château', + hp: 150, + maxHp: 150, + atk: 20, + def: 11, + spd: 8, + crit: 5, + xp: 50, + ability: 'rally', + gold: { min: 6, max: 10 }, + loot: ['axe_heavy', 'iron_armor', 'potion_heal', 'helmet_soldier'], + }, +}; diff --git a/worldboss-app/src/data/enemies/arc3_bandits.js b/worldboss-app/src/data/enemies/arc3_bandits.js new file mode 100644 index 0000000..70bc9d7 --- /dev/null +++ b/worldboss-app/src/data/enemies/arc3_bandits.js @@ -0,0 +1,66 @@ +'use strict'; + +// Arc 3 - Bandits des routes (niveaux 5-8) +// Stats: HP 120-220, ATK 18-28, DEF 6-15, SPD 6-16, XP 35-70, Gold 4-12 + +module.exports = { + bandit_scout: { + id: 'bandit_scout', + name: 'Éclaireur bandit', + hp: 120, + maxHp: 120, + atk: 18, + def: 6, + spd: 16, + crit: 10, + xp: 38, + ability: 'quick_strike', + gold: { min: 4, max: 7 }, + loot: ['dagger_shadow', 'boots_shadow', 'potion_heal'], + }, + + bandit_thief: { + id: 'bandit_thief', + name: 'Voleur bandit', + hp: 130, + maxHp: 130, + atk: 20, + def: 7, + spd: 14, + crit: 8, + xp: 45, + ability: 'steal', + gold: { min: 5, max: 9 }, + loot: ['dagger_shadow', 'studded_leather', 'potion_heal', 'smoke_bomb'], + }, + + bandit_brute: { + id: 'bandit_brute', + name: 'Brute bandit', + hp: 190, + maxHp: 190, + atk: 24, + def: 12, + spd: 7, + crit: 0, + xp: 58, + ability: 'smash', + gold: { min: 6, max: 10 }, + loot: ['axe_heavy', 'studded_leather', 'potion_heal'], + }, + + bandit_leader: { + id: 'bandit_leader', + name: 'Chef bandit', + hp: 175, + maxHp: 175, + atk: 26, + def: 10, + spd: 12, + crit: 8, + xp: 65, + ability: 'intimidate', + gold: { min: 8, max: 12 }, + loot: ['sword_ancient', 'studded_leather', 'potion_heal', 'ring_shadow'], + }, +}; diff --git a/worldboss-app/src/data/enemies/arc4_desert_assassins.js b/worldboss-app/src/data/enemies/arc4_desert_assassins.js new file mode 100644 index 0000000..dac71be --- /dev/null +++ b/worldboss-app/src/data/enemies/arc4_desert_assassins.js @@ -0,0 +1,51 @@ +'use strict'; + +// Arc 4 - Désert assassins (niveaux 8-11) +// Stats: HP 160-260, ATK 24-35, DEF 8-18, SPD 8-18, XP 50-90, Gold 6-15 + +module.exports = { + desert_ninja: { + id: 'desert_ninja', + name: 'Ninja du désert', + hp: 160, + maxHp: 160, + atk: 28, + def: 8, + spd: 18, + crit: 15, + xp: 55, + ability: 'shadow_step', + gold: { min: 6, max: 10 }, + loot: ['dagger_shadow', 'hood_shadow', 'antidote'], + }, + + desert_berserker: { + id: 'desert_berserker', + name: 'Berserker du désert', + hp: 230, + maxHp: 230, + atk: 32, + def: 10, + spd: 10, + crit: 5, + xp: 68, + ability: 'frenzy_strike', + gold: { min: 7, max: 12 }, + loot: ['scimitar_gold', 'throwing_knife', 'potion_heal'], + }, + + desert_assassin: { + id: 'desert_assassin', + name: 'Assassin du désert', + hp: 175, + maxHp: 175, + atk: 34, + def: 9, + spd: 17, + crit: 20, + xp: 80, + ability: 'shadow_strike', + gold: { min: 9, max: 15 }, + loot: ['dagger_shadow', 'boots_shadow', 'smoke_bomb', 'ring_shadow'], + }, +}; diff --git a/worldboss-app/src/data/enemies/arc5_desert_monstres.js b/worldboss-app/src/data/enemies/arc5_desert_monstres.js new file mode 100644 index 0000000..8632e6b --- /dev/null +++ b/worldboss-app/src/data/enemies/arc5_desert_monstres.js @@ -0,0 +1,51 @@ +'use strict'; + +// Arc 5 - Désert monstres (niveaux 11-14) +// Stats: HP 200-320, ATK 30-45, DEF 12-22, SPD 6-14, XP 70-120, Gold 8-20 + +module.exports = { + desert_snake: { + id: 'desert_snake', + name: 'Serpent du désert', + hp: 200, + maxHp: 200, + atk: 32, + def: 12, + spd: 12, + crit: 8, + xp: 75, + ability: 'poison_sting', + gold: { min: 8, max: 14 }, + loot: ['whip_venom', 'desert_cloth', 'antidote', 'scarab_beetle'], + }, + + desert_scorpion: { + id: 'desert_scorpion', + name: 'Scorpion du désert', + hp: 240, + maxHp: 240, + atk: 38, + def: 18, + spd: 8, + crit: 5, + xp: 90, + ability: 'poison_sting', + gold: { min: 10, max: 16 }, + loot: ['scimitar_gold', 'desert_cloth', 'antidote'], + }, + + desert_mummy: { + id: 'desert_mummy', + name: 'Momie du désert', + hp: 310, + maxHp: 310, + atk: 35, + def: 20, + spd: 6, + crit: 0, + xp: 110, + ability: 'curse', + gold: { min: 12, max: 20 }, + loot: ['staff_desert', 'desert_robe', 'potion_mana', 'turban_desert'], + }, +}; diff --git a/worldboss-app/src/data/enemies/arc6_desert_steampunk.js b/worldboss-app/src/data/enemies/arc6_desert_steampunk.js new file mode 100644 index 0000000..90bad40 --- /dev/null +++ b/worldboss-app/src/data/enemies/arc6_desert_steampunk.js @@ -0,0 +1,66 @@ +'use strict'; + +// Arc 6 - Désert steampunk (niveaux 14-17) +// Stats: HP 280-400, ATK 40-55, DEF 18-30, SPD 5-12, XP 100-160, Gold 12-30 + +module.exports = { + steampunk_steamkin: { + id: 'steampunk_steamkin', + name: 'Steamkin du désert', + hp: 290, + maxHp: 290, + atk: 42, + def: 20, + spd: 8, + crit: 5, + xp: 105, + ability: 'steam_blast', + gold: { min: 12, max: 20 }, + loot: ['steam_armor', 'goggles_steam', 'elixir_berserk'], + }, + + steampunk_mechanic: { + id: 'steampunk_mechanic', + name: 'Mécanicien du désert', + hp: 320, + maxHp: 320, + atk: 44, + def: 24, + spd: 6, + crit: 0, + xp: 120, + ability: 'oil_slick', + gold: { min: 15, max: 24 }, + loot: ['goggles_steam', 'boots_steam', 'scroll_fire', 'elixir_iron'], + }, + + steampunk_gunner: { + id: 'steampunk_gunner', + name: 'Tireur du désert', + hp: 280, + maxHp: 280, + atk: 52, + def: 18, + spd: 12, + crit: 15, + xp: 138, + ability: 'overheat', + gold: { min: 18, max: 26 }, + loot: ['rifle_steam', 'goggles_steam', 'scroll_fire'], + }, + + steampunk_captain: { + id: 'steampunk_captain', + name: 'Capitaine du désert', + hp: 390, + maxHp: 390, + atk: 50, + def: 28, + spd: 9, + crit: 8, + xp: 155, + ability: 'steam_shield', + gold: { min: 22, max: 30 }, + loot: ['rifle_steam', 'steam_armor', 'boots_steam', 'elixir_berserk'], + }, +}; diff --git a/worldboss-app/src/data/enemies/arc7_desert_palais.js b/worldboss-app/src/data/enemies/arc7_desert_palais.js new file mode 100644 index 0000000..a565f71 --- /dev/null +++ b/worldboss-app/src/data/enemies/arc7_desert_palais.js @@ -0,0 +1,81 @@ +'use strict'; + +// Arc 7 - Palais du désert (niveaux 17-20) +// Stats: HP 360-500, ATK 48-65, DEF 24-38, SPD 6-14, XP 140-200, Gold 18-40 + +module.exports = { + desert_guard: { + id: 'desert_guard', + name: 'Garde du désert', + hp: 370, + maxHp: 370, + atk: 50, + def: 28, + spd: 8, + crit: 0, + xp: 145, + ability: 'shield_wall', + gold: { min: 18, max: 28 }, + loot: ['royal_armor', 'sword_pharaoh', 'potion_heal'], + }, + + desert_shaman: { + id: 'desert_shaman', + name: 'Chaman du désert', + hp: 360, + maxHp: 360, + atk: 52, + def: 24, + spd: 10, + crit: 8, + xp: 155, + ability: 'curse', + gold: { min: 20, max: 30 }, + loot: ['staff_royal', 'ankh_pharaoh', 'potion_mana'], + }, + + desert_prince: { + id: 'desert_prince', + name: 'Prince du désert', + hp: 430, + maxHp: 430, + atk: 58, + def: 32, + spd: 12, + crit: 10, + xp: 175, + ability: 'royal_command', + gold: { min: 25, max: 35 }, + loot: ['sword_pharaoh', 'royal_armor', 'crown_pharaoh'], + }, + + desert_pharaoh: { + id: 'desert_pharaoh', + name: 'Pharaon du désert', + hp: 490, + maxHp: 490, + atk: 62, + def: 36, + spd: 8, + crit: 12, + xp: 190, + ability: 'hellfire', + gold: { min: 30, max: 40 }, + loot: ['sword_pharaoh', 'crown_pharaoh', 'ankh_pharaoh', 'royal_armor'], + }, + + demon_lord: { + id: 'demon_lord', + name: 'Seigneur démon', + hp: 500, + maxHp: 500, + atk: 65, + def: 38, + spd: 14, + crit: 15, + xp: 200, + ability: 'hellfire', + gold: { min: 32, max: 40 }, + loot: ['sword_pharaoh', 'staff_royal', 'royal_armor', 'ankh_pharaoh'], + }, +}; diff --git a/worldboss-app/src/data/enemies/arc8_enfers.js b/worldboss-app/src/data/enemies/arc8_enfers.js new file mode 100644 index 0000000..8d02ca6 --- /dev/null +++ b/worldboss-app/src/data/enemies/arc8_enfers.js @@ -0,0 +1,126 @@ +'use strict'; + +// Arc 8 - Les Enfers (niveaux 20-25) +// Stats: HP 450-700, ATK 60-85, DEF 30-50, SPD 7-16, XP 180-300, Gold 25-60 + +module.exports = { + desert_goule: { + id: 'desert_goule', + name: 'Goule du désert', + hp: 460, + maxHp: 460, + atk: 62, + def: 30, + spd: 10, + crit: 5, + xp: 185, + ability: 'life_drain', + gold: { min: 25, max: 38 }, + loot: ['infernal_plate', 'helm_infernal', 'potion_heal'], + }, + + desert_banshee: { + id: 'desert_banshee', + name: 'Banshee du désert', + hp: 450, + maxHp: 450, + atk: 65, + def: 30, + spd: 14, + crit: 10, + xp: 195, + ability: 'wail', + gold: { min: 28, max: 42 }, + loot: ['staff_infernal', 'pendant_soul', 'potion_mana'], + }, + + desert_demon: { + id: 'desert_demon', + name: 'Démon du désert', + hp: 550, + maxHp: 550, + atk: 70, + def: 38, + spd: 12, + crit: 12, + xp: 230, + ability: 'shadow_strike', + gold: { min: 32, max: 48 }, + loot: ['axe_infernal', 'infernal_plate', 'potion_heal'], + }, + + desert_demonist: { + id: 'desert_demonist', + name: 'Démoniste du désert', + hp: 500, + maxHp: 500, + atk: 68, + def: 33, + spd: 10, + crit: 8, + xp: 215, + ability: 'curse', + gold: { min: 30, max: 45 }, + loot: ['staff_infernal', 'helm_infernal', 'potion_mana'], + }, + + hell_hound: { + id: 'hell_hound', + name: 'Chien de l\'enfer', + hp: 580, + maxHp: 580, + atk: 74, + def: 35, + spd: 16, + crit: 10, + xp: 245, + ability: 'fire_breath', + gold: { min: 35, max: 52 }, + loot: ['axe_infernal', 'boots_infernal', 'potion_heal'], + }, + + hell_succubus: { + id: 'hell_succubus', + name: 'Succube de l\'enfer', + hp: 520, + maxHp: 520, + atk: 72, + def: 32, + spd: 15, + crit: 15, + xp: 255, + ability: 'charm', + gold: { min: 35, max: 50 }, + loot: ['pendant_soul', 'boots_infernal', 'potion_heal'], + }, + + hell_warlord: { + id: 'hell_warlord', + name: 'Chef de guerre de l\'enfer', + hp: 650, + maxHp: 650, + atk: 80, + def: 45, + spd: 10, + crit: 8, + xp: 275, + ability: 'hell_cry', + gold: { min: 42, max: 58 }, + loot: ['sword_hell', 'infernal_plate', 'helm_infernal', 'potion_heal'], + }, + + hell_lord: { + id: 'hell_lord', + name: 'Seigneur de l\'enfer', + hp: 700, + maxHp: 700, + atk: 85, + def: 50, + spd: 12, + crit: 15, + xp: 300, + ability: 'inferno', + gold: { min: 48, max: 60 }, + loot: ['sword_hell', 'staff_infernal', 'axe_infernal', 'infernal_plate', 'pendant_soul'], + }, +}; diff --git a/worldboss-app/src/data/enemies/index.js b/worldboss-app/src/data/enemies/index.js new file mode 100644 index 0000000..efb953b --- /dev/null +++ b/worldboss-app/src/data/enemies/index.js @@ -0,0 +1,23 @@ +'use strict'; + +const arc1 = require('./arc1_catacombes'); +const arc2 = require('./arc2_chateau'); +const arc3 = require('./arc3_bandits'); +const arc4 = require('./arc4_desert_assassins'); +const arc5 = require('./arc5_desert_monstres'); +const arc6 = require('./arc6_desert_steampunk'); +const arc7 = require('./arc7_desert_palais'); +const arc8 = require('./arc8_enfers'); + +const ENEMIES = { + ...arc1, + ...arc2, + ...arc3, + ...arc4, + ...arc5, + ...arc6, + ...arc7, + ...arc8, +}; + +module.exports = { ENEMIES }; diff --git a/worldboss-app/src/data/items/accessories.js b/worldboss-app/src/data/items/accessories.js new file mode 100644 index 0000000..2b50c01 --- /dev/null +++ b/worldboss-app/src/data/items/accessories.js @@ -0,0 +1,113 @@ +'use strict'; + +module.exports = { + ring_wood: { + id: 'ring_wood', + name: 'Anneau de bois', + type: 'accessory', + rarity: 'common', + stats: { hp: 5 }, + skill: null, + passive: null, + price: 15, + levelRequired: 1, + }, + ring_power: { + id: 'ring_power', + name: 'Anneau de puissance', + type: 'accessory', + rarity: 'rare', + stats: { atk: 2 }, + skill: null, + passive: null, + price: 90, + levelRequired: 2, + }, + ring_crit: { + id: 'ring_crit', + name: 'Anneau de critique', + type: 'accessory', + rarity: 'rare', + stats: { crit: 10 }, + skill: null, + passive: null, + price: 130, + levelRequired: 3, + }, + talisman_old: { + id: 'talisman_old', + name: 'Talisman ancien', + type: 'accessory', + rarity: 'epic', + stats: {}, + skill: 'soin', + passive: null, + price: 400, + levelRequired: 5, + }, + + // ── Arc 1-2 ────────────────────────────────────────────────────────────── + amulet_bone: { + id: 'amulet_bone', + name: 'Amulette d\'os', + type: 'accessory', + rarity: 'rare', + stats: { hp: 15, def: 3 }, + skill: null, + passive: null, + price: 100, + levelRequired: 2, + }, + + // ── Arc 3-5 ────────────────────────────────────────────────────────────── + ring_shadow: { + id: 'ring_shadow', + name: 'Anneau de l\'ombre', + type: 'accessory', + rarity: 'epic', + stats: { crit: 15, atk: 3 }, + skill: 'shadow_burst', + passive: null, + price: 450, + levelRequired: 5, + }, + + // ── Arc 4-5 ────────────────────────────────────────────────────────────── + scarab_beetle: { + id: 'scarab_beetle', + name: 'Scarabée sacré', + type: 'accessory', + rarity: 'rare', + stats: { hp: 20, spd: 3 }, + skill: null, + passive: null, + price: 280, + levelRequired: 8, + }, + + // ── Arc 7 ───────────────────────────────────────────────────────────────── + ankh_pharaoh: { + id: 'ankh_pharaoh', + name: 'Ânkh du Pharaon', + type: 'accessory', + rarity: 'epic', + stats: { hp: 40 }, + skill: 'divine_heal', + passive: null, + price: 1100, + levelRequired: 17, + }, + + // ── Arc 8 ───────────────────────────────────────────────────────────────── + pendant_soul: { + id: 'pendant_soul', + name: 'Pendentif de l\'âme', + type: 'accessory', + rarity: 'legendary', + stats: { atk: 10, crit: 20 }, + skill: 'soul_drain', + passive: 'life_steal', + price: 2200, + levelRequired: 21, + }, +}; diff --git a/worldboss-app/src/data/items/armors.js b/worldboss-app/src/data/items/armors.js new file mode 100644 index 0000000..d5795ef --- /dev/null +++ b/worldboss-app/src/data/items/armors.js @@ -0,0 +1,151 @@ +'use strict'; + +module.exports = { + cloth_simple: { + id: 'cloth_simple', + name: 'Vêtements simples', + type: 'armor', + rarity: 'common', + stats: { hp: 5, def: 1 }, + skill: null, + passive: null, + price: 0, + levelRequired: 1, + }, + leather_armor: { + id: 'leather_armor', + name: 'Armure de cuir', + type: 'armor', + rarity: 'common', + stats: { hp: 15, def: 3 }, + skill: null, + passive: null, + price: 80, + levelRequired: 2, + }, + iron_armor: { + id: 'iron_armor', + name: 'Armure de fer', + type: 'armor', + rarity: 'rare', + stats: { hp: 25, def: 8, spd: -1 }, + skill: null, + passive: null, + price: 200, + levelRequired: 4, + }, + magic_robe: { + id: 'magic_robe', + name: 'Robe magique', + type: 'armor', + rarity: 'rare', + stats: { hp: 10, atk: 3 }, + skill: null, + passive: null, + price: 220, + levelRequired: 5, + }, + + // ── Arc 1-2 ────────────────────────────────────────────────────────────── + robe_cloth: { + id: 'robe_cloth', + name: 'Robe de tissu', + type: 'armor', + rarity: 'common', + stats: { hp: 8, def: 2 }, + skill: null, + passive: null, + price: 50, + levelRequired: 1, + }, + + // ── Arc 3-5 ────────────────────────────────────────────────────────────── + scale_armor: { + id: 'scale_armor', + name: 'Armure d\'écailles', + type: 'armor', + rarity: 'rare', + stats: { hp: 35, def: 12, spd: -2 }, + skill: null, + passive: null, + price: 280, + levelRequired: 5, + }, + + studded_leather: { + id: 'studded_leather', + name: 'Cuir clouté', + type: 'armor', + rarity: 'rare', + stats: { hp: 22, def: 8, crit: 5 }, + skill: null, + passive: null, + price: 240, + levelRequired: 6, + }, + + // ── Arc 4-5 ────────────────────────────────────────────────────────────── + desert_cloth: { + id: 'desert_cloth', + name: 'Vêtement du désert', + type: 'armor', + rarity: 'common', + stats: { hp: 20, def: 5, spd: 2 }, + skill: null, + passive: null, + price: 150, + levelRequired: 8, + }, + + // ── Arc 5-6 ────────────────────────────────────────────────────────────── + desert_robe: { + id: 'desert_robe', + name: 'Robe du désert', + type: 'armor', + rarity: 'rare', + stats: { hp: 30, atk: 5 }, + skill: null, + passive: null, + price: 350, + levelRequired: 10, + }, + + // ── Arc 6 ───────────────────────────────────────────────────────────────── + steam_armor: { + id: 'steam_armor', + name: 'Armure à vapeur', + type: 'armor', + rarity: 'epic', + stats: { hp: 60, def: 20, spd: -3 }, + skill: null, + passive: null, + price: 700, + levelRequired: 14, + }, + + // ── Arc 7 ───────────────────────────────────────────────────────────────── + royal_armor: { + id: 'royal_armor', + name: 'Armure royale', + type: 'armor', + rarity: 'epic', + stats: { hp: 55, def: 18, atk: 5 }, + skill: null, + passive: null, + price: 900, + levelRequired: 17, + }, + + // ── Arc 8 ───────────────────────────────────────────────────────────────── + infernal_plate: { + id: 'infernal_plate', + name: 'Plastron infernal', + type: 'armor', + rarity: 'legendary', + stats: { hp: 100, def: 30, spd: -4 }, + skill: null, + passive: null, + price: 2000, + levelRequired: 21, + }, +}; diff --git a/worldboss-app/src/data/items/boots.js b/worldboss-app/src/data/items/boots.js new file mode 100644 index 0000000..eacd684 --- /dev/null +++ b/worldboss-app/src/data/items/boots.js @@ -0,0 +1,78 @@ +'use strict'; + +module.exports = { + boots_simple: { + id: 'boots_simple', + name: 'Bottes simples', + type: 'boots', + rarity: 'common', + stats: { spd: 1 }, + skill: null, + passive: null, + price: 20, + levelRequired: 1, + }, + boots_light: { + id: 'boots_light', + name: 'Bottes légères', + type: 'boots', + rarity: 'rare', + stats: { spd: 3 }, + skill: null, + passive: null, + price: 100, + levelRequired: 3, + }, + + // ── Arc 3-5 ────────────────────────────────────────────────────────────── + boots_shadow: { + id: 'boots_shadow', + name: 'Bottes de l\'ombre', + type: 'boots', + rarity: 'rare', + stats: { spd: 5, crit: 8 }, + skill: null, + passive: null, + price: 180, + levelRequired: 5, + }, + + // ── Arc 4-5 ────────────────────────────────────────────────────────────── + sandals_desert: { + id: 'sandals_desert', + name: 'Sandales du désert', + type: 'boots', + rarity: 'rare', + stats: { spd: 6 }, + skill: null, + passive: null, + price: 200, + levelRequired: 8, + }, + + // ── Arc 6 ───────────────────────────────────────────────────────────────── + boots_steam: { + id: 'boots_steam', + name: 'Bottes à vapeur', + type: 'boots', + rarity: 'epic', + stats: { spd: 8, def: 5 }, + skill: null, + passive: null, + price: 600, + levelRequired: 14, + }, + + // ── Arc 8 ───────────────────────────────────────────────────────────────── + boots_infernal: { + id: 'boots_infernal', + name: 'Bottes infernales', + type: 'boots', + rarity: 'legendary', + stats: { spd: 10, atk: 4 }, + skill: null, + passive: null, + price: 1800, + levelRequired: 21, + }, +}; diff --git a/worldboss-app/src/data/items/consumables.js b/worldboss-app/src/data/items/consumables.js new file mode 100644 index 0000000..6747bb8 --- /dev/null +++ b/worldboss-app/src/data/items/consumables.js @@ -0,0 +1,148 @@ +'use strict'; + +// usableOutOfCombat : peut être utilisé depuis l'inventaire hors combat +// infiniteUse : non consommé quand utilisé en combat (usage illimité) +module.exports = { + potion_heal: { + id: 'potion_heal', + name: 'Potion de soin', + type: 'consumable', + rarity: 'common', + effect: { type: 'heal', value: 30 }, + usableOutOfCombat: true, + infiniteUse: false, + price: 25, + levelRequired: 1, + }, + elixir_ap: { + id: 'elixir_ap', + name: 'Élixir de volonté', + type: 'consumable', + rarity: 'rare', + effect: { type: 'restore_ap', value: 3 }, + usableOutOfCombat: true, + infiniteUse: false, + price: 120, + levelRequired: 1, + }, + bomb: { + id: 'bomb', + name: 'Bombe', + type: 'consumable', + rarity: 'common', + effect: { type: 'damage', value: 25, aoe: true }, + usableOutOfCombat: false, + infiniteUse: false, + price: 40, + levelRequired: 1, + }, + scroll_ice: { + id: 'scroll_ice', + name: 'Parchemin de glace', + type: 'consumable', + rarity: 'rare', + effect: { type: 'stun', turns: 2 }, + usableOutOfCombat: false, + infiniteUse: false, + price: 80, + levelRequired: 3, + }, + potion_speed: { + id: 'potion_speed', + name: 'Potion de vitesse', + type: 'consumable', + rarity: 'rare', + effect: { type: 'buff', stat: 'spd', value: 5, turns: 3 }, + usableOutOfCombat: false, + infiniteUse: false, + price: 60, + levelRequired: 2, + }, + poison_vial: { + id: 'poison_vial', + name: 'Fiole de poison', + type: 'consumable', + rarity: 'rare', + effect: { type: 'dot', value: 10, turns: 3 }, + usableOutOfCombat: false, + infiniteUse: false, + price: 70, + levelRequired: 3, + }, + + // ── Arc 1-2 ────────────────────────────────────────────────────────────── + potion_mana: { + id: 'potion_mana', + name: 'Potion de mana', + type: 'consumable', + rarity: 'common', + effect: { type: 'buff', stat: 'atk', value: 5, turns: 2 }, + usableOutOfCombat: false, + infiniteUse: false, + price: 35, + levelRequired: 1, + }, + + // ── Arc 3-4 ────────────────────────────────────────────────────────────── + antidote: { + id: 'antidote', + name: 'Antidote', + type: 'consumable', + rarity: 'common', + effect: { type: 'cure_dot' }, + usableOutOfCombat: true, + infiniteUse: false, + price: 30, + levelRequired: 1, + }, + + smoke_bomb: { + id: 'smoke_bomb', + name: 'Bombe fumigène', + type: 'consumable', + rarity: 'rare', + effect: { type: 'stun', turns: 1, aoe: true }, + usableOutOfCombat: false, + infiniteUse: false, + price: 90, + levelRequired: 7, + }, + + // ── Arc 5-6 ────────────────────────────────────────────────────────────── + elixir_berserk: { + id: 'elixir_berserk', + name: 'Élixir berserker', + type: 'consumable', + rarity: 'rare', + effect: { type: 'buff', stat: 'atk', value: 15, turns: 2 }, + usableOutOfCombat: false, + infiniteUse: false, + price: 100, + levelRequired: 8, + }, + + elixir_iron: { + id: 'elixir_iron', + name: 'Élixir de fer', + type: 'consumable', + rarity: 'rare', + effect: { type: 'buff', stat: 'def', value: 15, turns: 3 }, + usableOutOfCombat: false, + infiniteUse: false, + price: 90, + levelRequired: 6, + }, + + // ── Arc 6 ───────────────────────────────────────────────────────────────── + scroll_fire: { + id: 'scroll_fire', + name: 'Parchemin de feu', + type: 'consumable', + rarity: 'rare', + effect: { type: 'damage', value: 40 }, + usableOutOfCombat: false, + infiniteUse: false, + price: 75, + levelRequired: 5, + }, +}; diff --git a/worldboss-app/src/data/items/helmets.js b/worldboss-app/src/data/items/helmets.js new file mode 100644 index 0000000..c32ec94 --- /dev/null +++ b/worldboss-app/src/data/items/helmets.js @@ -0,0 +1,104 @@ +'use strict'; + +module.exports = { + helmet_broken: { + id: 'helmet_broken', + name: 'Casque cassé', + type: 'helmet', + rarity: 'common', + stats: { def: 2 }, + skill: null, + passive: null, + price: 30, + levelRequired: 1, + }, + helmet_soldier: { + id: 'helmet_soldier', + name: 'Casque de soldat', + type: 'helmet', + rarity: 'rare', + stats: { def: 5 }, + skill: null, + passive: null, + price: 120, + levelRequired: 3, + }, + + // ── Arc 1-2 ────────────────────────────────────────────────────────────── + circlet_bone: { + id: 'circlet_bone', + name: 'Diadème d\'os', + type: 'helmet', + rarity: 'common', + stats: { def: 3, atk: 2 }, + skill: null, + passive: null, + price: 45, + levelRequired: 2, + }, + + // ── Arc 3-5 ────────────────────────────────────────────────────────────── + hood_shadow: { + id: 'hood_shadow', + name: 'Capuche de l\'ombre', + type: 'helmet', + rarity: 'rare', + stats: { crit: 15, def: 2 }, + skill: null, + passive: null, + price: 200, + levelRequired: 5, + }, + + // ── Arc 4-5 ────────────────────────────────────────────────────────────── + turban_desert: { + id: 'turban_desert', + name: 'Turban du désert', + type: 'helmet', + rarity: 'rare', + stats: { def: 6, spd: 2 }, + skill: null, + passive: null, + price: 220, + levelRequired: 8, + }, + + // ── Arc 6 ───────────────────────────────────────────────────────────────── + goggles_steam: { + id: 'goggles_steam', + name: 'Lunettes à vapeur', + type: 'helmet', + rarity: 'epic', + stats: { crit: 20, def: 8 }, + skill: null, + passive: null, + price: 650, + levelRequired: 14, + }, + + // ── Arc 7 ───────────────────────────────────────────────────────────────── + crown_pharaoh: { + id: 'crown_pharaoh', + name: 'Couronne du Pharaon', + type: 'helmet', + rarity: 'legendary', + stats: { atk: 8, def: 10, crit: 10 }, + skill: null, + passive: null, + price: 1500, + levelRequired: 17, + }, + + // ── Arc 8 ───────────────────────────────────────────────────────────────── + helm_infernal: { + id: 'helm_infernal', + name: 'Heaume infernal', + type: 'helmet', + rarity: 'legendary', + stats: { def: 18, atk: 6 }, + skill: null, + passive: null, + price: 1800, + levelRequired: 21, + }, +}; diff --git a/worldboss-app/src/data/items/index.js b/worldboss-app/src/data/items/index.js new file mode 100644 index 0000000..4812609 --- /dev/null +++ b/worldboss-app/src/data/items/index.js @@ -0,0 +1,19 @@ +'use strict'; + +const weapons = require('./weapons'); +const armors = require('./armors'); +const helmets = require('./helmets'); +const boots = require('./boots'); +const accessories = require('./accessories'); +const consumables = require('./consumables'); + +const ITEMS = { + ...weapons, + ...armors, + ...helmets, + ...boots, + ...accessories, + ...consumables, +}; + +module.exports = { ITEMS }; diff --git a/worldboss-app/src/data/items/weapons.js b/worldboss-app/src/data/items/weapons.js new file mode 100644 index 0000000..efff1a1 --- /dev/null +++ b/worldboss-app/src/data/items/weapons.js @@ -0,0 +1,268 @@ +'use strict'; + +module.exports = { + fists: { + id: 'fists', + name: 'Poings', + type: 'weapon', + rarity: 'common', + stats: { atk: 3 }, + skill: null, + passive: null, + price: 0, + levelRequired: 1, + }, + sword_rusty: { + id: 'sword_rusty', + name: 'Épée rouillée', + type: 'weapon', + rarity: 'common', + stats: { atk: 5 }, + skill: null, + passive: null, + price: 100, + levelRequired: 2, + }, + sword_steel: { + id: 'sword_steel', + name: "Lame d'acier", + type: 'weapon', + rarity: 'rare', + stats: { atk: 8, crit: 5 }, + skill: null, + passive: null, + price: 150, + levelRequired: 3, + }, + book_fire: { + id: 'book_fire', + name: 'Livre de feu', + type: 'weapon', + rarity: 'rare', + stats: { atk: 5 }, + skill: 'firebolt', + passive: 'fire_dot', + price: 200, + levelRequired: 4, + }, + bow_wooden: { + id: 'bow_wooden', + name: 'Arc en bois', + type: 'weapon', + rarity: 'common', + stats: { atk: 4, crit: 10 }, + skill: null, + passive: null, + price: 60, + levelRequired: 1, + }, + axe_heavy: { + id: 'axe_heavy', + name: 'Hache lourde', + type: 'weapon', + rarity: 'rare', + stats: { atk: 12, spd: -2 }, + skill: null, + passive: null, + price: 180, + levelRequired: 5, + }, + bow_hunter: { + id: 'bow_hunter', + name: 'Arc du chasseur', + type: 'weapon', + rarity: 'epic', + stats: { atk: 6, crit: 15 }, + skill: null, + passive: null, + price: 350, + levelRequired: 6, + }, + + // ── Arc 1-2 ────────────────────────────────────────────────────────────── + staff_bone: { + id: 'staff_bone', + name: 'Bâton d\'os', + type: 'weapon', + rarity: 'common', + stats: { atk: 6 }, + skill: 'bone_bolt', + passive: null, + price: 80, + levelRequired: 2, + }, + + spear_iron: { + id: 'spear_iron', + name: 'Lance de fer', + type: 'weapon', + rarity: 'rare', + stats: { atk: 10, def: 2 }, + skill: null, + passive: null, + price: 160, + levelRequired: 3, + }, + + // ── Arc 3-4 ────────────────────────────────────────────────────────────── + dagger_shadow: { + id: 'dagger_shadow', + name: 'Dague de l\'ombre', + type: 'weapon', + rarity: 'rare', + stats: { atk: 7, crit: 15 }, + skill: null, + passive: 'poison_dot', + price: 190, + levelRequired: 4, + }, + + sword_ancient: { + id: 'sword_ancient', + name: 'Épée ancienne', + type: 'weapon', + rarity: 'epic', + stats: { atk: 12, crit: 8 }, + skill: null, + passive: 'bleed', + price: 300, + levelRequired: 6, + }, + + crossbow_steel: { + id: 'crossbow_steel', + name: 'Arbalète d\'acier', + type: 'weapon', + rarity: 'rare', + stats: { atk: 9, crit: 12 }, + skill: null, + passive: null, + price: 220, + levelRequired: 5, + }, + + // ── Arc 4-5 ────────────────────────────────────────────────────────────── + throwing_knife: { + id: 'throwing_knife', + name: 'Couteau de lancer', + type: 'weapon', + rarity: 'rare', + stats: { atk: 8, crit: 20 }, + skill: null, + passive: null, + price: 200, + levelRequired: 8, + }, + + scimitar_gold: { + id: 'scimitar_gold', + name: 'Cimeterre doré', + type: 'weapon', + rarity: 'epic', + stats: { atk: 11, spd: 3, crit: 10 }, + skill: null, + passive: null, + price: 380, + levelRequired: 8, + }, + + // ── Arc 5-6 ────────────────────────────────────────────────────────────── + whip_venom: { + id: 'whip_venom', + name: 'Fouet venimeux', + type: 'weapon', + rarity: 'epic', + stats: { atk: 9, crit: 12 }, + skill: null, + passive: 'poison_dot', + price: 360, + levelRequired: 10, + }, + + staff_desert: { + id: 'staff_desert', + name: 'Bâton du désert', + type: 'weapon', + rarity: 'epic', + stats: { atk: 10 }, + skill: 'sand_storm', + passive: null, + price: 400, + levelRequired: 10, + }, + + // ── Arc 6 ───────────────────────────────────────────────────────────────── + rifle_steam: { + id: 'rifle_steam', + name: 'Fusil à vapeur', + type: 'weapon', + rarity: 'epic', + stats: { atk: 16, crit: 15 }, + skill: null, + passive: null, + price: 600, + levelRequired: 14, + }, + + // ── Arc 7 ───────────────────────────────────────────────────────────────── + sword_pharaoh: { + id: 'sword_pharaoh', + name: 'Épée du Pharaon', + type: 'weapon', + rarity: 'legendary', + stats: { atk: 18, crit: 12 }, + skill: 'royal_smite', + passive: 'cursed_strike', + price: 1200, + levelRequired: 17, + }, + + staff_royal: { + id: 'staff_royal', + name: 'Bâton royal', + type: 'weapon', + rarity: 'epic', + stats: { atk: 14 }, + skill: 'thunder_bolt', + passive: 'fire_dot', + price: 800, + levelRequired: 16, + }, + + // ── Arc 8 ───────────────────────────────────────────────────────────────── + sword_hell: { + id: 'sword_hell', + name: 'Épée de l\'enfer', + type: 'weapon', + rarity: 'legendary', + stats: { atk: 25, crit: 15 }, + skill: 'hellstrike', + passive: 'fire_dot', + price: 2000, + levelRequired: 21, + }, + + staff_infernal: { + id: 'staff_infernal', + name: 'Bâton infernal', + type: 'weapon', + rarity: 'legendary', + stats: { atk: 20 }, + skill: 'inferno_blast', + passive: 'fire_dot', + price: 1800, + levelRequired: 20, + }, + + axe_infernal: { + id: 'axe_infernal', + name: 'Hache infernale', + type: 'weapon', + rarity: 'legendary', + stats: { atk: 28, spd: -3 }, + skill: null, + passive: 'bleed', + price: 1600, + levelRequired: 22, + }, +}; diff --git a/worldboss-app/src/data/passives/defensive.js b/worldboss-app/src/data/passives/defensive.js new file mode 100644 index 0000000..8b5a301 --- /dev/null +++ b/worldboss-app/src/data/passives/defensive.js @@ -0,0 +1,32 @@ +'use strict'; + +module.exports = { + regeneration: { + name: 'Régénération', + resolve(player, _target, logs) { + const healAmt = 3; + player.hp = Math.min(player.maxHp, player.hp + healAmt); + logs.push(`💚 **Régénération** : vous récupérez **${healAmt}** HP (HP : ${player.hp}/${player.maxHp}).`); + }, + }, + + life_steal: { + name: 'Vol de vie', + resolve(player, _target, logs) { + const healAmt = 5; + player.hp = Math.min(player.maxHp, player.hp + healAmt); + logs.push(`🩸 **Vol de vie** : vous volez **${healAmt}** HP (HP : ${player.hp}/${player.maxHp}).`); + }, + }, + + frost_shield: { + name: 'Bouclier de givre', + resolve(player, _target, logs) { + if (!(player.buffs ?? []).some((b) => b.id === 'frost_shield')) { + player.def += 3; + player.buffs = [...(player.buffs ?? []), { id: 'frost_shield', stat: 'def', value: 3, turns: 2 }]; + logs.push(`❄️ **Bouclier de givre** : votre DEF augmente de **3** pour 2 tours (DEF : ${player.def}).`); + } + }, + }, +}; diff --git a/worldboss-app/src/data/passives/index.js b/worldboss-app/src/data/passives/index.js new file mode 100644 index 0000000..1a21880 --- /dev/null +++ b/worldboss-app/src/data/passives/index.js @@ -0,0 +1,11 @@ +'use strict'; + +const offensive = require('./offensive'); +const defensive = require('./defensive'); + +const PASSIVES = { + ...offensive, + ...defensive, +}; + +module.exports = { PASSIVES }; diff --git a/worldboss-app/src/data/passives/offensive.js b/worldboss-app/src/data/passives/offensive.js new file mode 100644 index 0000000..e9c5f42 --- /dev/null +++ b/worldboss-app/src/data/passives/offensive.js @@ -0,0 +1,53 @@ +'use strict'; + +module.exports = { + fire_dot: { + name: 'Brûlure', + resolve(player, target, logs) { + const existing = (target.dots ?? []).findIndex((d) => d.id === 'fire_dot'); + const dot = { id: 'fire_dot', label: 'Brûlure', value: 2, turns: 2 }; + if (existing !== -1) { + target.dots[existing] = dot; + } else { + target.dots = [...(target.dots ?? []), dot]; + } + logs.push(`🔥 **Brûlure** : ${target.name} est enflammé pour **2 tours** (2 dégâts/tour).`); + }, + }, + + poison_dot: { + name: 'Poison', + resolve(player, target, logs) { + const existing = (target.dots ?? []).findIndex((d) => d.id === 'poison_dot'); + const dot = { id: 'poison_dot', label: 'Poison', value: 1, turns: 3 }; + if (existing !== -1) { + target.dots[existing] = dot; + } else { + target.dots = [...(target.dots ?? []), dot]; + } + logs.push(`🐍 **Poison** : ${target.name} est empoisonné pour **3 tours** (1 dégât/tour).`); + }, + }, + + bleed: { + name: 'Saignement', + resolve(player, target, logs) { + const existing = (target.dots ?? []).findIndex((d) => d.id === 'bleed'); + const dot = { id: 'bleed', label: 'Saignement', value: 3, turns: 3 }; + if (existing !== -1) { + target.dots[existing] = dot; + } else { + target.dots = [...(target.dots ?? []), dot]; + } + logs.push(`🩸 **Saignement** : ${target.name} saigne pour **3 tours** (3 dégâts/tour).`); + }, + }, + + cursed_strike: { + name: 'Frappe maudite', + resolve(player, target, logs) { + target.def = Math.max(0, target.def - 2); + logs.push(`🔮 **Frappe maudite** : la DEF de ${target.name} est réduite de 2 (DEF : ${target.def}).`); + }, + }, +}; diff --git a/worldboss-app/src/data/skills/defensive.js b/worldboss-app/src/data/skills/defensive.js new file mode 100644 index 0000000..017f6aa --- /dev/null +++ b/worldboss-app/src/data/skills/defensive.js @@ -0,0 +1,60 @@ +'use strict'; + +module.exports = { + soin: { + name: 'Soin', + oncePerCombat: true, + cooldown: 0, + resolve(player, _target, logs) { + const healAmt = 20; + player.hp = Math.min(player.maxHp, player.hp + healAmt); + logs.push(`✨ **Soin** : vous récupérez **${healAmt}** HP (HP : ${player.hp}/${player.maxHp}).`); + }, + }, + + divine_heal: { + name: 'Soin divin', + oncePerCombat: true, + cooldown: 0, + resolve(player, _target, logs) { + const healAmt = 50; + player.hp = Math.min(player.maxHp, player.hp + healAmt); + logs.push(`💚 **Soin divin** : vous récupérez **${healAmt}** HP (HP : ${player.hp}/${player.maxHp}).`); + }, + }, + + second_wind: { + name: 'Second souffle', + oncePerCombat: false, + cooldown: 5, + resolve(player, _target, logs) { + const healAmt = Math.floor(player.maxHp * 0.25); + player.hp = Math.min(player.maxHp, player.hp + healAmt); + logs.push(`💨 **Second souffle** : vous récupérez **${healAmt}** HP (HP : ${player.hp}/${player.maxHp}).`); + }, + }, + + iron_skin: { + name: 'Peau de fer', + oncePerCombat: false, + cooldown: 4, + resolve(player, _target, logs) { + const buffVal = 15; + player.def += buffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'def', value: buffVal, turns: 3 }]; + logs.push(`🛡️ **Peau de fer** : votre DEF augmente de **${buffVal}** pour 3 tours (DEF : ${player.def}).`); + }, + }, + + barrier: { + name: 'Barrière', + oncePerCombat: false, + cooldown: 5, + resolve(player, _target, logs) { + const buffVal = 30; + player.def += buffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'def', value: buffVal, turns: 2 }]; + logs.push(`🔰 **Barrière** : votre DEF augmente de **${buffVal}** pour 2 tours (DEF : ${player.def}).`); + }, + }, +}; diff --git a/worldboss-app/src/data/skills/index.js b/worldboss-app/src/data/skills/index.js new file mode 100644 index 0000000..621e0df --- /dev/null +++ b/worldboss-app/src/data/skills/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const offensive = require('./offensive'); +const defensive = require('./defensive'); +const support = require('./support'); + +const SKILLS = { + ...offensive, + ...defensive, + ...support, +}; + +module.exports = { SKILLS }; diff --git a/worldboss-app/src/data/skills/offensive.js b/worldboss-app/src/data/skills/offensive.js new file mode 100644 index 0000000..dfc217b --- /dev/null +++ b/worldboss-app/src/data/skills/offensive.js @@ -0,0 +1,208 @@ +'use strict'; + +module.exports = { + firebolt: { + name: 'Firebolt', + oncePerCombat: false, + cooldown: 2, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 1.8); + target.hp -= result.damage; + logs.push(`🔥 **Firebolt** : ${result.log}`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + bone_bolt: { + name: 'Trait osseux', + oncePerCombat: false, + cooldown: 2, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 1.5); + target.hp -= result.damage; + const dot = { id: 'bone_chill', label: 'Froid Osseux', value: 3, turns: 2 }; + const existing = (target.dots ?? []).findIndex((d) => d.id === 'bone_chill'); + if (existing !== -1) { + target.dots[existing] = dot; + } else { + target.dots = [...(target.dots ?? []), dot]; + } + logs.push(`🦴 **Trait osseux** : ${result.log} + Froid Osseux (3 dégâts/tour, 2 tours).`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + shadow_burst: { + name: 'Explosion d\'ombre', + oncePerCombat: false, + cooldown: 3, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 2.2); + target.hp -= result.damage; + logs.push(`🌑 **Explosion d'ombre** : ${result.log}`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + ice_lance: { + name: 'Lance de glace', + oncePerCombat: false, + cooldown: 3, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 1.6); + target.hp -= result.damage; + target.stunned = true; + logs.push(`❄️ **Lance de glace** : ${result.log} + **${target.name}** est étourdi !`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + thunder_bolt: { + name: 'Éclair', + oncePerCombat: false, + cooldown: 3, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 1.7); + target.hp -= result.damage; + logs.push(`⚡ **Éclair** : ${result.log}`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + soul_rend: { + name: 'Déchirement d\'âme', + oncePerCombat: false, + cooldown: 2, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 1.5); + target.hp -= result.damage; + const dot = { id: 'bleed', label: 'Saignement', value: 5, turns: 3 }; + const existing = (target.dots ?? []).findIndex((d) => d.id === 'bleed'); + if (existing !== -1) { + target.dots[existing] = dot; + } else { + target.dots = [...(target.dots ?? []), dot]; + } + logs.push(`💔 **Déchirement d'âme** : ${result.log} + Saignement (5 dégâts/tour, 3 tours).`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + power_slash: { + name: 'Taille puissante', + oncePerCombat: false, + cooldown: 3, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 2.0); + target.hp -= result.damage; + logs.push(`⚔️ **Taille puissante** : ${result.log}`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + royal_smite: { + name: 'Frappe royale', + oncePerCombat: false, + cooldown: 4, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 2.2); + target.hp -= result.damage; + logs.push(`👑 **Frappe royale** : ${result.log}`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + hellstrike: { + name: 'Frappe infernale', + oncePerCombat: false, + cooldown: 4, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 2.5); + target.hp -= result.damage; + const dot = { id: 'fire_dot', label: 'Brûlure', value: 5, turns: 3 }; + const existing = (target.dots ?? []).findIndex((d) => d.id === 'fire_dot'); + if (existing !== -1) { + target.dots[existing] = dot; + } else { + target.dots = [...(target.dots ?? []), dot]; + } + logs.push(`😈 **Frappe infernale** : ${result.log} + Brûlure (5 dégâts/tour, 3 tours).`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + inferno_blast: { + name: 'Souffle infernal', + oncePerCombat: false, + cooldown: 5, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 2.0); + target.hp -= result.damage; + logs.push(`🌋 **Souffle infernal** : ${result.log}`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + soul_drain: { + name: 'Drain d\'âme', + oncePerCombat: false, + cooldown: 3, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 1.8); + target.hp -= result.damage; + const healAmt = Math.floor(result.damage * 0.3); + player.hp = Math.min(player.maxHp, player.hp + healAmt); + logs.push(`🩸 **Drain d'âme** : ${result.log} + vous récupérez **${healAmt}** HP.`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, + + sand_storm: { + name: 'Tempête de sable', + oncePerCombat: false, + cooldown: 3, + resolve(player, target, logs, { playerAttack }) { + const result = playerAttack(player, target, 1.4); + target.hp -= result.damage; + target.def = Math.max(0, target.def - 5); + logs.push(`🌪️ **Tempête de sable** : ${result.log} + DEF de **${target.name}** réduite de 5 (DEF : ${target.def}).`); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + }, + }, +}; diff --git a/worldboss-app/src/data/skills/support.js b/worldboss-app/src/data/skills/support.js new file mode 100644 index 0000000..39af1a8 --- /dev/null +++ b/worldboss-app/src/data/skills/support.js @@ -0,0 +1,44 @@ +'use strict'; + +module.exports = { + battle_cry: { + name: 'Cri de bataille', + oncePerCombat: false, + cooldown: 4, + resolve(player, _target, logs) { + const buffVal = 10; + player.atk += buffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'atk', value: buffVal, turns: 3 }]; + logs.push(`⚔️ **Cri de bataille** : votre ATK augmente de **${buffVal}** pour 3 tours (ATK : ${player.atk}).`); + }, + }, + + quicken: { + name: 'Accélération', + oncePerCombat: false, + cooldown: 3, + resolve(player, _target, logs) { + const buffVal = 8; + player.spd += buffVal; + player.buffs = [...(player.buffs ?? []), { stat: 'spd', value: buffVal, turns: 3 }]; + logs.push(`⚡ **Accélération** : votre SPD augmente de **${buffVal}** pour 3 tours (SPD : ${player.spd}).`); + }, + }, + + resurrection: { + name: 'Résurrection', + oncePerCombat: true, + cooldown: 0, + resolve(player, _target, logs) { + if (player.hp <= 0) { + const healAmt = Math.floor(player.maxHp * 0.3); + player.hp = healAmt; + logs.push(`✝️ **Résurrection** : vous revenez à la vie avec **${healAmt}** HP !`); + } else { + const healAmt = Math.floor(player.maxHp * 0.3); + player.hp = Math.min(player.maxHp, player.hp + healAmt); + logs.push(`✝️ **Résurrection** : vous récupérez **${healAmt}** HP (HP : ${player.hp}/${player.maxHp}).`); + } + }, + }, +}; diff --git a/worldboss-app/src/db/prisma.js b/worldboss-app/src/db/prisma.js new file mode 100644 index 0000000..71b707b --- /dev/null +++ b/worldboss-app/src/db/prisma.js @@ -0,0 +1,9 @@ +'use strict'; + +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], +}); + +module.exports = { prisma }; diff --git a/worldboss-app/src/deploy-commands.js b/worldboss-app/src/deploy-commands.js new file mode 100644 index 0000000..b840bd2 --- /dev/null +++ b/worldboss-app/src/deploy-commands.js @@ -0,0 +1,45 @@ +'use strict'; + +require('dotenv').config(); + +const path = require('path'); +const fs = require('fs'); +const { REST, Routes } = require('discord.js'); + +const commands = []; + +const commandDirs = [ + path.join(__dirname, 'commands', 'player'), + path.join(__dirname, 'commands', 'inventory'), + path.join(__dirname, 'commands', 'dungeon'), +]; + +for (const dir of commandDirs) { + if (!fs.existsSync(dir)) continue; + const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js')); + for (const file of files) { + const command = require(path.join(dir, file)); + if (command.data) { + commands.push(command.data.toJSON()); + console.log(`[Deploy] Préparé: ${command.data.name}`); + } + } +} + +const rest = new REST().setToken(process.env.DISCORD_TOKEN); + +(async () => { + try { + console.log(`[Deploy] Publication de ${commands.length} commande(s)...`); + + const data = await rest.put( + Routes.applicationCommands(process.env.DISCORD_CLIENT_ID), + { body: commands }, + ); + + console.log(`[Deploy] ${data.length} commande(s) publiée(s) avec succès.`); + } catch (error) { + console.error('[Deploy] Erreur:', error); + process.exit(1); + } +})(); diff --git a/worldboss-app/src/engines/combatEngine.js b/worldboss-app/src/engines/combatEngine.js new file mode 100644 index 0000000..caffbf7 --- /dev/null +++ b/worldboss-app/src/engines/combatEngine.js @@ -0,0 +1,272 @@ +'use strict'; + +const { ABILITIES } = require('../data/abilities'); +const { PASSIVES } = require('../data/passives'); + +function rollInitiative(spd) { + return spd + Math.random() * spd * 0.1; +} + +function rawDamage(atk, def, skillMult = 1.0) { + return atk * skillMult * (100 / (100 + def)); +} + +function applyCrit(raw, critChance, critMult = 1.5) { + const roll = Math.random() * 100; + const isCrit = roll < critChance; + return { damage: isCrit ? raw * critMult : raw, isCrit }; +} + +function playerAttack(player, enemy, skillMult = 1.0) { + const raw = rawDamage(player.atk, enemy.def, skillMult); + const { damage: afterCrit, isCrit } = applyCrit(raw, player.crit ?? 0, player.critMult ?? 1.5); + const finalDmg = Math.max(1, Math.round(afterCrit)); + const critText = isCrit ? ' 💥 **CRITIQUE!**' : ''; + return { damage: finalDmg, isCrit, log: `Vous infligez **${finalDmg}** dégâts à ${enemy.name}${critText}.` }; +} + +function resolveEnemyTurn(enemy, player, logs, allies = []) { + if (enemy.hp <= 0) return; + + // Enemy DoTs (e.g. fire_dot from player passive) + if (enemy.dots && enemy.dots.length > 0) { + enemy.dots = enemy.dots.map((dot) => { + enemy.hp -= dot.value; + logs.push(`🔥 **${dot.label ?? 'DoT'}** : ${enemy.name} perd **${dot.value}** HP.`); + return { ...dot, turns: dot.turns - 1 }; + }).filter((d) => d.turns > 0); + if (enemy.hp <= 0) { enemy.hp = 0; return; } + } + + if (enemy.stunned) { + logs.push(`${enemy.name} est étourdi et passe son tour.`); + enemy.stunned = false; + return; + } + + // Pick a target: player or a living ally (equal probability per combatant) + const aliveAllies = allies.filter((a) => a.hp > 0); + const allTargets = [player, ...aliveAllies]; + const target = allTargets[Math.floor(Math.random() * allTargets.length)]; + const isAlly = target !== player; + + // Random AI: 0 = attack, 1 = ability (or attack if none), 2 = rest + const choice = Math.floor(Math.random() * 3); + + if (choice === 2) { + const heal = Math.max(1, Math.floor(enemy.maxHp * 0.15)); + enemy.hp = Math.min(enemy.maxHp, enemy.hp + heal); + logs.push(`💤 **${enemy.name}** se repose et récupère **${heal}** HP.`); + } else if (choice === 1 && enemy.ability && !isAlly) { + // Abilities always target the player (complex effects) + const ability = ABILITIES[enemy.ability]; + if (ability) { + ability.resolve(enemy, player, logs); + } else { + const raw = rawDamage(enemy.atk, player.def); + const dmg = Math.max(1, Math.round(raw)); + player.hp -= dmg; + logs.push(`${enemy.name} vous inflige **${dmg}** dégâts.`); + } + } else { + const raw = rawDamage(enemy.atk, target.def); + const dmg = Math.max(1, Math.round(raw)); + target.hp = Math.max(0, target.hp - dmg); + if (isAlly) { + logs.push(`${enemy.name} attaque **${target.name}** et inflige **${dmg}** dégâts${target.hp <= 0 ? ' — *il tombe !*' : ''}.`); + } else { + logs.push(`${enemy.name} vous inflige **${dmg}** dégâts.`); + } + } +} + +/** + * Resolve a full turn. + * @param {object} state - combat state + * @param {string} playerAction - 'attack' | 'skill_' | 'item_potion_heal' | 'flee' + * @param {number} targetIndex - index in state.enemies array + */ +function resolveAllyTurn(ally, enemies, logs) { + if (ally.hp <= 0) return; + + const aliveEnemies = enemies.filter((e) => e.hp > 0); + if (aliveEnemies.length === 0) return; + + // Simple AI: 1/3 rest, 2/3 attack + if (Math.random() < 0.33) { + const heal = Math.max(1, Math.floor(ally.maxHp * 0.15)); + ally.hp = Math.min(ally.maxHp, ally.hp + heal); + logs.push(`💤 **${ally.name}** reprend son souffle (+**${heal}** HP).`); + } else { + const target = aliveEnemies[Math.floor(Math.random() * aliveEnemies.length)]; + const raw = rawDamage(ally.atk, target.def); + const dmg = Math.max(1, Math.round(raw)); + target.hp = Math.max(0, target.hp - dmg); + logs.push(`🧑‍💼 **${ally.name}** attaque **${target.name}** pour **${dmg}** dégâts${target.hp <= 0 ? ' ☠️' : ''}.`); + } +} + +function resolveTurn(state, playerAction, targetIndex = 0) { + const player = { + ...state.player, + buffs: [...(state.player.buffs ?? [])], + dots: [...(state.player.dots ?? [])], + consumables: (state.player.consumables ?? []).map((c) => ({ ...c })), + activeSkills: (state.player.activeSkills ?? []).map((s) => ({ ...s })), + activePassives: [...(state.player.activePassives ?? [])], + skillCooldowns: { ...(state.player.skillCooldowns ?? {}) }, + usedOnceSkills: [...(state.player.usedOnceSkills ?? [])], + }; + const enemies = state.enemies.map((e) => ({ ...e, dots: [...(e.dots ?? [])] })); + const allies = (state.allies ?? []).map((a) => ({ ...a })); + const logs = []; + + // ── Flee ────────────────────────────────────────────────────────────────── + if (playerAction === 'flee') { + const aliveEnemy = enemies.find((e) => e.hp > 0); + const fleeChance = aliveEnemy ? 0.4 + (player.spd / (player.spd + aliveEnemy.spd)) * 0.3 : 1; + if (Math.random() < fleeChance) { + logs.push('Vous avez **fui** le combat !'); + return { playerState: player, enemiesState: enemies, logs, fled: true, playerDied: false, allEnemiesDead: false }; + } + logs.push('Fuite **échouée** !'); + } + + // ── Player DoTs ─────────────────────────────────────────────────────────── + if (player.dots.length > 0) { + player.dots = player.dots.map((dot) => { + player.hp -= dot.value; + logs.push(`☠️ Poison: vous perdez **${dot.value}** HP.`); + return { ...dot, turns: dot.turns - 1 }; + }).filter((d) => d.turns > 0); + } + + // ── Buffs decay ─────────────────────────────────────────────────────────── + if (player.buffs.length > 0) { + player.buffs = player.buffs.map((b) => ({ ...b, turns: b.turns - 1 })).filter((b) => { + if (b.turns <= 0) { player[b.stat] -= b.value; logs.push(`Buff **${b.stat}** expiré.`); return false; } + return true; + }); + } + + // ── Skill cooldowns decay ───────────────────────────────────────────────── + for (const key of Object.keys(player.skillCooldowns)) { + if (player.skillCooldowns[key] > 0) player.skillCooldowns[key] -= 1; + } + + // ── Initiative ──────────────────────────────────────────────────────────── + const aliveEnemies = enemies.filter((e) => e.hp > 0); + const fastestEnemy = aliveEnemies.reduce((a, b) => (b.spd > a.spd ? b : a), aliveEnemies[0] ?? { spd: 0 }); + const playerGoesFirst = rollInitiative(player.spd) >= rollInitiative(fastestEnemy.stunned ? 0 : fastestEnemy.spd); + + // ── Player action ───────────────────────────────────────────────────────── + function executePlayerAction() { + const target = enemies[targetIndex]?.hp > 0 + ? enemies[targetIndex] + : enemies.find((e) => e.hp > 0); + + if (!target) return; + + if (playerAction === 'attack') { + const result = playerAttack(player, target, 1.0); + target.hp -= result.damage; + logs.push(result.log); + if (target.hp <= 0) { + target.hp = 0; + logs.push(`☠️ **${target.name}** est vaincu !`); + } + // Trigger all weapon passives + for (const passiveKey of player.activePassives) { + const passive = PASSIVES[passiveKey]; + if (passive && target.hp > 0) passive.resolve(player, target, logs); + } + } else if (playerAction.startsWith('skill_')) { + const skillKey = playerAction.slice(6); // strip 'skill_' + const skill = (player.activeSkills ?? []).find((s) => s.key === skillKey); + if (!skill) { logs.push('Skill introuvable !'); return; } + + const cd = player.skillCooldowns[skillKey] ?? 0; + if (cd > 0) { + logs.push(`**${skill.name}** en recharge encore **${cd}** tour(s) !`); + return; + } + if (skill.oncePerCombat && player.usedOnceSkills.includes(skillKey)) { + logs.push(`**${skill.name}** déjà utilisé ce combat !`); + return; + } + + skill.resolve(player, target, logs, { playerAttack }); + + if (skill.oncePerCombat) { + player.usedOnceSkills.push(skillKey); + } else { + player.skillCooldowns[skillKey] = skill.cooldown ?? 0; + } + } else if (playerAction.startsWith('item_')) { + const { ITEMS } = require('../data/items'); + const itemId = playerAction.slice(5); + const itemDef = ITEMS[itemId]; + if (!itemDef) { logs.push('Item inconnu !'); return; } + + const idx = (player.consumables || []).findIndex((c) => c.itemId === itemId && (c.quantity > 0 || c.quantity === -1)); + if (idx === -1) { logs.push(`**${itemDef.name}** non disponible !`); return; } + + const effect = itemDef.effect; + if (effect?.type === 'heal') { + player.hp = Math.min(player.maxHp, player.hp + effect.value); + logs.push(`💊 **${itemDef.name}** : vous récupérez **${effect.value}** HP.`); + } else if (effect?.type === 'damage') { + const targets = effect.aoe ? enemies.filter((e) => e.hp > 0) : [target]; + for (const t of targets) { + t.hp = Math.max(0, t.hp - effect.value); + logs.push(`💣 **${itemDef.name}** inflige **${effect.value}** dégâts à **${t.name}**${t.hp <= 0 ? ' ☠️' : ''}.`); + } + } else if (effect?.type === 'stun') { + target.stunned = true; + logs.push(`❄️ **${itemDef.name}** : **${target.name}** est étourdi pour **${effect.turns}** tour(s) !`); + } else if (effect?.type === 'buff') { + player[effect.stat] = (player[effect.stat] ?? 0) + effect.value; + player.buffs = [...(player.buffs ?? []), { stat: effect.stat, value: effect.value, turns: effect.turns }]; + logs.push(`⚡ **${itemDef.name}** : **${effect.stat}** +${effect.value} pendant **${effect.turns}** tours.`); + } else if (effect?.type === 'dot') { + target.dots = [...(target.dots ?? []), { id: itemId, label: itemDef.name, value: effect.value, turns: effect.turns }]; + logs.push(`☠️ **${itemDef.name}** : **${target.name}** est empoisonné (**${effect.value}** dégâts/tour).`); + } else if (effect?.type === 'cure_dot') { + const count = (player.dots ?? []).length; + player.dots = []; + logs.push(count > 0 ? `💊 **${itemDef.name}** : tous vos effets négatifs sont dissipés.` : `💊 **${itemDef.name}** : aucun effet à dissiper.`); + } + + if (player.consumables[idx].quantity !== -1) player.consumables[idx].quantity -= 1; + } + } + + if (playerGoesFirst) { + executePlayerAction(); + const stillAlive = enemies.filter((e) => e.hp > 0); + for (const enemy of stillAlive) { + resolveEnemyTurn(enemy, player, logs, allies); + if (player.hp <= 0) break; + } + } else { + const stillAlive = enemies.filter((e) => e.hp > 0); + for (const enemy of stillAlive) { + resolveEnemyTurn(enemy, player, logs, allies); + if (player.hp <= 0) break; + } + if (player.hp > 0) executePlayerAction(); + } + + // ── Ally turns (after player + enemies) ───────────────────────────────── + for (const ally of allies) { + resolveAllyTurn(ally, enemies, logs); + } + + const allEnemiesDead = enemies.every((e) => e.hp <= 0); + const playerDied = player.hp <= 0; + if (playerDied) { player.hp = 0; logs.push('💀 Vous êtes **tombé** au combat.'); } + + return { playerState: player, enemiesState: enemies, alliesState: allies, logs, fled: false, playerDied, allEnemiesDead }; +} + +module.exports = { resolveTurn, playerAttack }; diff --git a/worldboss-app/src/engines/dungeonEngine.js b/worldboss-app/src/engines/dungeonEngine.js new file mode 100644 index 0000000..664f5be --- /dev/null +++ b/worldboss-app/src/engines/dungeonEngine.js @@ -0,0 +1,47 @@ +'use strict'; + +const { DUNGEONS } = require('../data/dungeons'); +const { ENEMIES } = require('../data/enemies'); + +function createDungeonState(characterId, guildId, chapter) { + const dungeon = DUNGEONS[chapter]; + if (!dungeon) throw new Error(`Unknown chapter: ${chapter}`); + + return { + characterId, + guildId, + chapter, + currentRoom: 1, + totalRooms: dungeon.rooms.length, + rooms: dungeon.rooms.map((r) => ({ ...r, completed: false })), + enemiesFought: [], + status: 'active', + }; +} + +function getCurrentRoom(dungeonState) { + return dungeonState.rooms.find((r) => r.room === dungeonState.currentRoom) ?? null; +} + +function getRoomEnemies(room) { + return room.enemies.map((id) => { + const template = ENEMIES[id]; + if (!template) throw new Error(`Unknown enemy: ${id}`); + return { ...template }; + }); +} + +function advanceRoom(dungeonState) { + const room = getCurrentRoom(dungeonState); + if (room) room.completed = true; + + if (dungeonState.currentRoom >= dungeonState.totalRooms) { + dungeonState.status = 'completed'; + return { done: true }; + } + + dungeonState.currentRoom += 1; + return { done: false }; +} + +module.exports = { createDungeonState, getCurrentRoom, getRoomEnemies, advanceRoom }; diff --git a/worldboss-app/src/engines/lootEngine.js b/worldboss-app/src/engines/lootEngine.js new file mode 100644 index 0000000..0641aa4 --- /dev/null +++ b/worldboss-app/src/engines/lootEngine.js @@ -0,0 +1,40 @@ +'use strict'; + +const { ITEMS } = require('../data/items'); + +function rollLootOptions(enemy) { + const pool = [...(enemy.loot || [])]; + if (pool.length === 0) return ['potion_heal', 'potion_heal']; + + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + + const options = pool.slice(0, 2); + while (options.length < 2) options.push('potion_heal'); + + return options; +} + +async function applyLoot(prisma, characterId, itemId) { + const item = ITEMS[itemId]; + if (!item) throw new Error(`Unknown item: ${itemId}`); + + const existing = await prisma.characterItem.findUnique({ + where: { characterId_itemId: { characterId, itemId } }, + }); + + if (existing) { + const updated = await prisma.characterItem.update({ + where: { characterId_itemId: { characterId, itemId } }, + data: { quantity: { increment: 1 } }, + }); + return { itemId, isNew: false, newQuantity: updated.quantity }; + } else { + await prisma.characterItem.create({ data: { characterId, itemId, quantity: 1 } }); + return { itemId, isNew: true, newQuantity: 1 }; + } +} + +module.exports = { rollLootOptions, applyLoot }; diff --git a/worldboss-app/src/events/guildCreate.js b/worldboss-app/src/events/guildCreate.js new file mode 100644 index 0000000..08b9ac4 --- /dev/null +++ b/worldboss-app/src/events/guildCreate.js @@ -0,0 +1,49 @@ +'use strict'; + +const { Events, EmbedBuilder } = require('discord.js'); +const { + ensureGuildInDb, + initGuildChannels, + updateGuildChannels, +} = require('../services/guild.service'); + +module.exports = { + name: Events.GuildCreate, + once: false, + + async execute(guild) { + console.log(`[Guild] Rejoint : ${guild.name} (${guild.id})`); + + try { + await ensureGuildInDb(guild); + const channelIds = await initGuildChannels(guild); + await updateGuildChannels(guild.id, channelIds); + + const infoChannel = guild.channels.cache.get(channelIds.infoChannelId); + if (infoChannel) { + const embed = new EmbedBuilder() + .setTitle('⚔️ Une nouvelle guilde entre dans la légende…') + .setDescription( + 'Le bot **WorldBoss** s\'installe sur ce serveur.\n\n' + + '**Channels créés :**\n' + + `📌 <#${channelIds.infoChannelId}> — Infos & statut du bot\n` + + `💬 <#${channelIds.generalChannelId}> — Tchat général\n` + + `🎮 <#${channelIds.commandChannelId}> — Commandes du bot\n` + + `⚔️ <#${channelIds.dungeonChannelId}> — Battle (donjons, pvp, ...)\n\n` + + '**Premiers pas :**\n' + + '`/start` — Créer votre personnage\n' + + '`/dungeon` — Explorer un donjon\n' + + '`/profile` — Consulter vos statistiques', + ) + .setColor(0x8b0000) + .setFooter({ text: 'Que vos lames ne faiblissent jamais.' }); + + await infoChannel.send({ embeds: [embed] }); + } + + console.log(`[Guild] Serveur ${guild.name} initialisé.`); + } catch (err) { + console.error(`[Guild] Erreur initialisation ${guild.name}:`, err.message); + } + }, +}; diff --git a/worldboss-app/src/events/guildDelete.js b/worldboss-app/src/events/guildDelete.js new file mode 100644 index 0000000..41488fe --- /dev/null +++ b/worldboss-app/src/events/guildDelete.js @@ -0,0 +1,20 @@ +'use strict'; + +const { Events } = require('discord.js'); +const { removeGuildFromDb } = require('../services/guild.service'); + +module.exports = { + name: Events.GuildDelete, + once: false, + + async execute(guild) { + console.log(`[Guild] Quitté : ${guild.name} (${guild.id})`); + try { + await removeGuildFromDb(guild.id); + console.log(`[Guild] Données supprimées pour ${guild.name}.`); + } catch (err) { + // La guilde peut ne pas exister en DB si le bot n'a jamais été correctement initialisé + console.error(`[Guild] Erreur suppression DB pour ${guild.name}:`, err.message); + } + }, +}; diff --git a/worldboss-app/src/events/guildMemberAdd.js b/worldboss-app/src/events/guildMemberAdd.js new file mode 100644 index 0000000..ea0d5a2 --- /dev/null +++ b/worldboss-app/src/events/guildMemberAdd.js @@ -0,0 +1,49 @@ +'use strict'; + +const { Events, EmbedBuilder } = require('discord.js'); +const { prisma } = require('../db/prisma'); + +// Messages de bienvenue aléatoires dans le style Darkest Dungeon / FF +const WELCOME_LINES = [ + 'Un nouvel aventurier répond à l\'appel des ténèbres.', + 'Les portes du donjon s\'ouvrent pour accueillir un héros de plus.', + 'La guilde s\'agrandit — que vos talents servent la lumière.', + 'Bienvenue, étranger. Le boss ne dormira pas éternellement.', + 'Une nouvelle lame s\'ajoute à l\'arsenal de la guilde.', +]; + +module.exports = { + name: Events.GuildMemberAdd, + once: false, + + async execute(member) { + try { + const guildData = await prisma.guild.findUnique({ + where: { id: member.guild.id }, + include: { channels: true }, + }); + + if (!guildData?.channels?.announcesChannelId) return; + + const channel = member.guild.channels.cache.get(guildData.channels.announcesChannelId); + if (!channel) return; + + const flavor = WELCOME_LINES[Math.floor(Math.random() * WELCOME_LINES.length)]; + + const embed = new EmbedBuilder() + .setTitle('📜 Un nouveau héros rejoint la guilde !') + .setDescription( + `*${flavor}*\n\n` + + `Bienvenue ${member} sur **${member.guild.name}** !\n\n` + + 'Lance `/start` pour créer ton personnage et rejoindre le raid contre le **World Boss**.', + ) + .setThumbnail(member.user.displayAvatarURL({ dynamic: true })) + .setColor(0x4169e1) + .setFooter({ text: 'Que ton épée soit aussi tranchante que ta volonté.' }); + + await channel.send({ embeds: [embed] }); + } catch (err) { + console.error('[GuildMemberAdd] Erreur:', err.message); + } + }, +}; diff --git a/worldboss-app/src/events/interactionCreate.js b/worldboss-app/src/events/interactionCreate.js new file mode 100644 index 0000000..9ba525a --- /dev/null +++ b/worldboss-app/src/events/interactionCreate.js @@ -0,0 +1,395 @@ +'use strict'; + +const { Events, MessageFlags } = require('discord.js'); +const { handleCombatButton } = require('../services/combat.service'); +const { handleDungeonNext, startDungeon, showDungeonSelection } = require('../services/dungeon.service'); +const { getPendingLoot, deletePendingLoot, deleteDungeonState, deleteCombatState } = require('../cache/redis'); +const { applyLoot } = require('../engines/lootEngine'); +const { buildLeaderboardEmbed } = require('../services/infoPanel.service'); +const { buildWikiEmbed } = require('../services/wiki.service'); +const { equipItem, sellItem, getInventory, getOrCreateLoadout, unequipSlot, useConsumable } = require('../services/inventory.service'); +const { sellToMerchant, createAuction, placeBid, finaliseBuyout } = require('../services/market.service'); +const { getAP } = require('../services/actionPoints.service'); +const { errorEmbed } = require('../utils/embed'); +const { prisma } = require('../db/prisma'); + +const ALL_BOT_COMMANDS = ['start', 'profile', 'inventory', 'setup', 'dungeon']; + +async function getGuildChannels(guildId) { + return prisma.guildChannels.findUnique({ where: { guildId } }); +} + +async function resolveCharacterId(interaction) { + const character = await prisma.character.findUnique({ + where: { userId_guildId: { userId: interaction.user.id, guildId: interaction.guildId } }, + select: { id: true }, + }); + return character?.id ?? null; +} + +async function refreshInventoryReply(interaction, characterId, method = 'update') { + const { buildInventoryMessage } = require('../utils/embed'); + const character = await prisma.character.findUnique({ where: { id: characterId } }); + const [charItems, loadout, ap] = await Promise.all([ + getInventory(characterId), + getOrCreateLoadout(characterId), + getAP(characterId), + ]); + const { embed, rows } = buildInventoryMessage(character, charItems, loadout, ap); + return method === 'update' + ? interaction.update({ embeds: [embed], components: rows }) + : interaction.reply({ embeds: [embed], components: rows, flags: MessageFlags.Ephemeral }); +} + +module.exports = { + name: Events.InteractionCreate, + + async execute(interaction) { + // ── Slash commands ───────────────────────────────────────────────────── + if (interaction.isChatInputCommand()) { + const command = interaction.client.commands.get(interaction.commandName); + + if (!command) { + console.warn(`[Interaction] Commande inconnue: ${interaction.commandName}`); + return; + } + + if (interaction.guildId && ALL_BOT_COMMANDS.includes(interaction.commandName)) { + const channels = await getGuildChannels(interaction.guildId); + if (channels?.categoryId) { + const channel = interaction.guild?.channels.cache.get(interaction.channelId); + + if (channel?.parentId !== channels.categoryId) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + return interaction.deleteReply(); + } + + if (interaction.commandName === 'dungeon' && channels.dungeonChannelId && interaction.channelId !== channels.dungeonChannelId) { + return interaction.reply({ + embeds: [errorEmbed(`La commande \`/dungeon\` est réservée à <#${channels.dungeonChannelId}>.`)], + flags: MessageFlags.Ephemeral, + }); + } + } + } + + try { + await command.execute(interaction); + } catch (err) { + console.error(`[Command/${interaction.commandName}]`, err); + const reply = { embeds: [errorEmbed('Une erreur inattendue est survenue.')], flags: MessageFlags.Ephemeral }; + if (interaction.replied || interaction.deferred) { + await interaction.followUp(reply).catch(() => {}); + } else { + await interaction.reply(reply).catch(() => {}); + } + } + return; + } + + // ── Select menu interactions ─────────────────────────────────────────── + if (interaction.isStringSelectMenu()) { + if (interaction.customId === 'info_scope') { + await interaction.deferUpdate(); + try { + const scope = interaction.values[0]; + const payload = await buildLeaderboardEmbed(interaction.guild, scope); + await interaction.editReply(payload); + } catch (err) { + console.error('[InfoPanel/select]', err); + } + } else if (interaction.customId === 'inventory_action') { + try { + const itemId = interaction.values[0].split(':')[1]; + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + const result = await equipItem(characterId, itemId); + if (!result.success) { + return interaction.reply({ embeds: [errorEmbed(result.message)], flags: MessageFlags.Ephemeral }); + } + await refreshInventoryReply(interaction, characterId); + } catch (err) { + console.error('[Inventory/equip]', err); + } + } else if (interaction.customId === 'inventory_sell_choice') { + try { + const itemId = interaction.values[0].split(':')[1]; + const { ITEMS } = require('../data/items'); + const { buildSellChoiceRow } = require('../utils/embed'); + const { EmbedBuilder } = require('discord.js'); + const itemDef = ITEMS[itemId]; + const sellPrice = Math.max(1, Math.floor((itemDef?.price ?? 0) * 0.1)); + const embed = new EmbedBuilder() + .setTitle('🪙 Vendre un item') + .setDescription( + `> Item : **${itemDef?.name ?? itemId}**\n` + + `> Vente marchant : **${sellPrice}** 🪙 *(10 % du prix de base)*\n` + + '> Enchère : tu définis le prix toi-même\n' + + '> Les deux options consomment **1 PA**.', + ) + .setColor(0xf39c12); + await interaction.reply({ embeds: [embed], components: [buildSellChoiceRow(itemId)], flags: MessageFlags.Ephemeral }); + } catch (err) { + console.error('[Inventory/sell_choice]', err); + } + } else if (interaction.customId === 'inventory_use') { + try { + const itemId = interaction.values[0].split(':')[1]; + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + const result = await useConsumable(characterId, itemId); + if (!result.success) { + return interaction.reply({ embeds: [errorEmbed(result.message)], flags: MessageFlags.Ephemeral }); + } + // Refresh inventory to show updated quantities + await refreshInventoryReply(interaction, characterId); + // Also send a confirmation (ephemeral follow-up since update consumed the interaction) + await interaction.followUp({ embeds: [{ color: 0x2ecc71, description: `✅ ${result.message}` }], flags: MessageFlags.Ephemeral }); + } catch (err) { + console.error('[Inventory/use]', err); + } + } else if (interaction.customId === 'wiki_section') { + await interaction.deferUpdate(); + try { + const section = interaction.values[0]; + const payload = await buildWikiEmbed(interaction.guild, section); + await interaction.editReply(payload); + } catch (err) { + console.error('[Wiki/select]', err); + } + } else if (interaction.customId === 'combat_consumable') { + const itemId = interaction.values[0]; + interaction.customId = `combat_item_${itemId}`; + return await handleCombatButton(interaction); + } else if (interaction.customId === 'dungeon_select') { + try { + const chapter = parseInt(interaction.values[0], 10); + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + return await startDungeon(interaction, chapter, characterId); + } catch (err) { + console.error('[Dungeon/select]', err); + } + } + return; + } + + // ── Modal submissions ────────────────────────────────────────────────── + if (interaction.isModalSubmit()) { + const { customId } = interaction; + + if (customId.startsWith('market_auction_modal:')) { + const [, itemId, duration] = customId.split(':'); + const guildId = interaction.guildId; + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + + const startPrice = parseInt(interaction.fields.getTextInputValue('start_price'), 10); + const buyoutRaw = interaction.fields.getTextInputValue('buyout_price'); + const buyoutPrice = buyoutRaw ? parseInt(buyoutRaw, 10) : null; + + if (isNaN(startPrice) || startPrice < 1) { + return interaction.reply({ embeds: [errorEmbed('Prix de départ invalide.')], flags: MessageFlags.Ephemeral }); + } + if (buyoutPrice !== null && (isNaN(buyoutPrice) || buyoutPrice <= startPrice)) { + return interaction.reply({ embeds: [errorEmbed('Le prix d\'achat direct doit être supérieur au prix de départ.')], flags: MessageFlags.Ephemeral }); + } + + const result = await createAuction(characterId, itemId, guildId, startPrice, buyoutPrice, duration, interaction.client); + if (!result.success) { + return interaction.reply({ embeds: [errorEmbed(result.message)], flags: MessageFlags.Ephemeral }); + } + return refreshInventoryReply(interaction, characterId, 'reply'); + } + + if (customId.startsWith('market_bid_modal:')) { + const listingId = parseInt(customId.split(':')[1], 10); + const amount = parseInt(interaction.fields.getTextInputValue('bid_amount'), 10); + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + + if (isNaN(amount) || amount < 1) { + return interaction.reply({ embeds: [errorEmbed('Montant invalide.')], flags: MessageFlags.Ephemeral }); + } + + const result = await placeBid(characterId, listingId, amount, interaction.client); + if (!result.success) { + return interaction.reply({ embeds: [errorEmbed(result.message)], flags: MessageFlags.Ephemeral }); + } + return interaction.reply({ embeds: [errorEmbed(`✅ ${result.message}`)], flags: MessageFlags.Ephemeral }); + } + + return; + } + + // ── Button interactions ──────────────────────────────────────────────── + if (interaction.isButton()) { + const { customId } = interaction; + + try { + if (customId.startsWith('unequip:')) { + const slot = customId.split(':')[1]; + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + const result = await unequipSlot(characterId, slot); + if (!result.success) { + return interaction.reply({ embeds: [errorEmbed(result.message)], flags: MessageFlags.Ephemeral }); + } + return refreshInventoryReply(interaction, characterId); + } + + if (customId === 'info_leaderboard') { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const payload = await buildLeaderboardEmbed(interaction.guild, 'server_level'); + return interaction.editReply(payload); + } + + if (customId === 'info_wiki') { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const payload = await buildWikiEmbed(interaction.guild, 'gameplay'); + return interaction.editReply(payload); + } + + if (customId.startsWith('market_merchant:')) { + const itemId = customId.split(':')[1]; + const guildId = interaction.guildId; + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + const result = await sellToMerchant(characterId, itemId, guildId, interaction.client); + if (!result.success) { + return interaction.reply({ embeds: [errorEmbed(result.message)], flags: MessageFlags.Ephemeral }); + } + await refreshInventoryReply(interaction, characterId); + return interaction.followUp({ embeds: [{ color: 0x2ecc71, description: `✅ ${result.message}` }], flags: MessageFlags.Ephemeral }); + } + + if (customId.startsWith('market_auction_start:')) { + const itemId = customId.split(':')[1]; + const { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js'); + const { ITEMS } = require('../data/items'); + const itemDef = ITEMS[itemId]; + const embed = new EmbedBuilder() + .setTitle('🔨 Nouvelle enchère') + .setDescription( + `> Item : **${itemDef?.name ?? itemId}**\n` + + '> Choisis la durée de l\'enchère.\n' + + '> Tu devras ensuite définir le prix de départ via le modal.', + ) + .setColor(0xf39c12); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`market_auction_dur:${itemId}:1h`).setLabel('1 heure').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(`market_auction_dur:${itemId}:6h`).setLabel('6 heures').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId(`market_auction_dur:${itemId}:24h`).setLabel('24 heures').setStyle(ButtonStyle.Secondary), + ); + return interaction.reply({ embeds: [embed], components: [row], flags: MessageFlags.Ephemeral }); + } + + if (customId.startsWith('market_auction_dur:')) { + const [, itemId, duration] = customId.split(':'); + const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder: AR } = require('discord.js'); + const modal = new ModalBuilder() + .setCustomId(`market_auction_modal:${itemId}:${duration}`) + .setTitle('Paramètres de l\'enchère'); + modal.addComponents( + new AR().addComponents( + new TextInputBuilder().setCustomId('start_price').setLabel('Prix de départ (or)').setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder('ex: 50'), + ), + new AR().addComponents( + new TextInputBuilder().setCustomId('buyout_price').setLabel('Prix d\'achat direct (laisser vide si non)').setStyle(TextInputStyle.Short).setRequired(false).setPlaceholder('ex: 200'), + ), + ); + return interaction.showModal(modal); + } + + if (customId.startsWith('market_bid:')) { + const listingId = parseInt(customId.split(':')[1], 10); + const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder: AR } = require('discord.js'); + const modal = new ModalBuilder() + .setCustomId(`market_bid_modal:${listingId}`) + .setTitle('Placer une enchère'); + modal.addComponents( + new AR().addComponents( + new TextInputBuilder().setCustomId('bid_amount').setLabel('Montant de ton enchère (or)').setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder('ex: 100'), + ), + ); + return interaction.showModal(modal); + } + + if (customId.startsWith('market_buyout:')) { + const listingId = parseInt(customId.split(':')[1], 10); + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + const result = await finaliseBuyout(characterId, listingId, interaction.client); + if (!result.success) { + return interaction.reply({ embeds: [errorEmbed(result.message)], flags: MessageFlags.Ephemeral }); + } + return interaction.reply({ embeds: [errorEmbed(`✅ ${result.message}`)], flags: MessageFlags.Ephemeral }); + } + + if (customId.startsWith('loot_')) { + const idx = parseInt(customId.split('_')[1], 10); + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + + await interaction.deferUpdate(); + const { EmbedBuilder } = require('discord.js'); + const { ITEMS } = require('../data/items'); + const options = await getPendingLoot(characterId); + const chosen = options?.[idx] ?? null; + + if (chosen) { + await applyLoot(prisma, characterId, chosen); + } + await deletePendingLoot(characterId); + + const itemName = chosen ? (ITEMS[chosen]?.name ?? chosen) : 'rien'; + const embed = new EmbedBuilder() + .setTitle('🎁 Butin récupéré') + .setDescription(`Vous avez obtenu : **${itemName}**`) + .setColor(0xf1c40f); + return interaction.editReply({ embeds: [embed], components: [] }); + } + + if (customId.startsWith('combat_')) { + return await handleCombatButton(interaction); + } + + if (customId === 'dungeon_next') { + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + return await handleDungeonNext(interaction, characterId); + } + + if (customId.startsWith('dungeon_start:')) { + const chapter = parseInt(customId.split(':')[1], 10); + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + return await startDungeon(interaction, chapter, characterId); + } + + if (customId === 'dungeon_abandon') { + const characterId = await resolveCharacterId(interaction); + if (!characterId) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + await interaction.deferUpdate(); + await deleteDungeonState(characterId); + await deleteCombatState(characterId); + await prisma.dungeonRun.updateMany({ + where: { characterId, status: 'active' }, + data: { status: 'failed' }, + }); + return await showDungeonSelection(interaction, characterId); + } + + console.warn(`[Button] customId non géré: ${customId}`); + } catch (err) { + console.error(`[Button/${customId}]`, err); + const reply = { embeds: [errorEmbed('Une erreur inattendue est survenue.')], flags: MessageFlags.Ephemeral }; + if (interaction.replied || interaction.deferred) { + await interaction.followUp(reply).catch(() => {}); + } else { + await interaction.reply(reply).catch(() => {}); + } + } + } + }, +}; diff --git a/worldboss-app/src/events/ready.js b/worldboss-app/src/events/ready.js new file mode 100644 index 0000000..c00ea2d --- /dev/null +++ b/worldboss-app/src/events/ready.js @@ -0,0 +1,37 @@ +'use strict'; + +const { Events } = require('discord.js'); +const { ensureItemsSeeded } = require('../services/player.service'); +const { ensureGuildInDb, initGuildChannels, updateGuildChannels } = require('../services/guild.service'); +const { refreshInfoPanel } = require('../services/infoPanel.service'); + +module.exports = { + name: Events.ClientReady, + once: true, + + async execute(client) { + console.log(`[Bot] Connecté en tant que ${client.user.tag}`); + + try { + await ensureItemsSeeded(); + console.log('[DB] Items synchronisés.'); + } catch (err) { + console.error('[DB] Erreur lors de la synchronisation des items:', err.message); + } + + for (const guild of client.guilds.cache.values()) { + try { + await guild.fetch(); + await ensureGuildInDb(guild); + const channelIds = await initGuildChannels(guild); + await updateGuildChannels(guild.id, channelIds); + if (channelIds.infoChannelId) { + await refreshInfoPanel(client, guild, channelIds.infoChannelId); + } + console.log(`[Setup] Serveur initialisé : ${guild.name}`); + } catch (err) { + console.error(`[Setup] Erreur sur ${guild.name}:`, err.message); + } + } + }, +}; diff --git a/worldboss-app/src/index.js b/worldboss-app/src/index.js new file mode 100644 index 0000000..0b4d796 --- /dev/null +++ b/worldboss-app/src/index.js @@ -0,0 +1,88 @@ +'use strict'; + +require('dotenv').config(); + +const path = require('path'); +const fs = require('fs'); +const { Client, Collection, GatewayIntentBits } = require('discord.js'); +const { redis } = require('./cache/redis'); +const { startAuctionWorker } = require('./services/market.service'); + +// ── Discord client ─────────────────────────────────────────────────────────── +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + ], +}); + +client.commands = new Collection(); + +// ── Load commands ──────────────────────────────────────────────────────────── +const commandsRoot = path.join(__dirname, 'commands'); +const commandDirs = fs.readdirSync(commandsRoot) + .map((sub) => path.join(commandsRoot, sub)) + .filter((p) => fs.statSync(p).isDirectory()); + +for (const dir of commandDirs) { + if (!fs.existsSync(dir)) continue; + const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js')); + for (const file of files) { + const command = require(path.join(dir, file)); + if (command.data && command.execute) { + client.commands.set(command.data.name, command); + console.log(`[Commands] Loaded: ${command.data.name}`); + } + } +} + +// ── Load events ────────────────────────────────────────────────────────────── +const eventsDir = path.join(__dirname, 'events'); +const eventFiles = fs.readdirSync(eventsDir).filter((f) => f.endsWith('.js')); + +for (const file of eventFiles) { + const event = require(path.join(eventsDir, file)); + if (event.once) { + client.once(event.name, (...args) => event.execute(...args)); + } else { + client.on(event.name, (...args) => event.execute(...args)); + } + console.log(`[Events] Loaded: ${event.name}`); +} + +// ── Graceful shutdown ──────────────────────────────────────────────────────── +process.on('SIGINT', async () => { + console.log('[Bot] Shutting down...'); + await redis.quit(); + client.destroy(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.log('[Bot] Shutting down...'); + await redis.quit(); + client.destroy(); + process.exit(0); +}); + +// ── Login ──────────────────────────────────────────────────────────────────── +const token = process.env.DISCORD_TOKEN; +if (!token) { + console.error('[Bot] DISCORD_TOKEN manquant dans les variables d\'environnement.'); + process.exit(1); +} + +redis.connect().catch((err) => console.error('[Redis] Failed to connect:', err.message)); + +// Empêche les rejections non gérées (ex: interaction Discord expirée) de crasher le process +process.on('unhandledRejection', (err) => { + console.error('[UnhandledRejection]', err?.message ?? err); +}); + +client.login(token).then(() => { + console.log('[Bot] Login en cours...'); + startAuctionWorker(client); +}).catch((err) => { + console.error('[Bot] Impossible de se connecter:', err.message); + process.exit(1); +}); diff --git a/worldboss-app/src/services/actionPoints.service.js b/worldboss-app/src/services/actionPoints.service.js new file mode 100644 index 0000000..d6a9185 --- /dev/null +++ b/worldboss-app/src/services/actionPoints.service.js @@ -0,0 +1,92 @@ +'use strict'; + +const { prisma } = require('../db/prisma'); + +const AP_MAX = 10; +const AP_RECHARGE_MS = 2 * 60 * 60 * 1000; // 1 PA toutes les 2 heures + +const AP_COST = { + sell: 1, +}; + +function computeCurrentAP(character) { + const elapsed = Date.now() - new Date(character.actionPointsUpdatedAt).getTime(); + const recharged = Math.floor(elapsed / AP_RECHARGE_MS); + return Math.min(AP_MAX, character.actionPoints + recharged); +} + +function msUntilNextAP(character) { + const current = computeCurrentAP(character); + if (current >= AP_MAX) return 0; + const elapsed = Date.now() - new Date(character.actionPointsUpdatedAt).getTime(); + return AP_RECHARGE_MS - (elapsed % AP_RECHARGE_MS); +} + +async function consumeAP(characterId, action) { + const cost = AP_COST[action]; + if (cost === undefined) throw new Error(`Action inconnue : ${action}`); + + const character = await prisma.character.findUnique({ + where: { id: characterId }, + select: { actionPoints: true, actionPointsUpdatedAt: true }, + }); + if (!character) throw new Error('Character not found'); + + const elapsed = Date.now() - new Date(character.actionPointsUpdatedAt).getTime(); + const recharged = Math.floor(elapsed / AP_RECHARGE_MS); + const currentAP = Math.min(AP_MAX, character.actionPoints + recharged); + + if (currentAP < cost) { + return { success: false, currentAP, msUntilNext: msUntilNextAP(character) }; + } + + const newUpdatedAt = recharged > 0 + ? new Date(new Date(character.actionPointsUpdatedAt).getTime() + recharged * AP_RECHARGE_MS) + : character.actionPointsUpdatedAt; + + await prisma.character.update({ + where: { id: characterId }, + data: { actionPoints: currentAP - cost, actionPointsUpdatedAt: newUpdatedAt }, + }); + + return { success: true, currentAP: currentAP - cost, msUntilNext: 0 }; +} + +async function getAP(characterId) { + const character = await prisma.character.findUnique({ + where: { id: characterId }, + select: { actionPoints: true, actionPointsUpdatedAt: true }, + }); + if (!character) throw new Error('Character not found'); + return { + current: computeCurrentAP(character), + max: AP_MAX, + msUntilNext: msUntilNextAP(character), + }; +} + +async function restoreAP(characterId, amount) { + const character = await prisma.character.findUnique({ + where: { id: characterId }, + select: { actionPoints: true, actionPointsUpdatedAt: true }, + }); + if (!character) throw new Error('Character not found'); + + const elapsed = Date.now() - new Date(character.actionPointsUpdatedAt).getTime(); + const recharged = Math.floor(elapsed / AP_RECHARGE_MS); + const currentAP = Math.min(AP_MAX, character.actionPoints + recharged); + const newAP = Math.min(AP_MAX, currentAP + amount); + + const newUpdatedAt = recharged > 0 + ? new Date(new Date(character.actionPointsUpdatedAt).getTime() + recharged * AP_RECHARGE_MS) + : character.actionPointsUpdatedAt; + + await prisma.character.update({ + where: { id: characterId }, + data: { actionPoints: newAP, actionPointsUpdatedAt: newUpdatedAt }, + }); + + return { newAP, restored: newAP - currentAP }; +} + +module.exports = { consumeAP, restoreAP, getAP, AP_MAX, AP_COST }; diff --git a/worldboss-app/src/services/combat.service.js b/worldboss-app/src/services/combat.service.js new file mode 100644 index 0000000..9f1f098 --- /dev/null +++ b/worldboss-app/src/services/combat.service.js @@ -0,0 +1,328 @@ +'use strict'; + +const { prisma } = require('../db/prisma'); +const { getCombatState, setCombatState, deleteCombatState, deleteDungeonState, getDungeonState, setDungeonState } = require('../cache/redis'); +const { resolveTurn } = require('../engines/combatEngine'); +const { applyLoot } = require('../engines/lootEngine'); +const { addXp, addGold, computeRegenedHp } = require('./player.service'); +const { computeStats } = require('../utils/stats'); +const { buildCombatEmbed, buildCombatRow, buildDungeonNextRow, errorEmbed } = require('../utils/embed'); +const { EmbedBuilder, MessageFlags } = require('discord.js'); +const { DUNGEONS } = require('../data/dungeons'); + +/** + * Build combat state from character + list of enemies. + * Uses the character's current HP (with regen applied). + */ +function buildCombatState({ characterId, guildId, messageId, channelId, character, loadout, enemies, allies = [], dungeonChapter, currentRoom, totalRooms }) { + const stats = computeStats(character, loadout); + const maxHp = stats.hp; + const currentHp = computeRegenedHp(character.hp, character.hpUpdatedAt, maxHp); + + return { + characterId, + guildId, + messageId, + channelId, + dungeonChapter: dungeonChapter ?? null, + currentRoom: currentRoom ?? null, + totalRooms: totalRooms ?? null, + player: { + hp: currentHp, + maxHp, + atk: stats.atk, + def: stats.def, + spd: stats.spd, + crit: stats.crit, + critMult: stats.critMult, + consumables: [], + activeSkills: stats.activeSkills ?? [], + activePassives: stats.activePassives ?? [], + skillCooldowns: {}, // { skillKey: turnsRemaining } + usedOnceSkills: [], + buffs: [], + dots: [], + }, + allies: allies.map((a) => ({ ...a })), + enemies: enemies.map((e) => ({ + id: e.id, + name: e.name, + hp: e.hp, + maxHp: e.maxHp ?? e.hp, + atk: e.atk, + def: e.def, + spd: e.spd, + ability: e.ability ?? null, + stunned: false, + xp: e.xp ?? 0, + gold: e.gold ?? null, + loot: e.loot ?? [], + })), + turn: 1, + log: [], + status: 'active', + }; +} + +async function handleCombatButton(interaction) { + const userId = interaction.user.id; + const guildId = interaction.guildId; + + const character = await prisma.character.findUnique({ + where: { userId_guildId: { userId, guildId } }, + select: { id: true }, + }); + if (!character) { + return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + } + + const characterId = character.id; + const state = await getCombatState(characterId); + + if (!state || state.status !== 'active') { + return interaction.reply({ embeds: [errorEmbed('Aucun combat en cours.')], flags: MessageFlags.Ephemeral }); + } + if (state.characterId !== characterId) { + return interaction.reply({ embeds: [errorEmbed('Ce combat ne vous appartient pas.')], flags: MessageFlags.Ephemeral }); + } + + await interaction.deferUpdate(); + + // Parse action and optional target index from customId e.g. "combat_attack:1" + const raw = interaction.customId.replace('combat_', ''); + const colonIdx = raw.indexOf(':'); + const action = colonIdx !== -1 ? raw.substring(0, colonIdx) : raw; + const targetIndex = colonIdx !== -1 ? parseInt(raw.substring(colonIdx + 1), 10) : 0; + + // Snapshot consumable quantities before turn to detect usage + const consumablesBefore = (state.player.consumables ?? []).map((c) => ({ ...c })); + + const result = resolveTurn(state, action, targetIndex); + + state.player = result.playerState; + state.enemies = result.enemiesState; + state.allies = result.alliesState ?? state.allies ?? []; + state.log = [...state.log, ...result.logs]; + state.turn += 1; + + // Sync consumed items to DB (skip infiniteUse items) + for (const before of consumablesBefore) { + const after = state.player.consumables?.find((c) => c.itemId === before.itemId); + const usedQty = before.quantity - (after?.quantity ?? 0); + if (usedQty > 0 && !before.infiniteUse) { + const { ITEMS } = require('../data/items'); + if (ITEMS[before.itemId]?.infiniteUse) continue; + const charItem = await prisma.characterItem.findUnique({ + where: { characterId_itemId: { characterId, itemId: before.itemId } }, + }); + if (charItem) { + const newQty = charItem.quantity - usedQty; + if (newQty <= 0) { + await prisma.characterItem.delete({ where: { characterId_itemId: { characterId, itemId: before.itemId } } }); + } else { + await prisma.characterItem.update({ + where: { characterId_itemId: { characterId, itemId: before.itemId } }, + data: { quantity: newQty }, + }); + } + } + } + } + + // ── FLEE ────────────────────────────────────────────────────────────────── + if (result.fled) { + state.status = 'fled'; + await deleteCombatState(characterId); + await deleteDungeonState(characterId); + await prisma.character.update({ + where: { id: characterId }, + data: { hp: Math.max(1, state.player.hp), hpUpdatedAt: new Date() }, + }); + await prisma.dungeonRun.updateMany({ + where: { characterId, chapter: state.dungeonChapter, status: 'active' }, + data: { status: 'failed' }, + }); + + const embed = new EmbedBuilder() + .setTitle('🏃 Fuite réussie !') + .setDescription(`Vous quittez les catacombes avec **${Math.max(1, state.player.hp)} HP** restants.\n\n*Votre progression est perdue.*`) + .setColor(0xf39c12); + return interaction.editReply({ embeds: [embed], components: [] }); + } + + // ── PLAYER DIED ─────────────────────────────────────────────────────────── + if (result.playerDied) { + state.status = 'defeat'; + await deleteCombatState(characterId); + await deleteDungeonState(characterId); + await prisma.character.update({ where: { id: characterId }, data: { hp: 1, hpUpdatedAt: new Date() } }); + await prisma.dungeonRun.updateMany({ + where: { characterId, chapter: state.dungeonChapter, status: 'active' }, + data: { status: 'failed' }, + }); + + const embed = new EmbedBuilder() + .setTitle('💀 Défaite') + .setDescription(result.logs.join('\n') + '\n\n*Vous vous réveillez à l\'entrée avec 1 HP...*') + .setColor(0x2c2c2c); + return interaction.editReply({ embeds: [embed], components: [] }); + } + + // ── ALL ENEMIES DEAD (room cleared) ─────────────────────────────────────── + if (result.allEnemiesDead) { + state.status = 'victory'; + await deleteCombatState(characterId); + + const totalXp = state.enemies.reduce((sum, e) => sum + (e.xp ?? 0), 0); + const roomGold = state.enemies.reduce((sum, e) => { + if (!e.gold) return sum; + return sum + Math.floor(Math.random() * (e.gold.max - e.gold.min + 1)) + e.gold.min; + }, 0); + const xpResult = await addXp(characterId, totalXp); + + const levelUpText = xpResult.leveledUp + ? `\n🎉 **Level Up !** Niveau **${xpResult.newLevel}** !` + : ''; + + // ── Dungeon context ────────────────────────────────────────────────────── + if (state.dungeonChapter) { + const dungeonState = await getDungeonState(characterId); + const isLastRoom = state.currentRoom >= state.totalRooms; + + if (isLastRoom) { + // ── DUNGEON COMPLETE ──────────────────────────────────────────────── + // Accumulate all enemies fought (current room + previous rooms) + const allEnemiesFought = [...(dungeonState?.enemiesFought ?? []), ...state.enemies.map((e) => e.id)]; + + await deleteDungeonState(characterId); + await prisma.dungeonRun.updateMany({ + where: { characterId, chapter: state.dungeonChapter, status: 'active' }, + data: { status: 'completed' }, + }); + + // Dungeon-specific gold override (e.g. dungeon 3 has its own goldRange) + const dungeon = DUNGEONS[state.dungeonChapter]; + let finalGold = roomGold; + if (dungeon?.reward?.gold) { + const { min, max } = dungeon.reward.gold; + finalGold = Math.floor(Math.random() * (max - min + 1)) + min; + } + await addGold(characterId, finalGold); + + // Loot lottery: each enemy rolls independently; collect winners, pick one random drop + const { ENEMIES } = require('../data/enemies'); + const { ITEMS } = require('../data/items'); + // Each enemy contributes one candidate item to the pool, then ONE is picked at random + const pool = []; + for (const enemyId of allEnemiesFought) { + const enemyDef = ENEMIES[enemyId]; + if (!enemyDef?.loot?.length) continue; + pool.push(enemyDef.loot[Math.floor(Math.random() * enemyDef.loot.length)]); + } + const droppedNames = []; + if (pool.length > 0) { + const itemId = pool[Math.floor(Math.random() * pool.length)]; + await applyLoot(prisma, characterId, itemId); + droppedNames.push(ITEMS[itemId]?.name ?? itemId); + } + + // If leveled up, addXp already set HP to max — don't overwrite + if (!xpResult.leveledUp) { + await prisma.character.update({ + where: { id: characterId }, + data: { hp: state.player.hp, hpUpdatedAt: new Date() }, + }); + } + + const { baseStats, computeStats } = require('../utils/stats'); + const finalChar = await prisma.character.findUnique({ where: { id: characterId }, include: { loadout: true } }); + const finalMaxHp = computeStats(finalChar, finalChar.loadout ?? {}).hp; + const finalHp = finalChar.hp; + + const lootLines = droppedNames.map((n) => `> 🎁 **${n}**`).join('\n'); + const specialMsg = dungeon?.reward?.message ?? null; + + const embed = new EmbedBuilder() + .setTitle('🏆 Donjon terminé !') + .setDescription( + (specialMsg ? specialMsg + '\n\n' : '') + + `+**${totalXp}** XP${levelUpText}\n\n` + + `**Récompenses**\n` + + `> 🪙 **${finalGold}** or\n` + + (lootLines ? lootLines + '\n' : '') + + `\n*Vous ressortez avec ${finalHp}/${finalMaxHp} HP.*`, + ) + .setColor(0xf1c40f); + return interaction.editReply({ embeds: [embed], components: [] }); + + } else { + // ── NEXT ROOM ─────────────────────────────────────────────────────── + await addGold(characterId, roomGold); + + let newHp, newMaxHp, healDesc; + if (xpResult.leveledUp) { + // addXp already set HP to full — read it back + const afterLvl = await prisma.character.findUnique({ where: { id: characterId }, include: { loadout: true } }); + const { computeStats } = require('../utils/stats'); + newMaxHp = computeStats(afterLvl, afterLvl.loadout ?? {}).hp; + newHp = afterLvl.hp; + healDesc = `✨ Level up — HP restaurés à **${newHp}/${newMaxHp}** !`; + } else { + // Heal 15% maxHp between rooms + const healAmt = Math.max(5, Math.floor(state.player.maxHp * 0.15)); + newMaxHp = state.player.maxHp; + newHp = Math.min(newMaxHp, state.player.hp + healAmt); + healDesc = `💚 Vous récupérez **${healAmt}** HP.`; + await prisma.character.update({ + where: { id: characterId }, + data: { hp: newHp, hpUpdatedAt: new Date() }, + }); + } + + // Advance dungeon room, track enemies fought + if (dungeonState) { + dungeonState.currentRoom = state.currentRoom + 1; + dungeonState.enemiesFought = [...(dungeonState.enemiesFought ?? []), ...state.enemies.map((e) => e.id)]; + await setDungeonState(characterId, dungeonState); + } + + const embed = new EmbedBuilder() + .setTitle(`✅ Salle ${state.currentRoom}/${state.totalRooms} terminée !`) + .setDescription( + `+**${totalXp}** XP${levelUpText}\n` + + (roomGold > 0 ? `+**${roomGold}** 🪙\n` : '') + + `\n${healDesc}\n` + + `❤️ HP : **${newHp}/${newMaxHp}**\n\n` + + `*La prochaine salle vous attend...*`, + ) + .setColor(0x2ecc71); + return interaction.editReply({ + embeds: [embed], + components: [buildDungeonNextRow('Salle suivante ➡️')], + }); + } + } + + // ── Standalone fight (no dungeon context) ──────────────────────────────── + if (!xpResult.leveledUp) { + await prisma.character.update({ + where: { id: characterId }, + data: { hp: state.player.hp, hpUpdatedAt: new Date() }, + }); + } + const embed = new EmbedBuilder() + .setTitle('🏆 Victoire !') + .setDescription(`+**${totalXp}** XP${levelUpText}`) + .setColor(0x2ecc71); + return interaction.editReply({ embeds: [embed], components: [] }); + } + + // ── ONGOING ─────────────────────────────────────────────────────────────── + await setCombatState(characterId, state); + return interaction.editReply({ + embeds: [buildCombatEmbed(state)], + components: buildCombatRow(state), + }); +} + +module.exports = { buildCombatState, handleCombatButton }; diff --git a/worldboss-app/src/services/dungeon.service.js b/worldboss-app/src/services/dungeon.service.js new file mode 100644 index 0000000..c430409 --- /dev/null +++ b/worldboss-app/src/services/dungeon.service.js @@ -0,0 +1,201 @@ +'use strict'; + +const { prisma } = require('../db/prisma'); +const { getDungeonState, setDungeonState, deleteDungeonState, deleteCombatState, getCombatState } = require('../cache/redis'); +const { createDungeonState, getCurrentRoom, getRoomEnemies, advanceRoom } = require('../engines/dungeonEngine'); +const { buildCombatState } = require('./combat.service'); +const { getOrCreateLoadout } = require('./inventory.service'); +const { computeRegenedHp } = require('./player.service'); +const { computeStats } = require('../utils/stats'); +const { buildCombatEmbed, buildCombatRow, buildDungeonNextRow, errorEmbed } = require('../utils/embed'); +const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, MessageFlags } = require('discord.js'); +const { DUNGEONS, ALLIES } = require('../data/dungeons'); +const { setCombatState } = require('../cache/redis'); + +async function showDungeonSelection(interaction, characterId) { + const character = await prisma.character.findUnique({ where: { id: characterId } }); + if (!character) return interaction.reply({ embeds: [errorEmbed('Personnage introuvable.')], flags: MessageFlags.Ephemeral }); + + const completedRuns = await prisma.dungeonRun.findMany({ + where: { characterId, status: 'completed' }, + select: { chapter: true }, + }); + const completedChapters = new Set(completedRuns.map((r) => r.chapter)); + + const available = Object.values(DUNGEONS).filter((d) => character.level >= d.levelRequired); + + if (available.length === 0) { + return interaction.reply({ + embeds: [errorEmbed('Aucun donjon disponible pour ton niveau actuel.')], + flags: MessageFlags.Ephemeral, + }); + } + + const lines = available.map((d) => { + const done = completedChapters.has(d.id); + return `${done ? '✅' : '🔓'} **${d.name}**\n> *${d.lore}*`; + }); + + const embed = new EmbedBuilder() + .setTitle('⚔️ Sélection du donjon') + .setDescription(lines.join('\n\n')) + .setColor(0x8e44ad) + .setFooter({ text: '✅ Déjà terminé · 🔓 Disponible' }); + + const select = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('dungeon_select') + .setPlaceholder('Choisir un donjon…') + .addOptions(available.map((d) => ({ + label: completedChapters.has(d.id) ? `${d.name} ✅` : d.name, + description: `Niveau ${d.levelRequired} requis · ${d.rooms.length} salles`, + value: `${d.id}`, + }))), + ); + + const method = interaction.deferred || interaction.replied ? 'editReply' : 'reply'; + return interaction[method]({ embeds: [embed], components: [select], flags: MessageFlags.Ephemeral }); +} + +async function startDungeon(interaction, chapter, characterId) { + const dungeon = DUNGEONS[chapter]; + if (!dungeon) { + return interaction.update({ embeds: [errorEmbed('Donjon introuvable.')], components: [] }); + } + + const character = await prisma.character.findUnique({ where: { id: characterId } }); + if (character.level < dungeon.levelRequired) { + return interaction.update({ embeds: [errorEmbed(`Niveau ${dungeon.levelRequired} requis.`)], components: [] }); + } + + // Cancel any existing run for this chapter before starting a new one + await prisma.dungeonRun.updateMany({ + where: { characterId, chapter, status: 'active' }, + data: { status: 'failed' }, + }); + + const dungeonState = createDungeonState(characterId, interaction.guildId, chapter); + await setDungeonState(characterId, dungeonState); + await prisma.dungeonRun.create({ data: { characterId, chapter, status: 'active' } }); + + const firstRoom = getCurrentRoom(dungeonState); + + const embed = new EmbedBuilder() + .setTitle(`🏰 ${dungeon.name} — Salle 1/${dungeonState.totalRooms}`) + .setDescription(`*${firstRoom.description}*\n\nPréparez-vous au combat !`) + .setColor(0x8e44ad); + + return interaction.update({ + embeds: [embed], + components: [buildDungeonNextRow('Entrer dans la salle ⚔️')], + }); +} + +async function handleDungeonNext(interaction, characterId) { + const dungeonState = await getDungeonState(characterId); + + if (!dungeonState || dungeonState.status !== 'active') { + return interaction.reply({ + embeds: [errorEmbed('Aucun donjon en cours. Utilisez `/dungeon` pour commencer.')], + flags: MessageFlags.Ephemeral, + }); + } + + await interaction.deferUpdate(); + + const character = await prisma.character.findUnique({ + where: { id: characterId }, + include: { loadout: true }, + }); + const loadout = character.loadout ?? await getOrCreateLoadout(characterId); + const stats = computeStats(character, loadout); + + // Apply regen to get current HP before combat + const currentHp = computeRegenedHp(character.hp, character.hpUpdatedAt, stats.hp); + if (currentHp !== character.hp) { + await prisma.character.update({ where: { id: characterId }, data: { hp: currentHp, hpUpdatedAt: new Date() } }); + character.hp = currentHp; + } + + const room = getCurrentRoom(dungeonState); + const enemies = getRoomEnemies(room); + + // Inject NPC ally if this room defines one + const allies = room.ally && ALLIES[room.ally] ? [{ ...ALLIES[room.ally] }] : []; + + const { ITEMS } = require('../data/items'); + const charItems = await prisma.characterItem.findMany({ where: { characterId } }); + + const combatState = buildCombatState({ + characterId, + guildId: interaction.guildId, + messageId: interaction.message?.id, + channelId: interaction.channelId, + character, + loadout, + enemies, + allies, + dungeonChapter: dungeonState.chapter, + currentRoom: dungeonState.currentRoom, + totalRooms: dungeonState.totalRooms, + }); + + // Load consumables into combat state: + // - finite items: loaded with their actual quantity + // - infiniteUse items: loaded with quantity = -1 (sentinel for unlimited) + const combatConsumables = charItems + .filter((ci) => { + const def = ITEMS[ci.itemId]; + return def?.type === 'consumable' && (ci.quantity > 0 || def.infiniteUse); + }) + .map((ci) => { + const def = ITEMS[ci.itemId]; + return { itemId: ci.itemId, quantity: def.infiniteUse ? -1 : ci.quantity, infiniteUse: !!def.infiniteUse }; + }); + + combatState.player.consumables = combatConsumables; + + await setCombatState(characterId, combatState); + + const embed = buildCombatEmbed(combatState); + embed.setDescription(`**Salle ${dungeonState.currentRoom}/${dungeonState.totalRooms}** — *${room.description}*`); + + return interaction.editReply({ embeds: [embed], components: buildCombatRow(combatState) }); +} + +async function startOrResumeDungeon(interaction, characterId) { + const existing = await getDungeonState(characterId); + + if (existing && existing.status === 'active') { + const combatState = await getCombatState(characterId); + + if (combatState && combatState.status === 'active') { + return interaction.reply({ + embeds: [buildCombatEmbed(combatState)], + components: buildCombatRow(combatState), + flags: MessageFlags.Ephemeral, + }); + } + + const dungeon = DUNGEONS[existing.chapter]; + return interaction.reply({ + embeds: [ + new EmbedBuilder() + .setTitle(`🏰 ${dungeon?.name ?? 'Donjon'} — En cours`) + .setDescription(`Vous êtes à la **Salle ${existing.currentRoom}/${existing.totalRooms}**.\n\nContinuez ou abandonnez.`) + .setColor(0x8e44ad), + ], + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('dungeon_next').setLabel('Continuer ➡️').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId('dungeon_abandon').setLabel('Abandonner').setStyle(ButtonStyle.Danger), + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + return showDungeonSelection(interaction, characterId); +} + +module.exports = { startOrResumeDungeon, startDungeon, showDungeonSelection, handleDungeonNext }; diff --git a/worldboss-app/src/services/guild.service.js b/worldboss-app/src/services/guild.service.js new file mode 100644 index 0000000..b31f57d --- /dev/null +++ b/worldboss-app/src/services/guild.service.js @@ -0,0 +1,174 @@ +'use strict'; + +const { PermissionFlagsBits } = require('discord.js'); +const { prisma } = require('../db/prisma'); + +/** + * Ensures a guild exists in the database. + * Creates the Guild, its channel slots and the initial WorldBoss in one shot. + */ +async function ensureGuildInDb(guild) { + return prisma.guild.upsert({ + where: { id: guild.id }, + create: { + id: guild.id, + name: guild.name, + ownerId: guild.ownerId, + channels: { create: {} }, + }, + update: { name: guild.name, ownerId: guild.ownerId }, + include: { channels: true }, + }); +} + +/** + * Updates the channel IDs stored for a guild. + */ +async function updateGuildChannels(guildId, data) { + return prisma.guildChannels.upsert({ + where: { guildId }, + create: { guildId, ...data }, + update: data, + }); +} + +/** + * Removes a guild and all cascading data from the database. + */ +async function removeGuildFromDb(guildId) { + return prisma.guild.delete({ where: { id: guildId } }); +} + +/** + * Initialises the Discord category and thematic channels for a guild. + * + * Channels créés sous la catégorie "WorldBoss" : + * wb-info — lecture seule (annonces bot) + * wb-general — tchat libre + * wb-commandes — commandes du bot + * wb-battle — modes de jeu (donjon, pvp, infini...) + * + * Returns { categoryId, infoChannelId, generalChannelId, commandChannelId, dungeonChannelId } + */ +async function initGuildChannels(guild) { + const botId = guild.client.user.id; + const everyoneId = guild.roles.everyone.id; + + // Permissions bot : tout autoriser explicitement + const botPerms = { + id: botId, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.EmbedLinks, + PermissionFlagsBits.ReadMessageHistory, + PermissionFlagsBits.UseApplicationCommands, + ], + }; + + const channelDefs = [ + { + key: 'infoChannelId', + name: 'wb-info', + permissionOverwrites: [ + { + id: everyoneId, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory], + deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.UseApplicationCommands], + }, + botPerms, + ], + }, + { + key: 'generalChannelId', + name: 'wb-general', + permissionOverwrites: [ + { + id: everyoneId, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.ReadMessageHistory, + PermissionFlagsBits.UseApplicationCommands, + ], + }, + botPerms, + ], + }, + { + key: 'dungeonChannelId', + name: 'wb-battle', + permissionOverwrites: [ + { + id: everyoneId, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.ReadMessageHistory, + PermissionFlagsBits.UseApplicationCommands, + ], + deny: [PermissionFlagsBits.SendMessages], + }, + botPerms, + ], + }, + { + key: 'marketChannelId', + name: 'wb-market', + permissionOverwrites: [ + { + id: everyoneId, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.ReadMessageHistory, + PermissionFlagsBits.UseApplicationCommands, + ], + deny: [PermissionFlagsBits.SendMessages], + }, + botPerms, + ], + }, + ]; + + // Trouver ou créer la catégorie + let category = guild.channels.cache.find( + (c) => c.type === 4 && c.name === 'WorldBoss', + ); + if (!category) { + category = await guild.channels.create({ + name: 'WorldBoss', + type: 4, + permissionOverwrites: [ + { id: everyoneId, allow: [PermissionFlagsBits.ViewChannel] }, + botPerms, + ], + }); + } + + const result = { categoryId: category.id }; + + for (const { key, name, permissionOverwrites } of channelDefs) { + let ch = guild.channels.cache.find( + (c) => c.parentId === category.id && c.name === name, + ); + if (!ch) { + ch = await guild.channels.create({ + name, + type: 0, + parent: category.id, + permissionOverwrites, + }); + } else { + await ch.permissionOverwrites.set(permissionOverwrites); + } + result[key] = ch.id; + } + + return result; +} + +module.exports = { + ensureGuildInDb, + updateGuildChannels, + removeGuildFromDb, + initGuildChannels, +}; diff --git a/worldboss-app/src/services/infoPanel.service.js b/worldboss-app/src/services/infoPanel.service.js new file mode 100644 index 0000000..831b749 --- /dev/null +++ b/worldboss-app/src/services/infoPanel.service.js @@ -0,0 +1,229 @@ +'use strict'; + +const { + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + StringSelectMenuBuilder, + MessageFlags, +} = require('discord.js'); +const { prisma } = require('../db/prisma'); + +const DISCORD_INVITE = 'https://discord.gg/eS7rAMrehm'; +const BOT_INVITE = 'https://discord.com/oauth2/authorize?client_id=1295750967068856342&permissions=8&integration_type=0&scope=bot'; +const GITHUB_PROFILE_URL = 'https://github.com/RustyRory/'; +const GITHUB_URL = 'https://github.com/RustyRory/WorldBoss/'; + +const SCOPE_OPTIONS = [ + { label: 'Ce serveur — par niveau', value: 'server_level', emoji: '🏰' }, + { label: 'Global — par niveau', value: 'global_level', emoji: '🌍' }, +]; + +async function buildStaticPanel(client, guild) { + const [totalCharacters, guildChannels] = await Promise.all([ + prisma.character.count(), + guild ? prisma.guildChannels.findUnique({ where: { guildId: guild.id } }) : null, + ]); + const totalGuilds = client.guilds.cache.size; + const ping = client.ws.ping; + + const guildId = guild?.id ?? ''; + const generalChannelId = guildChannels?.generalChannelId; + const battleChannelId = guildChannels?.dungeonChannelId; + const marketChannelId = guildChannels?.marketChannelId; + + const channelMention = (id) => id ? `<#${id}>` : '`#wb-general`'; + + const SEP = `\`${'┄'.repeat(32)}\``; + + const embed = new EmbedBuilder() + .setTitle('WorldBoss — RPG Discord') + .setColor(0x5865f2) + .setDescription( + '> Bot Discord RPG coopératif.\n' + + '> Combats en donjon solo, World Boss coopératif et marché entre joueurs.\n' + + SEP, + ) + .addFields( + { + name: '🤖 Statut', + value: [ + '> État : 🟢 En ligne', + `> Ping : **${ping}ms**`, + `> Serveurs : **${totalGuilds}**`, + `> Personnages : **${totalCharacters}**`, + ].join('\n'), + inline: true, + }, + { + name: '🔗 Liens', + value: [ + `> [💬 Discord communautaire](${DISCORD_INVITE})`, + `> [💻 GitHub](${GITHUB_URL})`, + `> `, + `> [Ajouter le bot à votre serveur](${BOT_INVITE})`, + ].join('\n'), + inline: true, + }, + { name: '​', value: SEP, inline: false }, + { + name: '📖 Commandes', + value: [ + `**Dans ${channelMention(generalChannelId)}**`, + '> `/start` — Créer ton personnage sur ce serveur', + '> `/profile` — Voir tes stats et ton équipement', + '> `/inventory` — Gérer ton inventaire (équiper, vendre, utiliser)', + '', + `**Dans ${channelMention(battleChannelId)}**`, + '> `/dungeon` — Lancer un donjon solo', + '', + '**Niveaux requis**', + '> Lvl 1 → Donjon 1 · Lvl 2 → Donjon 2 · Lvl 3 → Donjon 3 + Marché', + ].join('\n'), + inline: false, + }, + { name: '​', value: SEP, inline: false }, + { + name: '💡 Comment commencer ?', + value: [ + `> **1.** Va dans ${channelMention(generalChannelId)} et tape \`/start\``, + '> **2.** Consulte ton profil avec `/profile`', + `> **3.** Lance ton premier donjon dans ${channelMention(battleChannelId)} avec \`/dungeon\``, + '> **4.** Consulte le 📖 **Wiki** pour en savoir plus', + ].join('\n'), + inline: false, + }, + ) + .setFooter({ text: 'Développé par RustyRory', iconURL: 'https://github.com/RustyRory.png' }) + .setTimestamp(); + + // Rangée 1 : navigation vers les channels + const navButtons = []; + if (generalChannelId) { + navButtons.push( + new ButtonBuilder() + .setLabel('💬 Général') + .setURL(`https://discord.com/channels/${guildId}/${generalChannelId}`) + .setStyle(ButtonStyle.Link), + ); + } + if (battleChannelId) { + navButtons.push( + new ButtonBuilder() + .setLabel('⚔️ Battle') + .setURL(`https://discord.com/channels/${guildId}/${battleChannelId}`) + .setStyle(ButtonStyle.Link), + ); + } + if (marketChannelId) { + navButtons.push( + new ButtonBuilder() + .setLabel('🏪 Marché') + .setURL(`https://discord.com/channels/${guildId}/${marketChannelId}`) + .setStyle(ButtonStyle.Link), + ); + } + + // Rangée 2 : classement + wiki + const actionRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('info_leaderboard') + .setLabel('Classement') + .setEmoji('🏆') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('info_wiki') + .setLabel('Wiki') + .setEmoji('📖') + .setStyle(ButtonStyle.Secondary), + ); + + const components = []; + if (navButtons.length > 0) { + components.push(new ActionRowBuilder().addComponents(navButtons)); + } + components.push(actionRow); + + return { embeds: [embed], components }; +} + +async function buildLeaderboardEmbed(guild, scope = 'server_level') { + const [sortScope, sortBy] = scope.split('_'); + const isGlobal = sortScope === 'global'; + + const medals = ['🥇', '🥈', '🥉']; + let leaderboardText; + + if (isGlobal) { + const characters = await prisma.character.findMany({ + orderBy: sortBy === 'level' + ? [{ level: 'desc' }, { xp: 'desc' }] + : { user: { username: 'asc' } }, + take: 100, + select: { level: true, user: { select: { username: true } } }, + }); + + if (characters.length === 0) { + leaderboardText = '*Aucun personnage pour le moment.*'; + } else { + leaderboardText = characters + .map((c, i) => `${medals[i] ?? `**${i + 1}.**`} ${c.user.username} — Lvl **${c.level}**`) + .join('\n'); + } + } else { + const characters = await prisma.character.findMany({ + where: { guildId: guild.id }, + orderBy: sortBy === 'level' + ? [{ level: 'desc' }, { xp: 'desc' }] + : { user: { username: 'asc' } }, + take: 15, + select: { level: true, user: { select: { username: true } } }, + }); + + if (characters.length === 0) { + leaderboardText = '*Aucun personnage sur ce serveur. Utilise `/start` pour commencer !*'; + } else { + leaderboardText = characters + .map((c, i) => `${medals[i] ?? `**${i + 1}.**`} ${c.user.username} — Lvl **${c.level}**`) + .join('\n'); + } + } + + const scopeLabel = SCOPE_OPTIONS.find((o) => o.value === scope)?.label ?? scope; + + const embed = new EmbedBuilder() + .setTitle(`🏆 Classement — ${scopeLabel}${isGlobal ? ' (Top 100)' : ''}`) + .setDescription(leaderboardText.slice(0, 4096)) + .setColor(0xf1c40f) + .setTimestamp(); + + const select = new StringSelectMenuBuilder() + .setCustomId('info_scope') + .setPlaceholder('Changer le filtre…') + .addOptions(SCOPE_OPTIONS.map((o) => ({ ...o, default: o.value === scope }))); + + return { + embeds: [embed], + components: [new ActionRowBuilder().addComponents(select)], + flags: MessageFlags.Ephemeral, + }; +} + +async function refreshInfoPanel(client, guild, channelId) { + const channel = guild.channels.cache.get(channelId); + if (!channel) return; + + const payload = await buildStaticPanel(client, guild); + + const messages = await channel.messages.fetch({ limit: 20 }); + const existing = messages.find((m) => m.author.id === client.user.id && m.embeds.length > 0); + + if (existing) { + await existing.edit(payload); + } else { + await channel.send(payload); + } +} + +module.exports = { refreshInfoPanel, buildLeaderboardEmbed }; diff --git a/worldboss-app/src/services/inventory.service.js b/worldboss-app/src/services/inventory.service.js new file mode 100644 index 0000000..c9ded8d --- /dev/null +++ b/worldboss-app/src/services/inventory.service.js @@ -0,0 +1,225 @@ +'use strict'; + +const { prisma } = require('../db/prisma'); +const { ITEMS } = require('../data/items'); +const { typeToSlot } = require('../utils/stats'); + +async function getInventory(characterId) { + return prisma.characterItem.findMany({ + where: { characterId }, + include: { item: true }, + orderBy: { createdAt: 'asc' }, + }); +} + +async function getOrCreateLoadout(characterId) { + let loadout = await prisma.loadout.findUnique({ where: { characterId } }); + if (!loadout) { + loadout = await prisma.loadout.create({ data: { characterId } }); + } + return loadout; +} + +async function equipItem(characterId, itemId) { + const itemDef = ITEMS[itemId]; + if (!itemDef) return { success: false, message: `Item **${itemId}** inconnu.` }; + if (itemDef.type === 'consumable') return { success: false, message: 'Les consommables ne peuvent pas être équipés.' }; + + const charItem = await prisma.characterItem.findUnique({ + where: { characterId_itemId: { characterId, itemId } }, + }); + if (!charItem || charItem.quantity < 1) { + return { success: false, message: `Vous ne possédez pas **${itemDef.name}**.` }; + } + + const character = await prisma.character.findUnique({ where: { id: characterId }, select: { level: true } }); + if (character.level < itemDef.levelRequired) { + return { + success: false, + message: `Niveau **${itemDef.levelRequired}** requis pour équiper **${itemDef.name}**.`, + }; + } + + const loadout = await getOrCreateLoadout(characterId); + + let slotField = typeToSlot(itemDef.type); + + if (!slotField) { + if (itemDef.type === 'accessory') { + if (!loadout.accessory1Id) { + slotField = 'accessory1Id'; + } else if (!loadout.accessory2Id) { + slotField = 'accessory2Id'; + } else { + slotField = 'accessory1Id'; + } + } else { + return { success: false, message: `Type d'item **${itemDef.type}** non équipable.` }; + } + } + + const updatedLoadout = await prisma.loadout.update({ + where: { characterId }, + data: { [slotField]: itemId }, + }); + + return { success: true, message: `**${itemDef.name}** équipé avec succès !`, loadout: updatedLoadout }; +} + +async function unequipSlot(characterId, slot) { + const VALID_SLOTS = ['weapon', 'armor', 'helmet', 'boots', 'accessory1', 'accessory2']; + if (!VALID_SLOTS.includes(slot)) { + return { success: false, message: `Slot invalide. Utilisez: ${VALID_SLOTS.join(', ')}` }; + } + + const slotField = slot + 'Id'; + const loadout = await getOrCreateLoadout(characterId); + + if (!loadout[slotField]) { + return { success: false, message: `Le slot **${slot}** est déjà vide.` }; + } + + const updatedLoadout = await prisma.loadout.update({ + where: { characterId }, + data: { [slotField]: null }, + }); + + return { success: true, message: `Slot **${slot}** déséquipé.`, loadout: updatedLoadout }; +} + +async function grantItem(characterId, itemId, quantity = 1) { + return prisma.characterItem.upsert({ + where: { characterId_itemId: { characterId, itemId } }, + update: { quantity: { increment: quantity } }, + create: { characterId, itemId, quantity }, + }); +} + +async function sellItem(characterId, itemId) { + const { consumeAP } = require('./actionPoints.service'); + const itemDef = ITEMS[itemId]; + if (!itemDef) return { success: false, message: `Item **${itemId}** inconnu.` }; + + const charItem = await prisma.characterItem.findUnique({ + where: { characterId_itemId: { characterId, itemId } }, + }); + if (!charItem || charItem.quantity < 1) { + return { success: false, message: `Tu ne possèdes pas **${itemDef.name}**.` }; + } + + const loadout = await getOrCreateLoadout(characterId); + const equippedIds = [ + loadout.weaponId, loadout.armorId, loadout.helmetId, + loadout.bootsId, loadout.accessory1Id, loadout.accessory2Id, + ]; + const equippedCount = equippedIds.filter((id) => id === itemId).length; + if (charItem.quantity - equippedCount < 1) { + return { success: false, message: `Déséquipe **${itemDef.name}** avant de le vendre.` }; + } + + const ap = await consumeAP(characterId, 'sell'); + if (!ap.success) { + const minutes = Math.ceil(ap.msUntilNext / 60000); + return { + success: false, + message: `Tu n'as plus de points d'action (**${ap.currentAP}/10** PA).\nProchain PA dans **${minutes} minute${minutes > 1 ? 's' : ''}**.`, + }; + } + + const sellPrice = Math.max(1, Math.floor(itemDef.price * 0.1)); + + await prisma.$transaction([ + charItem.quantity > 1 + ? prisma.characterItem.update({ + where: { characterId_itemId: { characterId, itemId } }, + data: { quantity: { decrement: 1 } }, + }) + : prisma.characterItem.delete({ where: { characterId_itemId: { characterId, itemId } } }), + prisma.character.update({ + where: { id: characterId }, + data: { gold: { increment: sellPrice } }, + }), + ]); + + return { success: true, message: `**${itemDef.name}** vendu pour **${sellPrice}** 🪙`, goldEarned: sellPrice }; +} + +/** + * Use a consumable from inventory outside of combat. + * Only items with usableOutOfCombat: true are allowed. + * Decrements quantity (or deletes) unless item.infiniteUse is true. + */ +async function useConsumable(characterId, itemId) { + const { computeRegenedHp } = require('./player.service'); + const { restoreAP } = require('./actionPoints.service'); + const { computeStats } = require('../utils/stats'); + + const itemDef = ITEMS[itemId]; + if (!itemDef || itemDef.type !== 'consumable') { + return { success: false, message: 'Item non consommable.' }; + } + if (!itemDef.usableOutOfCombat) { + return { success: false, message: `**${itemDef.name}** ne peut être utilisé qu'en combat.` }; + } + + const charItem = await prisma.characterItem.findUnique({ + where: { characterId_itemId: { characterId, itemId } }, + }); + if (!charItem || charItem.quantity < 1) { + return { success: false, message: `Vous ne possédez pas **${itemDef.name}**.` }; + } + + const character = await prisma.character.findUnique({ + where: { id: characterId }, + include: { loadout: true }, + }); + + let message = ''; + const { type, value } = itemDef.effect ?? {}; + + if (type === 'heal') { + const stats = computeStats(character, character.loadout); + const maxHp = stats.hp; + const currentHp = computeRegenedHp(character.hp, character.hpUpdatedAt, maxHp); + if (currentHp >= maxHp) { + return { success: false, message: 'Vos HP sont déjà au maximum !' }; + } + const newHp = Math.min(maxHp, currentHp + value); + const healed = newHp - currentHp; + await prisma.character.update({ + where: { id: characterId }, + data: { hp: newHp, hpUpdatedAt: new Date() }, + }); + message = `💚 +**${healed}** HP — vous avez maintenant **${newHp}/${maxHp}** HP.`; + + } else if (type === 'restore_ap') { + const result = await restoreAP(characterId, value); + if (result.restored === 0) { + return { success: false, message: 'Vos points d\'action sont déjà au maximum !' }; + } + message = `⚡ +**${result.restored}** PA — vous avez maintenant **${result.newAP}/10** PA.`; + + } else if (type === 'cure_dot') { + // Hors combat les DoTs n'existent pas — l'antidote n'a pas d'effet mais se consomme quand même + message = `💊 **${itemDef.name}** utilisé — aucun effet hors combat.`; + + } else { + return { success: false, message: `Effet **${type}** non utilisable hors combat.` }; + } + + // Consume item unless infiniteUse + if (!itemDef.infiniteUse) { + if (charItem.quantity <= 1) { + await prisma.characterItem.delete({ where: { characterId_itemId: { characterId, itemId } } }); + } else { + await prisma.characterItem.update({ + where: { characterId_itemId: { characterId, itemId } }, + data: { quantity: { decrement: 1 } }, + }); + } + } + + return { success: true, message }; +} + +module.exports = { getInventory, getOrCreateLoadout, equipItem, unequipSlot, grantItem, sellItem, useConsumable }; diff --git a/worldboss-app/src/services/market.service.js b/worldboss-app/src/services/market.service.js new file mode 100644 index 0000000..301887b --- /dev/null +++ b/worldboss-app/src/services/market.service.js @@ -0,0 +1,398 @@ +'use strict'; + +const { Queue, Worker } = require('bullmq'); +const { prisma } = require('../db/prisma'); +const { ITEMS } = require('../data/items'); +const { consumeAP } = require('./actionPoints.service'); +const { grantItem } = require('./inventory.service'); + +const MERCHANT_RATE = 0.1; +const MARKET_MIN_LVL = 3; +const MARKET_UNLOCK_DUNGEON = 3; + +async function checkMarketAccess(characterId) { + const [character, completedDungeon] = await Promise.all([ + prisma.character.findUnique({ where: { id: characterId }, select: { level: true } }), + prisma.dungeonRun.findFirst({ where: { characterId, chapter: MARKET_UNLOCK_DUNGEON, status: 'completed' } }), + ]); + const level = character?.level ?? 0; + if (level < MARKET_MIN_LVL) return { ok: false, message: `Le marché est accessible à partir du **niveau ${MARKET_MIN_LVL}**.` }; + if (!completedDungeon) return { ok: false, message: `Le marché est verrouillé. Complétez **Les Routes Infestées** (donjon 3) pour y accéder.` }; + return { ok: true }; +} + +const redisConnection = { + host: process.env.REDIS_HOST ?? 'localhost', + port: parseInt(process.env.REDIS_PORT ?? '6379', 10), + password: process.env.REDIS_PASSWORD ?? undefined, +}; + +const auctionQueue = new Queue('auction-expiry', { connection: redisConnection }); +const auctionRefresh = new Queue('auction-refresh', { connection: redisConnection }); + +let workerStarted = false; + +function startAuctionWorker(discordClient) { + if (workerStarted) return; + workerStarted = true; + + new Worker( + 'auction-expiry', + async (job) => { await finaliseAuction(job.data.listingId, discordClient); }, + { connection: redisConnection }, + ); + + new Worker( + 'auction-refresh', + async (job) => { + const listing = await prisma.marketListing.findUnique({ where: { id: job.data.listingId } }); + if (listing?.status === 'active') await refreshAuctionEmbed(listing, discordClient); + }, + { connection: redisConnection }, + ); +} + +async function getMarketChannel(guildId, discordClient) { + const channels = await prisma.guildChannels.findUnique({ where: { guildId } }); + if (!channels?.marketChannelId) return null; + return discordClient.channels.fetch(channels.marketChannelId).catch(() => null); +} + +function itemEmoji(rarity) { + return { common: '⚪', rare: '🔵', epic: '🟣', legendary: '🟠' }[rarity] ?? '⚪'; +} + +function formatDuration(ms) { + const h = Math.floor(ms / 3_600_000); + const m = Math.floor((ms % 3_600_000) / 60_000); + if (h > 0) return `${h}h${m > 0 ? `${m}m` : ''}`; + return `${m}m`; +} + +// ── Vente marchant ──────────────────────────────────────────────────────────── + +async function sellToMerchant(characterId, itemId, guildId, discordClient) { + const access = await checkMarketAccess(characterId); + if (!access.ok) return { success: false, message: access.message }; + + const itemDef = ITEMS[itemId]; + if (!itemDef) return { success: false, message: `Item **${itemId}** inconnu.` }; + + const charItem = await prisma.characterItem.findUnique({ + where: { characterId_itemId: { characterId, itemId } }, + }); + if (!charItem || charItem.quantity < 1) { + return { success: false, message: `Tu ne possèdes pas **${itemDef.name}**.` }; + } + + const ap = await consumeAP(characterId, 'sell'); + if (!ap.success) { + const minutes = Math.ceil(ap.msUntilNext / 60000); + return { + success: false, + message: `Plus de points d'action (**${ap.currentAP}/10** PA).\nProchain PA dans **${minutes} min**.`, + }; + } + + const goldEarned = Math.max(1, Math.floor(itemDef.price * MERCHANT_RATE)); + + await prisma.$transaction([ + charItem.quantity > 1 + ? prisma.characterItem.update({ + where: { characterId_itemId: { characterId, itemId } }, + data: { quantity: { decrement: 1 } }, + }) + : prisma.characterItem.delete({ where: { characterId_itemId: { characterId, itemId } } }), + prisma.character.update({ where: { id: characterId }, data: { gold: { increment: goldEarned } } }), + ]); + + return { success: true, message: `**${itemDef.name}** vendu pour **${goldEarned}** 🪙`, goldEarned }; +} + +// ── Création d'enchère ──────────────────────────────────────────────────────── + +const DURATIONS_MS = { + '1h': 1 * 3_600_000, + '6h': 6 * 3_600_000, + '24h': 24 * 3_600_000, +}; + +async function createAuction(characterId, itemId, guildId, startPrice, buyoutPrice, durationKey, discordClient) { + const access = await checkMarketAccess(characterId); + if (!access.ok) return { success: false, message: access.message }; + const itemDef = ITEMS[itemId]; + if (!itemDef) return { success: false, message: `Item **${itemId}** inconnu.` }; + + const durationMs = DURATIONS_MS[durationKey]; + if (!durationMs) return { success: false, message: 'Durée invalide.' }; + + const charItem = await prisma.characterItem.findUnique({ + where: { characterId_itemId: { characterId, itemId } }, + }); + if (!charItem || charItem.quantity < 1) { + return { success: false, message: `Tu ne possèdes pas **${itemDef.name}**.` }; + } + + if (charItem.quantity > 1) { + await prisma.characterItem.update({ + where: { characterId_itemId: { characterId, itemId } }, + data: { quantity: { decrement: 1 } }, + }); + } else { + await prisma.characterItem.delete({ where: { characterId_itemId: { characterId, itemId } } }); + } + + const expiresAt = new Date(Date.now() + durationMs); + + const listing = await prisma.marketListing.create({ + data: { guildId, sellerId: characterId, itemId, quantity: 1, type: 'auction', startPrice, buyoutPrice: buyoutPrice ?? null, expiresAt, status: 'active' }, + }); + + const seller = await prisma.character.findUnique({ + where: { id: characterId }, + include: { user: { select: { username: true } } }, + }); + + const channel = await getMarketChannel(guildId, discordClient); + if (channel) { + const { embed, row } = buildAuctionEmbed(listing, itemDef, characterId, seller?.user.username, null); + const msg = await channel.send({ embeds: [embed], components: [row] }); + await prisma.marketListing.update({ where: { id: listing.id }, data: { messageId: msg.id } }); + listing.messageId = msg.id; + } + + await auctionQueue.add('expire', { listingId: listing.id }, { delay: durationMs, jobId: `auction-expire-${listing.id}` }); + + const refreshIntervalMs = durationMs <= 3_600_000 ? 5 * 60_000 + : durationMs <= 6 * 3_600_000 ? 30 * 60_000 + : 60 * 60_000; + + const refreshCount = Math.floor(durationMs / refreshIntervalMs) - 1; + for (let i = 1; i <= refreshCount; i++) { + await auctionRefresh.add('refresh', { listingId: listing.id }, { delay: i * refreshIntervalMs, jobId: `auction-refresh-${listing.id}-${i}` }); + } + + return { success: true, listing }; +} + +// ── Enchère ─────────────────────────────────────────────────────────────────── + +async function placeBid(characterId, listingId, amount, discordClient) { + const access = await checkMarketAccess(characterId); + if (!access.ok) return { success: false, message: access.message }; + + const listing = await prisma.marketListing.findUnique({ where: { id: listingId } }); + if (!listing || listing.status !== 'active') { + return { success: false, message: "Cette enchère n'est plus active." }; + } + if (listing.sellerId === characterId) { + return { success: false, message: 'Tu ne peux pas enchérir sur ton propre item.' }; + } + if (listing.expiresAt && new Date() > listing.expiresAt) { + await finaliseAuction(listingId, discordClient); + return { success: false, message: "L'enchère vient d'expirer." }; + } + + const minBid = (listing.currentBid ?? listing.startPrice - 1) + 1; + if (amount < minBid) { + return { success: false, message: `L'enchère minimale est de **${minBid}** 🪙.` }; + } + + const bidder = await prisma.character.findUnique({ where: { id: characterId }, select: { gold: true } }); + if (!bidder || bidder.gold < amount) { + return { success: false, message: "Tu n'as pas assez d'or." }; + } + + if (listing.bidderId && listing.currentBid) { + await prisma.character.update({ + where: { id: listing.bidderId }, + data: { gold: { increment: listing.currentBid } }, + }); + } + + await prisma.character.update({ where: { id: characterId }, data: { gold: { decrement: amount } } }); + + const updated = await prisma.marketListing.update({ + where: { id: listingId }, + data: { currentBid: amount, bidderId: characterId }, + }); + + await refreshAuctionEmbed(updated, discordClient); + + if (listing.buyoutPrice && amount >= listing.buyoutPrice) { + return finaliseBuyout(characterId, listingId, discordClient, true); + } + + return { success: true, message: `Enchère de **${amount}** 🪙 placée !` }; +} + +// ── Achat direct ────────────────────────────────────────────────────────────── + +async function finaliseBuyout(characterId, listingId, discordClient, fromBid = false) { + if (!fromBid) { + const access = await checkMarketAccess(characterId); + if (!access.ok) return { success: false, message: access.message }; + } + const listing = await prisma.marketListing.findUnique({ where: { id: listingId } }); + if (!listing || listing.status !== 'active') { + return { success: false, message: "Cette enchère n'est plus disponible." }; + } + if (listing.expiresAt && new Date() > listing.expiresAt) { + await finaliseAuction(listingId, discordClient); + return { success: false, message: "L'enchère vient d'expirer." }; + } + if (!listing.buyoutPrice) { + return { success: false, message: "Pas de prix d'achat direct sur cette enchère." }; + } + if (listing.sellerId === characterId) { + return { success: false, message: 'Tu ne peux pas acheter ton propre item.' }; + } + + if (!fromBid) { + const buyer = await prisma.character.findUnique({ where: { id: characterId }, select: { gold: true } }); + if (!buyer || buyer.gold < listing.buyoutPrice) { + return { success: false, message: "Tu n'as pas assez d'or." }; + } + if (listing.bidderId && listing.currentBid) { + await prisma.character.update({ + where: { id: listing.bidderId }, + data: { gold: { increment: listing.currentBid } }, + }); + } + await prisma.character.update({ where: { id: characterId }, data: { gold: { decrement: listing.buyoutPrice } } }); + } + + await transferListing(listing, characterId, listing.buyoutPrice, 'sold', discordClient); + + await consumeAP(listing.sellerId, 'sell').catch(() => {}); + await consumeAP(characterId, 'sell').catch(() => {}); + + return { success: true, message: `Item acheté pour **${listing.buyoutPrice}** 🪙 !` }; +} + +// ── Finalisation enchère expirée ────────────────────────────────────────────── + +async function finaliseAuction(listingId, discordClient) { + const listing = await prisma.marketListing.findUnique({ where: { id: listingId } }); + if (!listing || listing.status !== 'active') return; + + if (listing.bidderId && listing.currentBid) { + await transferListing(listing, listing.bidderId, listing.currentBid, 'sold', discordClient); + await consumeAP(listing.sellerId, 'sell').catch(() => {}); + await consumeAP(listing.bidderId, 'sell').catch(() => {}); + } else { + await grantItem(listing.sellerId, listing.itemId, 1); + await prisma.marketListing.update({ where: { id: listingId }, data: { status: 'expired' } }); + await refreshAuctionEmbed({ ...listing, status: 'expired' }, discordClient); + } +} + +async function transferListing(listing, buyerCharacterId, price, status, discordClient) { + await prisma.$transaction([ + prisma.marketListing.update({ where: { id: listing.id }, data: { status, bidderId: buyerCharacterId } }), + prisma.character.update({ where: { id: listing.sellerId }, data: { gold: { increment: price } } }), + ]); + await grantItem(buyerCharacterId, listing.itemId, 1); + await refreshAuctionEmbed({ ...listing, status, bidderId: buyerCharacterId, currentBid: price }, discordClient); +} + +// ── Embed enchère ───────────────────────────────────────────────────────────── + +function buildAuctionEmbed(listing, itemDef, sellerId, sellerName, bidderName) { + const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); + const SEP = '┄'.repeat(28); + + const isActive = listing.status === 'active'; + const isSold = listing.status === 'sold'; + + const color = isActive ? 0xf39c12 : isSold ? 0x2ecc71 : 0x95a5a6; + + const timeLeft = listing.expiresAt && isActive + ? formatDuration(Math.max(0, new Date(listing.expiresAt).getTime() - Date.now())) + : null; + + const statusLine = isActive + ? `> ⏳ Expire dans **${timeLeft}**` + : isSold + ? `> ✅ Vendu à **${bidderName ?? 'un joueur'}** pour **${listing.currentBid}** 🪙` + : '> ❌ Expirée — aucune enchère'; + + const embed = new EmbedBuilder() + .setTitle(`🔨 Enchère — ${itemEmoji(itemDef.rarity)} ${itemDef.name}`) + .setDescription( + `> Vendeur : **${sellerName ?? sellerId}**\n` + + statusLine + '\n' + + `\`${SEP}\``, + ) + .setColor(color) + .addFields( + { + name: '📦 Item', + value: [`> ${itemEmoji(itemDef.rarity)} **${itemDef.name}**`, `> Type : ${itemDef.type} · Rareté : ${itemDef.rarity}`].join('\n'), + inline: true, + }, + { + name: '💰 Prix', + value: [ + `> Départ : **${listing.startPrice}** 🪙`, + listing.buyoutPrice ? `> Achat direct : **${listing.buyoutPrice}** 🪙` : '> Achat direct : *—*', + `> Enchère actuelle : **${listing.currentBid ?? '—'}** 🪙`, + ].join('\n'), + inline: true, + }, + ) + .setFooter({ text: `WorldBoss Market · Enchère #${listing.id}` }) + .setTimestamp(); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`market_bid:${listing.id}`) + .setLabel('Enchérir') + .setEmoji('📈') + .setStyle(ButtonStyle.Primary) + .setDisabled(!isActive), + new ButtonBuilder() + .setCustomId(`market_buyout:${listing.id}`) + .setLabel('Acheter directement') + .setEmoji('🛒') + .setStyle(ButtonStyle.Success) + .setDisabled(!isActive || !listing.buyoutPrice), + ); + + return { embed, row }; +} + +async function refreshAuctionEmbed(listing, discordClient) { + try { + const channels = await prisma.guildChannels.findUnique({ where: { guildId: listing.guildId } }); + if (!channels?.marketChannelId || !listing.messageId) return; + + const channel = await discordClient.channels.fetch(channels.marketChannelId).catch(() => null); + if (!channel) return; + + const msg = await channel.messages.fetch(listing.messageId).catch(() => null); + if (!msg) return; + + const itemDef = ITEMS[listing.itemId]; + const seller = await prisma.character.findUnique({ + where: { id: listing.sellerId }, + include: { user: { select: { username: true } } }, + }); + const bidder = listing.bidderId + ? await prisma.character.findUnique({ + where: { id: listing.bidderId }, + include: { user: { select: { username: true } } }, + }) + : null; + + const { embed, row } = buildAuctionEmbed( + listing, itemDef, listing.sellerId, + seller?.user.username, bidder?.user.username, + ); + await msg.edit({ embeds: [embed], components: [row] }); + } catch (err) { + console.error('[Market] refreshAuctionEmbed:', err.message); + } +} + +module.exports = { startAuctionWorker, sellToMerchant, createAuction, placeBid, finaliseBuyout, buildAuctionEmbed, DURATIONS_MS }; diff --git a/worldboss-app/src/services/player.service.js b/worldboss-app/src/services/player.service.js new file mode 100644 index 0000000..4c23ceb --- /dev/null +++ b/worldboss-app/src/services/player.service.js @@ -0,0 +1,134 @@ +'use strict'; + +const { prisma } = require('../db/prisma'); +const { xpRequired, baseStats } = require('../utils/stats'); +const { ITEMS } = require('../data/items'); + +async function getCharacter(userId, guildId) { + return prisma.character.findUnique({ + where: { userId_guildId: { userId, guildId } }, + include: { loadout: true }, + }); +} + +async function characterExists(userId, guildId) { + const c = await prisma.character.findUnique({ + where: { userId_guildId: { userId, guildId } }, + select: { id: true }, + }); + return !!c; +} + +/** + * Create a brand-new character for a player on a guild. + * Gives starting items and equips weapon + armor by default. + */ +async function createCharacter(userId, username, guildId) { + const base = baseStats(1); + + await ensureItemsSeeded(); + + return prisma.$transaction(async (tx) => { + // Upsert Discord identity + await tx.user.upsert({ + where: { id: userId }, + update: { username }, + create: { id: userId, username }, + }); + + const character = await tx.character.create({ + data: { + userId, + guildId, + level: 1, + xp: 0, + gold: 0, + hp: base.hp, + }, + }); + + const loadout = await tx.loadout.create({ + data: { characterId: character.id }, + }); + + return { character, loadout }; + }); +} + +/** + * Add XP to a character, handle level-up. + * Returns { newXp, newLevel, leveledUp, levelsGained } + */ +const HP_REGEN_PER_MINUTE = 2; + +/** + * Compute current HP after passive regen (1 HP/min outside combat). + * maxHp should be the character's computed max HP (base + equipment). + */ +function computeRegenedHp(storedHp, hpUpdatedAt, maxHp) { + const minutes = (Date.now() - new Date(hpUpdatedAt).getTime()) / 60000; + const regen = Math.floor(minutes * HP_REGEN_PER_MINUTE); + return Math.min(maxHp, storedHp + regen); +} + +async function addXp(characterId, amount) { + const character = await prisma.character.findUnique({ where: { id: characterId }, include: { loadout: true } }); + if (!character) throw new Error('Character not found'); + + let xp = character.xp + amount; + let level = character.level; + let leveledUp = false; + let levelsGained = 0; + + while (xp >= xpRequired(level)) { + xp -= xpRequired(level); + level += 1; + leveledUp = true; + levelsGained += 1; + } + + const updateData = { xp, level }; + if (leveledUp) { + const { AP_MAX } = require('./actionPoints.service'); + const { computeStats } = require('../utils/stats'); + const maxHp = computeStats({ level }, character.loadout ?? {}).hp; + updateData.hp = maxHp; + updateData.hpUpdatedAt = new Date(); + updateData.actionPoints = AP_MAX; + updateData.actionPointsUpdatedAt = new Date(); + } + + await prisma.character.update({ where: { id: characterId }, data: updateData }); + + return { newXp: xp, newLevel: level, leveledUp, levelsGained }; +} + +async function addGold(characterId, amount) { + return prisma.character.update({ + where: { id: characterId }, + data: { gold: { increment: amount } }, + }); +} + +async function ensureItemsSeeded() { + const ops = Object.values(ITEMS).map((item) => + prisma.item.upsert({ + where: { id: item.id }, + update: {}, + create: { + id: item.id, + name: item.name, + type: item.type, + rarity: item.rarity, + statsJson: item.stats ?? {}, + skillJson: item.skill ?? null, + effectJson: item.effect ?? null, + price: item.price ?? 0, + levelRequired: item.levelRequired ?? 1, + }, + }), + ); + await prisma.$transaction(ops); +} + +module.exports = { getCharacter, characterExists, createCharacter, addXp, addGold, ensureItemsSeeded, computeRegenedHp }; diff --git a/worldboss-app/src/services/wiki.service.js b/worldboss-app/src/services/wiki.service.js new file mode 100644 index 0000000..067eaf7 --- /dev/null +++ b/worldboss-app/src/services/wiki.service.js @@ -0,0 +1,260 @@ +'use strict'; + +const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, MessageFlags } = require('discord.js'); + +const GITHUB_URL = 'https://github.com/RustyRory/WorldBoss/'; + +const WIKI_OPTIONS = [ + { label: 'Gameplay', value: 'gameplay', emoji: '🎮' }, + { label: 'Combat', value: 'combat', emoji: '⚔️' }, + { label: 'Donjons', value: 'donjons', emoji: '🏰' }, + { label: 'Items', value: 'items', emoji: '🎒' }, + { label: 'Skills', value: 'skills', emoji: '🔥' }, + { label: 'Marché', value: 'marche', emoji: '🏪' }, +]; + +function buildSection(section) { + switch (section) { + case 'gameplay': + return { + title: '🎮 Gameplay', + description: [ + '**WorldBoss** est un RPG tour par tour jouable directement sur Discord.', + '', + '**Démarrer**', + '• `/start` — Crée ton personnage sur ce serveur', + '• `/profile` — Affiche tes stats et ton équipement', + '• `/inventory` — Gère ton inventaire', + '• `/dungeon` — Lance un donjon solo', + '', + '**Progression**', + '• Gagne de l\'XP en terminant des combats', + '• Monte de niveau pour débloquer du contenu', + '• Équipe des items pour améliorer tes stats', + '• Lvl 1 → Donjons 1 · Lvl 2 → Donjon 2 · Lvl 3 → Donjon 3 + Marché', + '', + '**Stats de base**', + '• ❤️ **HP** — Points de vie *(120 + 20/niveau)*', + '• ⚔️ **ATK** — Dégâts infligés *(12 + 2/niveau)*', + '• 🛡️ **DEF** — Réduit les dégâts reçus *(6 + 1/niveau)*', + '• 💨 **SPD** — Détermine qui agit en premier *(10 + 1 tous les 5 niveaux)*', + '• 🎯 **CRIT** — Chance de coup critique (×1.5 dégâts par défaut)', + '', + '**Régénération HP**', + '• +2 HP par minute hors combat (automatique)', + '', + '**Points d\'action (PA)**', + '• Maximum : **10 PA** · Recharge : **+1 PA toutes les 2h**', + '• Coûtent 1 PA : vendre un item', + ].join('\n'), + }; + + case 'combat': + return { + title: '⚔️ Combat', + description: [ + '**Formule de dégâts**', + '```', + 'Dégâts = ATK × mult × (100 / (100 + DEF))', + '```', + 'La DEF réduit les dégâts de façon asymptotique (jamais 0).', + '', + '**Initiative**', + '• Calculée à partir du SPD + léger RNG (±10%)', + '• Le joueur le plus rapide agit en premier', + '• Un ennemi **étourdi** passe son tour (initiative = 0)', + '', + '**Actions disponibles**', + '• ⚔️ **Attaquer** — Dégâts ×1.0, déclenche tes passifs', + '• 🔥 **Skill** — Multiplicateur plus élevé + effet spécial (cooldown)', + '• 🎒 **Consommable** — Sélectionne un item dans ton sac', + '• 🏃 **Fuir** — 40–70% de réussite selon ton SPD', + '', + '**Effets de statut**', + '• 🔥 **Brûlure / Poison / Saignement** — X dégâts/tour pendant N tours', + '• 💫 **Stun** — L\'ennemi passe son prochain tour', + '• ⚡ **Buff** — Bonus de stat temporaire (se décrémente chaque tour)', + '', + '**IA ennemie**', + '• 1/3 Attaque · 1/3 Ability spéciale · 1/3 Repos (+15% HP max)', + '', + '**Alliés NPC**', + '• Agissent après toi et les ennemis', + '• 2/3 Attaque sur ennemi aléatoire · 1/3 Repos', + ].join('\n'), + }; + + case 'donjons': + return { + title: '🏰 Donjons', + description: [ + '**Accès** via `/dungeon` dans `wb-donjon`.', + '', + '**Donjon 1 — Les Catacombes** *(Lvl 1)*', + '• Salle 1 : Squelette', + '• Salle 2 : Archer Squelette', + '• Salle 3 : Squelette + Archer Squelette', + '', + '**Donjon 2 — Les Catacombes Pt.2** *(Lvl 2)*', + '• Salle 1 : Squelette + Archer', + '• Salle 2 : Squelette Mage', + '• Salle 3 : Squelette + Squelette Mage', + '', + '**Donjon 3 — Les Catacombes Pt.3** *(Lvl 3)*', + '• Salle 1 : Squelette + Squelette Mage', + '• Salle 2 : Squelette + Archer Squelette', + '• Salle 3 : Squelette + Mage + Archer + Chevalier + **Aldric** 🧑‍💼', + '• 🏪 Complète ce donjon pour débloquer le **marché** !', + '', + '**Entre les salles**', + '• +15% HP max récupérés automatiquement (min 5 HP)', + '• Level up pendant une salle → HP restaurés à 100%', + '', + '**Loot**', + '• Chaque ennemi contribue un candidat à la pool', + '• Un seul item tiré au sort à la fin du donjon', + '• Plus d\'ennemis = pool plus diversifiée', + '', + '**Mort / Fuite**', + '• HP remis à 1, progression perdue', + ].join('\n'), + }; + + case 'items': + return { + title: '🎒 Items', + description: [ + '**Slots d\'équipement** (6 au total)', + '• ⚔️ **Arme** — ATK · skill actif · passif offensif', + '• 🧥 **Armure** — HP · DEF · passif défensif', + '• 🪖 **Casque** — DEF · ATK · CRIT', + '• 👢 **Bottes** — SPD · DEF', + '• 💍 **Accessoire ×2** — HP · CRIT · skill ou passif', + '', + '**Raretés**', + '• ⚪ **Commun** — Stats simples', + '• 🔵 **Rare** — Stats + passif ou skill de base', + '• 🟣 **Épique** — Stats solides + skill offensif', + '• 🟠 **Légendaire** — Stats hautes + skill + passif unique', + '', + '**Skills & Passifs via équipement**', + '• Un item peut donner un **skill actif** (cooldown en tours)', + '• Un item peut donner un **passif** (déclenché sur chaque attaque)', + '• Tu peux cumuler skills et passifs de plusieurs items équipés', + '', + '**Consommables** *(utilisables en combat via le menu)*', + '• 🧪 Potion de soin — +30 HP', + '• 💣 Bombe — 25 dégâts AoE', + '• ❄️ Bombe fumigène — Stun 1 tour', + '• ☠️ Antidote — Supprime tous tes DoTs', + '• 📜 Parchemin de feu — 40 dégâts feu', + '• ⚡ Élixir berserk — ATK +15 pendant 3 tours', + '• 🛡️ Élixir de fer — DEF +10 pendant 3 tours', + '', + '**Commandes**', + '• `/inventory` — Voir, équiper, utiliser, vendre', + ].join('\n'), + }; + + case 'skills': + return { + title: '🔥 Skills & Passifs', + description: [ + '**Skills offensifs** *(via arme ou accessoire)*', + '• 🔥 **Firebolt** — ×1.8 · CD 2 tours', + '• 🦴 **Bone Bolt** — ×1.5 + DoT os · CD 3 tours', + '• 🌑 **Shadow Burst** — ×2.2 · CD 3 tours', + '• 🧊 **Ice Lance** — ×1.6 + Stun · CD 3 tours', + '• ⚡ **Thunder Bolt** — ×1.7 · CD 3 tours', + '• 🩸 **Soul Rend** — ×1.5 + Saignement · CD 3 tours', + '• 💥 **Power Slash** — ×2.0 · CD 3 tours', + '• 👑 **Royal Smite** — ×2.2 · CD 3 tours', + '• 😈 **Hellstrike** — ×2.5 + DoT feu · CD 4 tours', + '• 🌋 **Inferno Blast** — ×2.0 · CD 4 tours', + '• 💀 **Soul Drain** — ×1.8 + Soin 30% dégâts · CD 3 tours', + '• 🌪️ **Sand Storm** — ×1.4 + DEF ennemi −5 · CD 3 tours', + '', + '**Skills défensifs / support**', + '• 💚 **Soin** — +20 HP · 1 fois/combat', + '• ✨ **Divine Heal** — +50 HP · 1 fois/combat', + '• 🌬️ **Second Wind** — +25% HP max · CD 5 tours', + '• 🛡️ **Iron Skin** — DEF +15 pour 4 tours · CD 4 tours', + '• 🔰 **Barrier** — DEF +30 pour 3 tours · CD 5 tours', + '• 📣 **Battle Cry** — ATK +10 pour 3 tours · CD 4 tours', + '• 💨 **Quicken** — SPD +8 pour 3 tours · CD 4 tours', + '• ☀️ **Resurrection** — Soin 30% HP · 1 fois/combat', + '', + '**Passifs** *(déclenchés sur chaque attaque basique)*', + '• 🔥 **Brûlure** — 2 dégâts/tour × 2 tours', + '• ☠️ **Poison** — 1 dégât/tour × 3 tours', + '• 🩸 **Saignement** — 3 dégâts/tour × 3 tours', + '• 💀 **Coup maudit** — DEF ennemi −2', + '• 💧 **Régénération** — +3 HP', + '• 🧛 **Vol de vie** — +5 HP', + '• ❄️ **Bouclier de givre** — DEF +3 pour 2 tours', + ].join('\n'), + }; + + case 'marche': + return { + title: '🏪 Marché', + description: [ + '**Condition d\'accès**', + '• Être niveau **3** minimum', + '• Avoir terminé **Les Catacombes Pt.3** (Donjon 3)', + '', + '**Vente au marchant**', + '• Via `/inventory` → menu "Vendre"', + '• Prix : **10%** de la valeur de l\'item', + '• Coûte **1 PA**', + '• Un item équipé peut être vendu si tu en as un exemplaire en double', + '', + '**Enchères**', + '• Met un item en vente avec un prix de départ et un prix d\'achat direct', + '• Durées : 1h · 6h · 24h', + '• Les autres joueurs du serveur peuvent enchérir', + '• Si personne n\'enchérit → item retourné au vendeur', + '', + '**Points d\'action (PA)**', + '• Maximum **10 PA** · Recharge **+1 PA toutes les 2h**', + '• Chaque vente coûte 1 PA', + ].join('\n'), + }; + + default: + return { title: 'Wiki', description: 'Section inconnue.' }; + } +} + +async function buildWikiEmbed(guild, section = 'gameplay') { + const { title, description } = buildSection(section); + + const embed = new EmbedBuilder() + .setTitle(`📖 Wiki — ${title}`) + .setDescription(description) + .setColor(0x2ecc71) + .setFooter({ text: 'Utilisez le menu pour naviguer entre les sections' }) + .setTimestamp(); + + const select = new StringSelectMenuBuilder() + .setCustomId('wiki_section') + .setPlaceholder('Choisir une section…') + .addOptions(WIKI_OPTIONS.map((o) => ({ ...o, default: o.value === section }))); + + const githubButton = new ButtonBuilder() + .setLabel('Code source') + .setEmoji('💻') + .setURL(GITHUB_URL) + .setStyle(ButtonStyle.Link); + + return { + embeds: [embed], + components: [ + new ActionRowBuilder().addComponents(select), + new ActionRowBuilder().addComponents(githubButton), + ], + flags: MessageFlags.Ephemeral, + }; +} + +module.exports = { buildWikiEmbed }; diff --git a/worldboss-app/src/utils/embed.js b/worldboss-app/src/utils/embed.js new file mode 100644 index 0000000..6db71a0 --- /dev/null +++ b/worldboss-app/src/utils/embed.js @@ -0,0 +1,602 @@ +'use strict'; + +const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } = require('discord.js'); +const { ITEMS } = require('../data/items'); + +const RARITY_COLOR = { + common: 0x9e9e9e, + rare: 0x2196f3, + epic: 0x9c27b0, + legendary: 0xff9800, +}; + +/** + * ASCII HP bar. + * @param {number} current + * @param {number} max + * @param {number} size - number of blocks + */ +function hpBar(current, max, size = 10) { + const clamped = Math.max(0, Math.min(current, max)); + const filled = Math.round((clamped / max) * size); + const empty = size - filled; + return `${'█'.repeat(filled)}${'░'.repeat(empty)} ${clamped}/${max} HP`; +} + +/** + * Build the combat embed (supports multiple enemies via state.enemies array). + */ +function buildCombatEmbed(state) { + const { player, enemies, log, turn } = state; + + const lines = []; + + // ── Player ──────────────────────────────────────────────────────────────── + lines.push(`**🧑 Vous** — Tour **${turn}**`); + lines.push(`\`${hpBar(player.hp, player.maxHp)}\``); + + // Player status effects + const playerStatus = []; + for (const dot of (player.dots ?? [])) { + playerStatus.push(`☠️ ${dot.label ?? 'DoT'} (${dot.turns}t)`); + } + for (const buf of (player.buffs ?? [])) { + playerStatus.push(`⚡ ${buf.stat.toUpperCase()}+${buf.value} (${buf.turns}t)`); + } + if (playerStatus.length > 0) lines.push(`-# ${playerStatus.join(' · ')}`); + + // Skill cooldowns + const cdLines = Object.entries(player.skillCooldowns ?? {}) + .filter(([, cd]) => cd > 0) + .map(([key, cd]) => { + const sk = (player.activeSkills ?? []).find((s) => s.key === key); + return `⏳ ${sk?.name ?? key} (${cd}t)`; + }); + if (cdLines.length > 0) lines.push(`-# ${cdLines.join(' · ')}`); + + // ── Allies ─────────────────────────────────────────────────────────────── + for (const ally of (state.allies ?? [])) { + if (ally.hp <= 0) { + lines.push(`~~**${ally.emoji ?? '🧑‍💼'} ${ally.name}**~~ — *K.O.*`); + } else { + lines.push(`**${ally.emoji ?? '🧑‍💼'} ${ally.name}** *(allié)*`); + lines.push(`\`${hpBar(ally.hp, ally.maxHp)}\``); + } + } + + lines.push(''); + + // ── Enemies ─────────────────────────────────────────────────────────────── + for (const enemy of enemies) { + if (enemy.hp <= 0) { + lines.push(`~~**☠️ ${enemy.name}**~~ — *Vaincu*`); + } else { + lines.push(`**💀 ${enemy.name}**`); + lines.push(`\`${hpBar(enemy.hp, enemy.maxHp)}\``); + const enemyStatus = []; + if (enemy.stunned) enemyStatus.push('💫 Étourdi'); + for (const dot of (enemy.dots ?? [])) { + enemyStatus.push(`🔥 ${dot.label ?? 'DoT'} (${dot.turns}t)`); + } + if (enemyStatus.length > 0) lines.push(`-# ${enemyStatus.join(' · ')}`); + } + } + + lines.push(''); + + // ── Journal ─────────────────────────────────────────────────────────────── + const lastLogs = (log || []).slice(-4); + if (lastLogs.length > 0) { + lines.push('**📜 Journal**'); + lines.push(lastLogs.map((l) => `> ${l}`).join('\n')); + } + + const footerParts = []; + if (state.currentRoom && state.totalRooms) footerParts.push(`Salle ${state.currentRoom}/${state.totalRooms}`); + + const embed = new EmbedBuilder() + .setColor(player.hp / player.maxHp < 0.3 ? 0xe74c3c : 0x8e44ad) + .setDescription(lines.join('\n')); + + if (footerParts.length > 0) embed.setFooter({ text: footerParts.join(' · ') }); + + return embed; +} + +/** + * Build combat action rows. + * Single enemy → one row (attack, skill, potion, flee). + * Multiple enemies → attack row per target, then skill row per target, then utils row. + * Returns an array of ActionRowBuilder. + */ +function buildCombatRow(state) { + const { player, enemies } = state; + const skills = player.activeSkills ?? []; + const cooldowns = player.skillCooldowns ?? {}; + const usedOnce = player.usedOnceSkills ?? []; + const aliveEnemies = enemies.filter((e) => e.hp > 0); + + // Build consumable select menu + const availableConsumables = (player.consumables ?? []).filter((c) => { + const def = ITEMS[c.itemId]; + if (!def?.effect) return false; + if (def.effect.type === 'restore_ap') return false; // not applicable in combat + return c.quantity > 0 || c.quantity === -1; + }); + + const consumableRow = availableConsumables.length > 0 + ? new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('combat_consumable') + .setPlaceholder('🎒 Utiliser un consommable…') + .addOptions(availableConsumables.map((c) => { + const def = ITEMS[c.itemId]; + const qty = c.quantity === -1 ? '∞' : `×${c.quantity}`; + return { label: `${def.name} (${qty})`, value: c.itemId }; + })), + ) + : null; + + const fleeRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('combat_flee') + .setLabel('Fuir') + .setEmoji('🏃') + .setStyle(ButtonStyle.Secondary), + ); + + const targetIdx = enemies.findIndex((e) => e.hp > 0); + const targetIdx0 = targetIdx >= 0 ? targetIdx : 0; + + if (aliveEnemies.length <= 1) { + const attackRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`combat_attack:${targetIdx0}`) + .setLabel('Attaquer') + .setEmoji('⚔️') + .setStyle(ButtonStyle.Danger), + ...skills.map((sk) => { + const cd = cooldowns[sk.key] ?? 0; + const blocked = cd > 0 || (sk.oncePerCombat && usedOnce.includes(sk.key)); + const label = cd > 0 ? `${sk.name} (${cd}t)` : sk.name; + return new ButtonBuilder() + .setCustomId(`combat_skill_${sk.key}:${targetIdx0}`) + .setLabel(label) + .setEmoji('🔥') + .setStyle(ButtonStyle.Primary) + .setDisabled(blocked); + }), + new ButtonBuilder() + .setCustomId('combat_flee') + .setLabel('Fuir') + .setEmoji('🏃') + .setStyle(ButtonStyle.Secondary), + ); + const rows = [attackRow]; + if (consumableRow) rows.push(consumableRow); + return rows; + } + + // Multiple enemies — attack row, one skill row per skill, consumable select, flee + const attackRow = new ActionRowBuilder().addComponents( + ...aliveEnemies.map((e) => { + const idx = enemies.indexOf(e); + return new ButtonBuilder() + .setCustomId(`combat_attack:${idx}`) + .setLabel(`⚔️ ${e.name}`) + .setStyle(ButtonStyle.Danger); + }), + ); + + const rows = [attackRow]; + + for (const sk of skills) { + const cd = cooldowns[sk.key] ?? 0; + const blocked = cd > 0 || (sk.oncePerCombat && usedOnce.includes(sk.key)); + const label = cd > 0 ? `${sk.name} (${cd}t)` : sk.name; + const skillRow = new ActionRowBuilder().addComponents( + ...aliveEnemies.map((e) => { + const idx = enemies.indexOf(e); + return new ButtonBuilder() + .setCustomId(`combat_skill_${sk.key}:${idx}`) + .setLabel(`🔥 ${label} → ${e.name}`) + .setStyle(ButtonStyle.Primary) + .setDisabled(blocked); + }), + ); + rows.push(skillRow); + } + + if (consumableRow) rows.push(consumableRow); + rows.push(fleeRow); + return rows; +} + +/** + * Build the "next room" button row used between dungeon rooms. + */ +function buildDungeonNextRow(label = 'Salle suivante ➡️') { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('dungeon_next') + .setLabel(label) + .setStyle(ButtonStyle.Success), + ); +} + +/** + * Build loot selection embed + buttons after combat victory. + */ +function buildLootEmbed(lootOptions) { + + const desc = lootOptions + .map((id, i) => { + const item = ITEMS[id]; + if (!item) return `${i + 1}. Item inconnu`; + const emoji = { common: '⚪', rare: '🔵', epic: '🟣', legendary: '🟠' }[item.rarity] ?? '⚪'; + return `**${i + 1}.** ${emoji} **${item.name}** (${item.rarity})`; + }) + .join('\n'); + + const embed = new EmbedBuilder() + .setTitle('🎁 Butin — Choisissez un item') + .setDescription(desc) + .setColor(0xf39c12); + + const row = new ActionRowBuilder().addComponents( + ...lootOptions.map((_, i) => + new ButtonBuilder() + .setCustomId(`loot_${i}`) + .setLabel(`Choix ${i + 1}`) + .setStyle(ButtonStyle.Primary), + ), + ); + + return { embed, row }; +} + +/** + * XP progress bar. + */ +function xpBar(current, required, size = 12) { + const filled = Math.round((current / required) * size); + const empty = size - filled; + return `${'▰'.repeat(filled)}${'▱'.repeat(empty)} ${current}/${required}`; +} + +const SEP = '┄'.repeat(32); + +/** + * Build sell-choice buttons (marchant / enchère) for an item. + */ +function buildSellChoiceRow(itemId) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`market_merchant:${itemId}`) + .setLabel('Vendre au marchant') + .setEmoji('🏪') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`market_auction_start:${itemId}`) + .setLabel('Mettre aux enchères') + .setEmoji('🔨') + .setStyle(ButtonStyle.Primary), + ); +} + +/** + * Profile embed. + */ +function buildProfileEmbed(user, stats, loadout, xpReq, interaction = null, ap = null) { + + const SLOTS = [ + { field: 'weaponId', icon: '⚔️', label: 'Arme' }, + { field: 'armorId', icon: '🧥', label: 'Armure' }, + { field: 'helmetId', icon: '🪖', label: 'Casque' }, + { field: 'bootsId', icon: '👢', label: 'Bottes' }, + { field: 'accessory1Id', icon: '💍', label: 'Accessoire 1' }, + { field: 'accessory2Id', icon: '💍', label: 'Accessoire 2' }, + ]; + + const equippedLines = SLOTS.map(({ field, icon, label }) => { + const item = loadout?.[field] ? ITEMS[loadout[field]] : null; + const rarityEmoji = item ? ({ common: '⚪', rare: '🔵', epic: '🟣', legendary: '🟠' }[item.rarity] ?? '⚪') : null; + return item + ? `> ${icon} **${label}** · ${rarityEmoji} ${item.name}` + : `> ${icon} **${label}** · *—*`; + }); + + const hpPct = Math.round((user.hp / stats.hp) * 100); + const hpIcon = hpPct > 50 ? '🟩' : hpPct > 25 ? '🟨' : '🟥'; + + const embed = new EmbedBuilder() + .setTitle(`📋 Profil — ${user.username}`) + .setDescription( + `> Aventurier de niveau **${user.level}**, continuez à explorer les donjons !\n` + + `\`${SEP}\``, + ) + .setColor(0x3498db) + .setThumbnail(interaction?.user?.displayAvatarURL({ size: 128 }) ?? null) + .addFields( + // ── Progression ─────────────────────────────────────────────── + { + name: '🏅 Progression', + value: [ + `> Niveau **${user.level}**`, + `> XP \`${xpBar(user.xp, xpReq)}\``, + `> Or **${user.gold}** 🪙`, + ap != null + ? `> PA **${ap.current}** / ${ap.max} ⚡` + : '', + ].filter(Boolean).join('\n'), + inline: true, + }, + // ── Statistiques ────────────────────────────────────────────── + { + name: '📊 Statistiques', + value: [ + `> ${hpIcon} HP **${user.hp}** / ${stats.hp}`, + `> ⚔️ ATK **${stats.atk}**`, + `> 🛡️ DEF **${stats.def}**`, + `> 💨 SPD **${stats.spd}**`, + `> 🎯 CRIT **${stats.crit}%**`, + ].join('\n'), + inline: true, + }, + // ── Séparateur ──────────────────────────────────────────────── + { + name: '​', + value: `\`${SEP}\``, + inline: false, + }, + // ── Équipement ──────────────────────────────────────────────── + { + name: '🛡️ Équipement', + value: equippedLines.join('\n'), + inline: false, + }, + ) + .setFooter({ text: 'WorldBoss • /inventory pour gérer ton équipement' }) + .setTimestamp(); + + return embed; +} + +/** + * Inventory embed + action rows (équiper / déséquiper). + * Retourne { embed, rows } + */ +function buildInventoryMessage(user, userItems, loadout, ap = null) { + + const RARITY_EMOJI = { common: '⚪', rare: '🔵', epic: '🟣', legendary: '🟠' }; + const RARITY_LABEL = { common: 'Commun', rare: 'Rare', epic: 'Épique', legendary: 'Légendaire' }; + const TYPE_ICON = { weapon: '⚔️', armor: '🧥', helmet: '🪖', boots: '👢', accessory: '💍', consumable: '🧪' }; + const TYPE_LABEL = { weapon: 'Armes', armor: 'Armures', helmet: 'Casques', boots: 'Bottes', accessory: 'Accessoires', consumable: 'Consommables' }; + + const equippedIds = new Set( + [loadout?.weaponId, loadout?.armorId, loadout?.helmetId, loadout?.bootsId, loadout?.accessory1Id, loadout?.accessory2Id].filter(Boolean), + ); + + const groups = {}; + for (const ui of userItems) { + const item = ITEMS[ui.itemId]; + if (!item) continue; + if (!groups[item.type]) groups[item.type] = []; + const rarity = RARITY_EMOJI[item.rarity] ?? '⚪'; + const qty = ui.quantity > 1 ? ` ×${ui.quantity}` : ''; + const statsStr = item.stats + ? Object.entries(item.stats).map(([k, v]) => `\`${v > 0 ? '+' : ''}${v} ${k.toUpperCase()}\``).join(' ') + : ''; + const equipped = equippedIds.has(ui.itemId) ? ' · ✅ **équipé**' : ''; + groups[item.type].push( + `> ${rarity} **${item.name}**${qty}${statsStr ? ` · ${statsStr}` : ''}${equipped}`, + ); + } + + const TYPE_ORDER = ['weapon', 'armor', 'helmet', 'boots', 'accessory', 'consumable']; + + const fields = []; + const orderedTypes = TYPE_ORDER.filter((t) => groups[t]); + + if (orderedTypes.length === 0) { + // inventaire vide — géré via description + } else { + for (let i = 0; i < orderedTypes.length; i++) { + const type = orderedTypes[i]; + fields.push({ + name: `${TYPE_ICON[type] ?? '📦'} ${TYPE_LABEL[type] ?? type}`, + value: groups[type].join('\n'), + inline: false, + }); + // séparateur entre sections sauf après la dernière + if (i < orderedTypes.length - 1) { + fields.push({ name: '​', value: `\`${SEP}\``, inline: false }); + } + } + } + + const totalItems = userItems.reduce((acc, ui) => acc + ui.quantity, 0); + + const embed = new EmbedBuilder() + .setTitle(`🎒 Inventaire — ${user.username}`) + .setDescription( + `> **${totalItems}** objet${totalItems > 1 ? 's' : ''} · Or **${user.gold}** 🪙` + + (ap != null ? ` · PA **${ap.current}** / ${ap.max} ⚡` : '') + '\n' + + `\`${SEP}\``, + ) + .setColor(0x27ae60) + .setTimestamp() + .setFooter({ text: 'WorldBoss • Utilise le menu ci-dessous pour équiper un item' }); + + if (fields.length) { + embed.addFields(fields); + } else { + embed.setDescription( + '> Ton inventaire est vide.\n' + + '> Bats des ennemis pour obtenir du butin !\n' + + `\`${SEP}\``, + ); + } + + // ── Select menu équiper ──────────────────────────────────────────────────── + const equipable = userItems.filter((ui) => { + const item = ITEMS[ui.itemId]; + return item && item.type !== 'consumable'; + }); + + const rows = []; + + if (equipable.length > 0) { + const { StringSelectMenuBuilder } = require('discord.js'); + + const equipOptions = equipable.map((ui) => { + const item = ITEMS[ui.itemId]; + const rarity = RARITY_EMOJI[item.rarity] ?? '⚪'; + const statsStr = item.stats + ? Object.entries(item.stats).map(([k, v]) => `${v > 0 ? '+' : ''}${v} ${k.toUpperCase()}`).join(', ') + : ''; + const tag = equippedIds.has(ui.itemId) ? ' ✅' : ''; + return { + label: `${rarity} ${item.name}${tag}`.slice(0, 100), + description: `${TYPE_LABEL[item.type] ?? item.type} · ${RARITY_LABEL[item.rarity] ?? item.rarity}${statsStr ? ` · ${statsStr}` : ''}`.slice(0, 100), + value: `equip:${ui.itemId}`, + }; + }); + + rows.push( + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('inventory_action') + .setPlaceholder('⚔️ Sélectionne un item à équiper…') + .addOptions(equipOptions.slice(0, 25)), + ), + ); + } + + // ── Select menu utiliser (consommables hors combat) ────────────────────── + const usable = userItems.filter((ui) => { + const item = ITEMS[ui.itemId]; + return item && item.type === 'consumable' && item.usableOutOfCombat && ui.quantity > 0; + }); + + if (usable.length > 0) { + const { StringSelectMenuBuilder } = require('discord.js'); + const useOptions = usable.map((ui) => { + const item = ITEMS[ui.itemId]; + const rarity = RARITY_EMOJI[item.rarity] ?? '⚪'; + const qty = ui.quantity > 1 ? ` ×${ui.quantity}` : ''; + const effectLabel = { + heal: `Restaure ${item.effect?.value ?? '?'} HP`, + restore_ap: `Restaure ${item.effect?.value ?? '?'} PA`, + }[item.effect?.type] ?? item.effect?.type ?? ''; + return { + label: `${rarity} ${item.name}${qty}`.slice(0, 100), + description: effectLabel.slice(0, 100), + value: `use:${ui.itemId}`, + }; + }); + rows.push( + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('inventory_use') + .setPlaceholder('🧪 Utiliser un consommable…') + .addOptions(useOptions.slice(0, 25)), + ), + ); + } + + // ── Select menu vendre ──────────────────────────────────────────────────── + const equippedList = [loadout?.weaponId, loadout?.armorId, loadout?.helmetId, loadout?.bootsId, loadout?.accessory1Id, loadout?.accessory2Id].filter(Boolean); + + const sellable = userItems.filter((ui) => { + const item = ITEMS[ui.itemId]; + if (!item) return false; + const equippedCount = equippedList.filter((id) => id === ui.itemId).length; + return ui.quantity - equippedCount >= 1; + }); + + if (sellable.length > 0) { + const { StringSelectMenuBuilder } = require('discord.js'); + + const sellOptions = sellable.map((ui) => { + const item = ITEMS[ui.itemId]; + const rarity = RARITY_EMOJI[item.rarity] ?? '⚪'; + const sellPrice = Math.max(1, Math.floor(item.price * 0.1)); + const equippedCount = equippedList.filter((id) => id === ui.itemId).length; + const sellableQty = ui.quantity - equippedCount; + const qty = sellableQty > 1 ? ` ×${sellableQty}` : ''; + return { + label: `${rarity} ${item.name}${qty}`.slice(0, 100), + description: `Marchant: ${sellPrice} 🪙 · Enchère possible · 1 PA requis`.slice(0, 100), + value: `sell_choice:${ui.itemId}`, + }; + }); + + rows.push( + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('inventory_sell_choice') + .setPlaceholder('🪙 Sélectionne un item à vendre…') + .addOptions(sellOptions.slice(0, 25)), + ), + ); + } + + // ── Boutons déséquiper ───────────────────────────────────────────────────── + const occupiedSlots = [ + { field: 'weaponId', label: 'Arme', slot: 'weapon' }, + { field: 'armorId', label: 'Armure', slot: 'armor' }, + { field: 'helmetId', label: 'Casque', slot: 'helmet' }, + { field: 'bootsId', label: 'Bottes', slot: 'boots' }, + { field: 'accessory1Id', label: 'Accessoire 1', slot: 'accessory1' }, + { field: 'accessory2Id', label: 'Accessoire 2', slot: 'accessory2' }, + ].filter((s) => loadout?.[s.field]); + + if (occupiedSlots.length > 0) { + const unequipButtons = occupiedSlots.map((s) => + new ButtonBuilder() + .setCustomId(`unequip:${s.slot}`) + .setLabel(`↩ ${s.label}`) + .setStyle(ButtonStyle.Secondary), + ); + for (let i = 0; i < unequipButtons.length; i += 5) { + rows.push(new ActionRowBuilder().addComponents(unequipButtons.slice(i, i + 5))); + } + } + + return { embed, rows }; +} + +/** @deprecated Utiliser buildInventoryMessage */ +function buildInventoryEmbed(user, userItems, loadout) { + return buildInventoryMessage(user, userItems, loadout).embed; +} + +/** + * Generic error embed. + */ +function errorEmbed(message) { + return new EmbedBuilder().setColor(0xe74c3c).setDescription(`❌ ${message}`); +} + +/** + * Generic success embed. + */ +function successEmbed(message) { + return new EmbedBuilder().setColor(0x2ecc71).setDescription(`✅ ${message}`); +} + +module.exports = { + hpBar, + buildCombatEmbed, + buildCombatRow, + buildDungeonNextRow, + buildLootEmbed, + buildProfileEmbed, + buildInventoryMessage, + buildInventoryEmbed, + buildSellChoiceRow, + errorEmbed, + successEmbed, + RARITY_COLOR, +}; diff --git a/worldboss-app/src/utils/stats.js b/worldboss-app/src/utils/stats.js new file mode 100644 index 0000000..38c1077 --- /dev/null +++ b/worldboss-app/src/utils/stats.js @@ -0,0 +1,129 @@ +'use strict'; + +const { ITEMS } = require('../data/items'); +const { SKILLS } = require('../data/skills'); + +/** + * Calculate base stats for a player at the given level. + */ +function baseStats(level) { + return { + hp: 100 + 20 * level, + atk: 10 + 2 * level, + def: 5 + 1 * level, + spd: 10 + Math.floor(level / 5), + crit: 0, + critMult: 1.5, + }; +} + +/** + * Calculate XP required to reach the next level from current level. + */ +function xpRequired(level) { + return Math.floor(100 * level * 1.5); +} + +/** + * Get all stat bonuses from a loadout record. + * @param {object} loadout - Prisma Loadout row (fields: weaponId, armorId, helmetId, bootsId, accessory1Id, accessory2Id) + */ +function loadoutStats(loadout) { + if (!loadout) return { hp: 0, atk: 0, def: 0, spd: 0, crit: 0 }; + + const slots = [ + loadout.weaponId, + loadout.armorId, + loadout.helmetId, + loadout.bootsId, + loadout.accessory1Id, + loadout.accessory2Id, + ].filter(Boolean); + + const bonus = { hp: 0, atk: 0, def: 0, spd: 0, crit: 0 }; + + for (const itemId of slots) { + const item = ITEMS[itemId]; + if (!item || !item.stats) continue; + for (const [stat, val] of Object.entries(item.stats)) { + if (stat in bonus) bonus[stat] += val; + } + } + + return bonus; +} + +/** + * Return complete computed stats for a player (base + equipment). + * Also returns the equipped skill (from weapon) if any. + */ +function computeStats(user, loadout) { + const base = baseStats(user.level); + const bonus = loadoutStats(loadout); + + const total = { + hp: base.hp + bonus.hp, + atk: base.atk + (bonus.atk || 0), + def: base.def + (bonus.def || 0), + spd: base.spd + (bonus.spd || 0), + crit: base.crit + (bonus.crit || 0), + critMult: base.critMult, + }; + + // Collect all skills and passives from every equipped slot + const allSlots = [ + loadout?.weaponId, + loadout?.armorId, + loadout?.helmetId, + loadout?.bootsId, + loadout?.accessory1Id, + loadout?.accessory2Id, + ].filter(Boolean); + + const activeSkills = []; // { key, ...skillDef } + const activePassives = []; // passive keys (strings) + + for (const slotId of allSlots) { + const item = ITEMS[slotId]; + if (!item) continue; + if (item.skill) { + const sk = SKILLS[item.skill]; + if (sk && !activeSkills.find((s) => s.key === item.skill)) { + activeSkills.push({ key: item.skill, ...sk }); + } + } + if (item.passive && !activePassives.includes(item.passive)) { + activePassives.push(item.passive); + } + } + + return { ...total, activeSkills, activePassives }; +} + +/** + * Slot name → loadout field name mapping. + */ +const SLOT_MAP = { + weapon: 'weaponId', + armor: 'armorId', + helmet: 'helmetId', + boots: 'bootsId', + accessory1: 'accessory1Id', + accessory2: 'accessory2Id', +}; + +/** + * Given an item type, return the default slot field. Accessories need special handling. + */ +function typeToSlot(type) { + const map = { + weapon: 'weaponId', + armor: 'armorId', + helmet: 'helmetId', + boots: 'bootsId', + accessory: null, // handled separately (accessory1 / accessory2) + }; + return map[type] ?? null; +} + +module.exports = { baseStats, xpRequired, loadoutStats, computeStats, SLOT_MAP, typeToSlot };