From 3710a28ae0442ac61ef7cbafa526bd9a83126012 Mon Sep 17 00:00:00 2001 From: gpatruno Date: Fri, 5 Jun 2026 15:00:58 +0200 Subject: [PATCH] first --- .github/workflows/build.yml | 25 + .gitignore | 29 + CONTRIBUTING.md | 41 + LICENSE | 21 + README.md | 153 ++++ assetpack/README.md | 54 ++ assetpack/ui/Pages/MmorpgPlayerInfo.ui | 29 + build.gradle.kts | 37 + docs/ARCHITECTURE.md | 72 ++ docs/DEVELOPMENT.md | 66 ++ docs/README-UI.md | 408 ++++++++++ docs/ROADMAP.md | 53 ++ gradle.properties | 3 + gradle.properties.bak | 3 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++ gradlew.bat | 93 +++ hytale.properties.example | 10 + settings.gradle.kts | 16 + .../com/disklexar/mmorpg/MmorpgPlugin.java | 148 ++++ .../disklexar/mmorpg/api/package-info.java | 4 + .../disklexar/mmorpg/bootstrap/Bootstrap.java | 122 +++ .../mmorpg/bootstrap/ServiceRegistry.java | 52 ++ .../mmorpg/combat/AbilityService.java | 747 ++++++++++++++++++ .../mmorpg/combat/ClassAbilityController.java | 119 +++ .../mmorpg/combat/CombatBuffService.java | 72 ++ .../com/disklexar/mmorpg/combat/CombatFx.java | 148 ++++ .../mmorpg/combat/CombatStateService.java | 58 ++ .../mmorpg/combat/MmorpgScheduler.java | 69 ++ .../mmorpg/combat/PassiveEffectService.java | 92 +++ .../mmorpg/combat/PoisonService.java | 64 ++ .../disklexar/mmorpg/combat/StunService.java | 32 + .../combat/system/MinerBlockBreakSystem.java | 66 ++ .../combat/system/MmorpgDamageSystem.java | 143 ++++ .../combat/system/MmorpgDeathSystem.java | 105 +++ .../mmorpg/command/AbilityCommand.java | 43 + .../mmorpg/command/ClassCommand.java | 159 ++++ .../mmorpg/command/CommandSupport.java | 51 ++ .../mmorpg/command/GroupCommand.java | 180 +++++ .../disklexar/mmorpg/command/HudCommand.java | 64 ++ .../disklexar/mmorpg/command/InfoCommand.java | 39 + .../disklexar/mmorpg/command/JobCommand.java | 131 +++ .../disklexar/mmorpg/command/MenuCommand.java | 55 ++ .../mmorpg/command/MenuCustomCommand.java | 55 ++ .../mmorpg/command/MmorpgCommand.java | 26 + .../mmorpg/command/MmorpgHelpCommand.java | 40 + .../mmorpg/command/MmorpgProfileCommand.java | 65 ++ .../mmorpg/command/PowerCommand.java | 131 +++ .../mmorpg/core/config/MmorpgConfig.java | 112 +++ .../mmorpg/core/service/Service.java | 11 + .../mmorpg/economy/EconomyService.java | 35 + .../mmorpg/economy/package-info.java | 7 + .../mmorpg/events/AbilityPacketFilter.java | 88 +++ .../events/PlayerConnectionHandler.java | 133 ++++ .../MmorpgCastAbilityInteraction.java | 90 +++ .../mmorpg/persistence/DatabaseManager.java | 72 ++ .../repository/GroupRepository.java | 100 +++ .../repository/PlayerProfileRepository.java | 148 ++++ .../persistence/schema/SchemaInitializer.java | 113 +++ .../disklexar/mmorpg/player/OnlinePlayer.java | 19 + .../mmorpg/player/OnlinePlayerRegistry.java | 35 + .../mmorpg/player/PlayerInfoView.java | 115 +++ .../mmorpg/player/PlayerSessionService.java | 122 +++ .../mmorpg/player/model/PlayerProfile.java | 175 ++++ .../progression/ProgressionService.java | 53 ++ .../disklexar/mmorpg/quest/package-info.java | 7 + .../mmorpg/rpg/clazz/AbilityDefinition.java | 14 + .../mmorpg/rpg/clazz/ClassCatalog.java | 119 +++ .../mmorpg/rpg/clazz/ClassService.java | 45 ++ .../mmorpg/rpg/clazz/PlayerClass.java | 24 + .../com/disklexar/mmorpg/rpg/group/Group.java | 61 ++ .../mmorpg/rpg/group/GroupService.java | 257 ++++++ .../disklexar/mmorpg/rpg/job/JobCatalog.java | 55 ++ .../mmorpg/rpg/job/JobDefinition.java | 12 + .../disklexar/mmorpg/rpg/job/JobService.java | 49 ++ .../mmorpg/rpg/power/PowerCatalog.java | 57 ++ .../mmorpg/rpg/power/PowerDefinition.java | 12 + .../mmorpg/rpg/power/PowerService.java | 49 ++ .../mmorpg/rpg/race/RaceCatalog.java | 46 ++ .../mmorpg/rpg/race/RaceDefinition.java | 13 + .../mmorpg/rpg/race/RaceService.java | 35 + .../disklexar/mmorpg/social/package-info.java | 7 + .../disklexar/mmorpg/ui/AbilityBarHud.java | 83 ++ .../mmorpg/ui/AbilityBarService.java | 237 ++++++ .../disklexar/mmorpg/ui/PlayerInfoPage.java | 94 +++ .../mmorpg/ui/PlayerVitalsReader.java | 45 ++ .../Common/UI/Custom/MmorpgAbilityBar.ui | 170 ++++ .../UI/Custom/Pages/MmorpgPlayerInfo.ui | 29 + .../Item/Items/Weapon_Battleaxe_Mithril.json | 20 + .../Items/Weapon_Crossbow_Ancient_Steel.json | 20 + .../Item/Items/Weapon_Daggers_Mithril.json | 20 + .../Item/Items/Weapon_Longsword_Mithril.json | 20 + .../Item/Items/Weapon_Shortbow_Cobalt.json | 20 + .../Item/Items/Weapon_Staff_Mithril.json | 20 + src/main/resources/config.json | 15 + src/main/resources/db/migrations/001_init.sql | 8 + src/main/resources/db/migrations/002_rpg.sql | 63 ++ src/main/resources/manifest.json | 18 + start-server.sh | 124 +++ 100 files changed, 7744 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assetpack/README.md create mode 100644 assetpack/ui/Pages/MmorpgPlayerInfo.ui create mode 100644 build.gradle.kts create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/README-UI.md create mode 100644 docs/ROADMAP.md create mode 100644 gradle.properties create mode 100644 gradle.properties.bak create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 hytale.properties.example create mode 100644 settings.gradle.kts create mode 100644 src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java create mode 100644 src/main/java/com/disklexar/mmorpg/api/package-info.java create mode 100644 src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java create mode 100644 src/main/java/com/disklexar/mmorpg/bootstrap/ServiceRegistry.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/AbilityService.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/ClassAbilityController.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/CombatFx.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/CombatStateService.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/MmorpgScheduler.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/PoisonService.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/StunService.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/system/MinerBlockBreakSystem.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java create mode 100644 src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDeathSystem.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/AbilityCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/ClassCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/CommandSupport.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/GroupCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/HudCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/InfoCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/JobCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/MenuCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/MenuCustomCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/MmorpgProfileCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/command/PowerCommand.java create mode 100644 src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java create mode 100644 src/main/java/com/disklexar/mmorpg/core/service/Service.java create mode 100644 src/main/java/com/disklexar/mmorpg/economy/EconomyService.java create mode 100644 src/main/java/com/disklexar/mmorpg/economy/package-info.java create mode 100644 src/main/java/com/disklexar/mmorpg/events/AbilityPacketFilter.java create mode 100644 src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java create mode 100644 src/main/java/com/disklexar/mmorpg/interaction/MmorpgCastAbilityInteraction.java create mode 100644 src/main/java/com/disklexar/mmorpg/persistence/DatabaseManager.java create mode 100644 src/main/java/com/disklexar/mmorpg/persistence/repository/GroupRepository.java create mode 100644 src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java create mode 100644 src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java create mode 100644 src/main/java/com/disklexar/mmorpg/player/OnlinePlayer.java create mode 100644 src/main/java/com/disklexar/mmorpg/player/OnlinePlayerRegistry.java create mode 100644 src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java create mode 100644 src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java create mode 100644 src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java create mode 100644 src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java create mode 100644 src/main/java/com/disklexar/mmorpg/quest/package-info.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/clazz/AbilityDefinition.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassService.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/group/Group.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/group/GroupService.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/job/JobCatalog.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/job/JobDefinition.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/job/JobService.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/power/PowerCatalog.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/power/PowerDefinition.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/power/PowerService.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/race/RaceCatalog.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/race/RaceDefinition.java create mode 100644 src/main/java/com/disklexar/mmorpg/rpg/race/RaceService.java create mode 100644 src/main/java/com/disklexar/mmorpg/social/package-info.java create mode 100644 src/main/java/com/disklexar/mmorpg/ui/AbilityBarHud.java create mode 100644 src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java create mode 100644 src/main/java/com/disklexar/mmorpg/ui/PlayerInfoPage.java create mode 100644 src/main/java/com/disklexar/mmorpg/ui/PlayerVitalsReader.java create mode 100644 src/main/resources/Common/UI/Custom/MmorpgAbilityBar.ui create mode 100644 src/main/resources/Common/UI/Custom/Pages/MmorpgPlayerInfo.ui create mode 100644 src/main/resources/Server/Item/Items/Weapon_Battleaxe_Mithril.json create mode 100644 src/main/resources/Server/Item/Items/Weapon_Crossbow_Ancient_Steel.json create mode 100644 src/main/resources/Server/Item/Items/Weapon_Daggers_Mithril.json create mode 100644 src/main/resources/Server/Item/Items/Weapon_Longsword_Mithril.json create mode 100644 src/main/resources/Server/Item/Items/Weapon_Shortbow_Cobalt.json create mode 100644 src/main/resources/Server/Item/Items/Weapon_Staff_Mithril.json create mode 100644 src/main/resources/config.json create mode 100644 src/main/resources/db/migrations/001_init.sql create mode 100644 src/main/resources/db/migrations/002_rpg.sql create mode 100644 src/main/resources/manifest.json create mode 100755 start-server.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..8c846fd --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,25 @@ +name: Build + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + java-version: "25" + distribution: "temurin" + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew build --no-daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..729d4ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +### Build ### +build/ +.gradle/ +.kotlin/ +devserver/ + +### Hytale local config (chemin d'installation personnel) ### +hytale.properties + +### Database ### +*.db +*.db-journal + +### IDE ### +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.classpath +.project +.settings/ + +### OS ### +.DS_Store +Thumbs.db + +### Logs ### +*.log diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8d3e2c6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contribution + +Merci de contribuer au serveur MMORPG Hytale. + +## Avant de commencer + +1. Lire [ARCHITECTURE.md](docs/ARCHITECTURE.md) et [ROADMAP.md](docs/ROADMAP.md) +2. Vérifier qu'aucune issue existante ne couvre déjà votre sujet + +## Branches + +- `main` — stable +- `feature/` — nouvelles fonctionnalités +- `fix/` — corrections + +## Pull requests + +- Une PR = un sujet cohérent +- `./gradlew build` doit passer +- Description en français ou anglais : **quoi** + **pourquoi** +- Référencer la phase roadmap (M0, M1, …) si applicable + +## Code + +- Respecter les frontières de packages (pas d'import `economy` → `quest` circulaire) +- Nouveaux services : implémenter `Service` et enregistrer dans `Bootstrap` +- Pas de secrets dans le dépôt (tokens, clés API) + +## Commits + +Messages clairs, de préférence en impératif : + +``` +feat(player): add experience gain on mob kill +fix(db): close connection on plugin reload +docs: update ROADMAP for M2 economy +``` + +## Questions + +Ouvrir une discussion GitHub ou contacter les mainteneurs du dépôt. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d965a99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Disklexar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bac6026 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# Hytale MMORPG + +Plugin serveur Hytale pour un MMORPG persistant : profils joueurs, progression et fondations modulaires pour l'économie, les quêtes et les guildes. + +## Prérequis + +- **JDK 25+** (Java 26 recommandé si JDK 25 indisponible) +- **Gradle** via le wrapper inclus (`./gradlew`) +- Serveur Hytale (binaire ou `./gradlew runServer` via [ScaffoldIt](https://scaffoldit.dev)) + +## Démarrage rapide + +```bash +# 1. Indiquer où Hytale est installé (launcher officiel) +cp hytale.properties.example hytale.properties +# Éditer hytale.home_path (voir exemples dans le fichier) + +# 2. Compiler puis lancer le serveur de dev (Java 25 forcé) +./start-server.sh +``` + +Si le serveur a déjà planté sans assets, réinitialisez le monde de dev : + +```bash +HYTALE_RESET_DEVSERVER=1 ./start-server.sh +``` + +Ou manuellement : + +```bash +# Compiler le plugin (JAR avec SQLite + Gson embarqués) +JAVA_HOME=/usr/lib/jvm/java-25-openjdk ./gradlew build + +# Lancer le serveur de développement +JAVA_HOME=/usr/lib/jvm/java-25-openjdk ./gradlew runServer +``` + +Le JAR produit se trouve dans `build/libs/hytale-mmorpg-0.1.0.jar` (nom selon version Gradle). + +### Déploiement manuel + +1. Copier le JAR dans le dossier `mods/` du serveur Hytale +2. Redémarrer le serveur +3. La configuration est générée dans le répertoire de données du plugin : `mods/com.disklexar_MMORPG/config.json` + +## Systèmes MMORPG + +| Système | Contenu | Effet | +|---------|---------|-------| +| **Classes** | Chevalier (`knight`) | Armes : Épée longue, Hache de bataille. 3 capacités (Charge/dash+étourdissement 3s, Toupie 3s, Cri de guerre : repousse + −20% résistance 20s, bonus si étourdi). | +| **Pouvoirs** (passifs, aucun par défaut) | Sprinteur (`sprinter`), Résilient (`resilient`) | +10% vitesse hors combat / +10% dégâts 5s après avoir été touché. | +| **Métiers** | Tueur de monstre (`monster_slayer`), Mineur (`miner`) | +1 money par monstre tué / +1 money par bloc cassé. | +| **Groupes** | Partage d'XP | Les membres reçoivent 40% de l'XP gagnée sur un kill. | +| **Races** | Humain (`human`, par défaut) | XP ×2. | + +> **Effets de combat** : dash, étourdissement, toupie, knockback et le buff de vitesse sont +> implémentés via les APIs serveur (`Velocity`, `TransformComponent`, `MovementManager`, +> `TargetUtil`, `DamageSystems`). Les valeurs (distances, forces, dégâts) sont des constantes à +> ajuster en jeu. + +## Commandes + +| Commande | Description | +|----------|-------------| +| `/mmorpg help` | Affiche l'aide | +| `/mmorpg profile` | Résumé court (niveau, XP) | +| `/mmorpg info` | Toutes vos informations (chat) | +| `/mmorpg menu` | Ouvre l'interface d'informations du joueur (page UI custom) | +| `/mmorpg class [classe]` | Gérer votre classe | +| `/mmorpg power [pouvoir]` | Gérer vos pouvoirs | +| `/mmorpg job [metier]` | Gérer vos métiers | +| `/mmorpg group [joueur]` | Gérer votre groupe | +| `/mmorpg ability <1\|2\|3>` | Utiliser une capacité de classe | +| Touches **Use Ability 1/2/3** | Même effet que la commande ci-dessus (si vous avez une classe) | + +### Capacités de classe (touches Ability 1/2/3) + +Les armes vanilla possèdent leurs propres compétences sur les slots Ability. Le plugin les **remplace** +dès qu'un joueur a choisi une classe MMORPG : + +1. **Filtre de paquets** (`SyncInteractionChains`) — intercepte Ability 1/2/3 avant le moteur vanilla, + envoie `CancelInteractionChain` au client et lance la capacité de classe. +2. **Interaction native** (`mmorpg_cast_ability`) — enregistrée côté serveur ; les patches d'items dans + `src/main/resources/Server/Item/Items/` remplacent Ability1/2/3 sur les armes de test (sync client/serveur). + +Prérequis en jeu : `/mmorpg class choisir ` puis équiper une arme autorisée pour cette classe. +Sans classe, les compétences vanilla de l'arme s'appliquent normalement. + +Pour ajouter d'autres armes, copiez un patch JSON existant (ex. `Weapon_Longsword_Mithril.json`) en +ne gardant que `"Id"` et `"Interactions"`. + + +`/mmorpg menu` ouvre une `CustomUIPage` listant toutes les infos du joueur (classe, race, pouvoirs, +métiers, groupe, money, temps de jeu, dates…). Un plugin **serveur seul** ne peut pas ajouter un +bouton dans l'écran d'inventaire natif ; les équivalents fournis sont la commande `/mmorpg menu` et +l'interaction `mmorpg_player_info` (voir `assetpack/README.md` pour brancher un asset `.ui`). + +## Configuration + +Fichier `config.json` (valeurs par défaut dans `src/main/resources/config.json`) : + +| Clé | Description | +|-----|-------------| +| `Debug` | Logs détaillés | +| `DefaultLevel` | Niveau initial des nouveaux joueurs | +| `MaxPlayers` | Limite prévue (référence future) | +| `BaseXpPerLevel` | XP de base requise par niveau | +| `KillExperience` | XP de base accordée par monstre tué | +| `Database.FileName` | Fichier SQLite (`mmorpg.db`) | +| `Features.Economy` | Active le module économie | +| `Features.Quests` | Active le module quêtes (M3) | +| `Features.Guilds` | Active le module guildes (M4) | + +## Base de données + +`player_profiles` ajoute : `class_id`, `powers` (JSON), `jobs` (JSON), `guild_id`, `group_id`, +`is_connected`, `total_time_play`, `last_date_connected`, `date_creation`, `money`, `race_id`. +Tables de dépendance : `classes`, `powers`, `jobs`, `races`, `groups`, `group_members`. +Les migrations sont versionnées via `schema_migrations` (voir `db/migrations/`). + +## Structure du projet + +``` +src/main/java/com/disklexar/mmorpg/ +├── MmorpgPlugin.java # Point d'entrée (commandes + systèmes ECS) +├── bootstrap/ # Initialisation et registre de services +├── core/ # Config et contrats +├── player/ # Sessions, profils, registre des joueurs en ligne, vue d'infos +├── persistence/ # SQLite (migrations versionnées, repositories) +├── command/ # Commandes /mmorpg (class, power, job, group, ability, info, menu) +├── events/ # Connexion / déconnexion, filtre paquets Ability 1/2/3 +├── interaction/ # mmorpg_cast_ability (interaction native asset pack) +├── progression/ # XP et niveaux (multiplicateurs de race) +├── economy/ # Monnaie (money) +├── rpg/ # clazz (classes) · power · job · race · group +├── combat/ # Capacités, état de combat, effets passifs, planificateur +│ └── system/ # Systèmes ECS : dégâts, morts/kills, casse de bloc +├── ui/ # AbilityBarHud, AbilityBarService, PlayerInfoPage +├── quest/ # Stub (M3) +└── social/ # Stub (M4) +``` + +## Documentation + +- [Interface utilisateur MMORPG (UI/UX)](docs/README-UI.md) +- [Architecture](docs/ARCHITECTURE.md) +- [Feuille de route](docs/ROADMAP.md) +- [Guide de développement](docs/DEVELOPMENT.md) +- [Contribution](CONTRIBUTING.md) + +## Licence + +MIT — voir [LICENSE](LICENSE). diff --git a/assetpack/README.md b/assetpack/README.md new file mode 100644 index 0000000..8362053 --- /dev/null +++ b/assetpack/README.md @@ -0,0 +1,54 @@ +# Asset pack (scaffold) — MMORPG + +Server-only plugins **cannot inject a button into the native inventory screen** and cannot render +a custom-designed page without a client-side asset (`.ui`). This folder is the scaffold to add one. + +## What already works without this asset pack + +- `/mmorpg menu` opens a real `CustomUIPage` (`PlayerInfoPage`) built with **inline UI markup**, so + it renders without any bundled asset. +- `/mmorpg info` prints the same data in chat (reliable fallback). +- The `mmorpg_player_info` interaction is registered (`OpenCustomUIInteraction`) so the page can be + opened from an item/block interaction once you wire one. + +## Class ability keys (Ability 1/2/3) + +Weapon ability slots are overridden in **`src/main/resources/Server/Item/Items/`** (bundled with the +plugin JAR, not this scaffold folder). Each patch sets: + +```json +"Ability1": { + "Interactions": [{ "Type": "mmorpg_cast_ability", "Slot": 1 }] +} +``` + +The Java interaction `mmorpg_cast_ability` is registered in `MmorpgPlugin`. Even without a patch +for a given weapon, the **packet filter** still routes Ability 1/2/3 to class abilities when the +player has a class. + +To patch another weapon, add `Server/Item/Items/.json` with only `"Id"` and `"Interactions"`. + +Shipped examples: longsword, battleaxe, staff, daggers, shortbow, crossbow (mithril/cobalt variants). + +## Enabling the designed `.ui` template + +1. Package this `assetpack/` content according to your Hytale/ScaffoldIt asset-pack layout and set + `"IncludesAssetPack": true` in `src/main/resources/manifest.json`. +2. Edit `com.disklexar.mmorpg.ui.PlayerInfoPage#build` to append the template and fill fields: + + ```java + ui.append("Pages/MmorpgPlayerInfo.ui"); + ui.set("#Title.Text", "Profil MMORPG"); + for (PlayerInfoView.Entry e : PlayerInfoView.entries(profile)) { + ui.append("#Content", "Label { Text: \"" + e.label() + " : " + e.value() + "\"; }"); + } + events.addEventBinding(CustomUIEventBindingType.Click, "#CloseButton"); + ``` + +3. Validate the `.ui` grammar in-game and adjust `MmorpgPlayerInfo.ui`. + +## Inventory button + +A literal button inside the native inventory UI requires a client UI mod/asset overriding the +inventory screen, which is out of scope for a server-only plugin. The supported equivalents are the +`/mmorpg menu` command and the `mmorpg_player_info` interaction above. diff --git a/assetpack/ui/Pages/MmorpgPlayerInfo.ui b/assetpack/ui/Pages/MmorpgPlayerInfo.ui new file mode 100644 index 0000000..4ce8c6c --- /dev/null +++ b/assetpack/ui/Pages/MmorpgPlayerInfo.ui @@ -0,0 +1,29 @@ +// Sample custom UI page template for the MMORPG player-info screen. +// +// This is a SCAFFOLD: the exact .ui markup grammar is defined by the Hytale client and must be +// validated in-game. The plugin works today without this file because PlayerInfoPage builds its +// layout with inline markup. Use this template when you want a richer designed layout, then have +// PlayerInfoPage.build() append "Pages/MmorpgPlayerInfo.ui" and set the named fields below. +// +// Named fields the server can target via UICommandBuilder.set(...): +// #Title.Text - page title +// #Content - vertical container the server appends one Label per attribute into +// #CloseButton - close/dismiss button (bind via UIEventBuilder) + +Panel { + Style: (Width: 420; Anchor: Center; Padding: 16; Background: (Color: #1b1b1fEE)); + + Label #Title { + Text: "Profil MMORPG"; + Style: (Alignment: Center; FontSize: 20; MarginBottom: 12); + } + + VerticalList #Content { + Style: (Spacing: 4); + } + + Button #CloseButton { + Text: "Fermer"; + Style: (Alignment: Center; MarginTop: 12); + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a165b2a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,37 @@ +import org.gradle.jvm.toolchain.JavaLanguageVersion + +plugins { + id("com.gradleup.shadow") version "9.0.0-rc1" +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.xerial:sqlite-jdbc:3.47.2.0") + implementation("com.google.code.gson:gson:2.11.0") +} + +tasks.shadowJar { + archiveClassifier.set("") + mergeServiceFiles() +} + +tasks.jar { + enabled = false +} + +tasks.assemble { + dependsOn(tasks.shadowJar) +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..20a9944 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,72 @@ +# Architecture + +## Vue d'ensemble + +Le projet est un **plugin monolithique** Java déployé sur le serveur Hytale officiel. Le serveur gère le monde, le réseau et l'ECS ; le plugin ajoute la couche MMORPG (persistance, progression, systèmes futurs). + +```mermaid +flowchart TB + subgraph server [Serveur Hytale] + ECS[ECS / Mondes] + Events[EventBus] + Cmds[CommandRegistry] + end + + subgraph mmorpg [Plugin com.disklexar:MMORPG] + Plugin[MmorpgPlugin] + Boot[Bootstrap] + Registry[ServiceRegistry] + PlayerSvc[PlayerSessionService] + DB[(SQLite)] + end + + Plugin --> Boot --> Registry + Registry --> PlayerSvc + PlayerSvc --> DB + Plugin --> Events + Plugin --> Cmds +``` + +## Cycle de vie du plugin + +| Phase | Responsabilité | +|-------|----------------| +| `setup()` | Charge la config, initialise `Bootstrap`, enregistre commandes et événements | +| `start()` | Démarre les services (`DatabaseManager`, `PlayerSessionService`, …) | +| `shutdown()` | Sauvegarde les sessions, ferme SQLite | + +Le singleton `MmorpgPlugin.get()` est rafraîchi à chaque chargement pour rester compatible avec le hot-reload Hytale. + +## Modules et dépendances + +| Package | Dépend de | Rôle | +|---------|-----------|------| +| `bootstrap` | `core`, `persistence`, `player` | Câblage des services | +| `core` | — | Config, interface `Service` | +| `persistence` | `core` | SQLite, repositories | +| `player` | `persistence`, `core` | Profils et sessions en ligne | +| `command` | `player`, `bootstrap` | Interface joueur | +| `events` | `player`, `bootstrap` | Connexion / déconnexion | +| `economy`, `quest`, `social` | — (stubs) | Extensions futures | + +**Règle** : les packages `economy`, `quest` et `social` ne doivent pas être importés par `core` ou `persistence` tant qu'ils ne sont pas implémentés. L'activation se fait via `Features.*` dans `config.json`. + +## Persistance + +- Fichier : `{dataDirectory}/mmorpg.db` +- Migrations : `src/main/resources/db/migrations/*.sql` +- Table initiale : `player_profiles` (uuid, display_name, level, experience, timestamps) + +Les dépendances JDBC (SQLite) et JSON (Gson) sont **embarquées dans le JAR** via Shadow, car absentes du classpath serveur. + +## Événements joueur + +- **Connexion prête** : `PlayerReadyEvent` → chargement ou création du profil +- **Déconnexion** : `PlayerDisconnectEvent` → sauvegarde et retrait de la session mémoire + +## Conventions + +- Identifiants joueur : `java.util.UUID` +- Noms affichés : `PlayerRef.getUsername()` en priorité +- Logs : `HytaleLogger` du plugin +- Permissions futures : préfixe `com.disklexar.mmorpg.*` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..73bd1cd --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,66 @@ +# Guide de développement + +## Environnement + +1. Installer **JDK 25** (`java-25-openjdk`). Le script `start-server.sh` force `/usr/lib/jvm/java-25-openjdk`. +2. Installer **Hytale** via le launcher et configurer `hytale.home_path` (copier `hytale.properties.example` → `hytale.properties`). +3. Cloner le dépôt et ouvrir le projet dans IntelliJ IDEA : `./gradlew openIdea` (ScaffoldIt). + +## Commandes Gradle + +| Tâche | Description | +|-------|-------------| +| `./gradlew build` | Compile et produit le JAR (Shadow) | +| `./gradlew runServer` | Serveur de dev local | +| `./gradlew generateManifest` | Régénère `manifest.json` depuis `settings.gradle.kts` | +| `./gradlew setupServer` | Prépare le répertoire `devserver/` | + +## Workflow recommandé + +1. Créer une branche depuis `main` +2. Implémenter dans le package approprié (voir [ARCHITECTURE.md](ARCHITECTURE.md)) +3. Vérifier `./gradlew build` +4. Tester en jeu avec `./gradlew runServer` +5. Ouvrir une pull request + +## Base de données locale + +- Fichier : `devserver/.../com.disklexar_MMORPG/mmorpg.db` (chemin exact selon ScaffoldIt) +- Inspecter avec `sqlite3 mmorpg.db` +- Requête utile : `SELECT * FROM player_profiles;` + +Les fichiers `*.db` sont ignorés par Git. + +## Logs + +Le plugin utilise `getLogger()` (HytaleLogger). Activer `Debug: true` dans `config.json` pour plus de détails sur les sessions. + +Logs serveur de dev : `devserver/logs/`. + +## Dépannage runServer + +| Symptôme | Cause | Solution | +|----------|-------|----------| +| `--assets=null` dans les logs Gradle | `hytale.home_path` non configuré | `hytale.properties` ou `HYTALE_HOME` | +| `Missing default DamageCause assets` | Assets jeu absents | Installer Hytale + configurer le chemin | +| `Env_Zone1_Plains doesn't exist` | `devserver` créé sans assets | `HYTALE_RESET_DEVSERVER=1 ./start-server.sh` | +| `Task :runServer FAILED` exit 1 | Voir `devserver/logs/*.log` | Corriger la cause ci-dessus | + +## API Hytale + +La dépendance `com.hypixel.hytale:Server:+` est en **compileOnly** : ne pas l'inclure dans le JAR final. En cas d'erreur de compilation après mise à jour serveur : + +- Vérifier les noms d'événements (`PlayerReadyEvent`, `PlayerDisconnectEvent`) +- Consulter [doctale.dev](https://doctale.dev) et le template [HytaleModding/plugin-template](https://github.com/HytaleModding/plugin-template) + +## Ajouter une migration SQL + +1. Créer `src/main/resources/db/migrations/00X_description.sql` +2. Étendre `SchemaInitializer` pour exécuter les nouveaux fichiers dans l'ordre +3. Documenter le changement dans le changelog / PR + +## Style de code + +- Packages en minuscules, classes en PascalCase +- Pas de logique métier dans `MmorpgPlugin` — déléguer aux services +- Fermer les ressources JDBC dans try-with-resources diff --git a/docs/README-UI.md b/docs/README-UI.md new file mode 100644 index 0000000..96780ca --- /dev/null +++ b/docs/README-UI.md @@ -0,0 +1,408 @@ +# Interface utilisateur MMORPG — conception UI/UX + +Document de référence pour l'interface joueur du plugin **com.disklexar:MMORPG** sur Hytale. +Objectif : une UI type MMORPG **moderne, ergonomique, immersive et intuitive**, compatible avec les +contraintes d'un plugin **serveur** (assets `.ui` embarqués, `CustomUIHud`, `CustomUIPage`). + +--- + +## Principes directeurs + +| Principe | Application | +|----------|-------------| +| **Lisibilité en combat** | Contrastes élevés, tailles minimales 12 px, icônes + texte court | +| **Hiérarchie visuelle** | HUD persistant discret ; menus modaux pour la gestion profonde | +| **Feedback immédiat** | Cooldowns, buffs et dégâts reflétés en < 1 s côté client | +| **Cohérence** | Palette sombre `#1a1c20` / accents `#c8a45c` (or MMORPG) sur tous les écrans | +| **Responsive** | Ancres relatives (`Left`, `Right`, `Bottom`) + grilles flex, pas de pixels fixes centraux | +| **Dégradation gracieuse** | Commandes chat (`/mmorpg info`, `/mmorpg profile`) si un asset `.ui` est invalide | + +### Palette + +``` +Fond HUD #1a1c20 @ 85 % Barre pleine vie #3d8f5a +Fond slot #24262a Barre vide #2a2d32 +Texte primaire #e8e8ec Mana / énergie #4a8fd4 +Texte secondaire #878e9c XP / niveau #c8a45c +Buff positif #5cb85c Debuff négatif #c9302c +Bordure focus #c8a45c Overlay modal #1b1b1f @ 93 % +``` + +--- + +## Architecture globale + +```mermaid +flowchart TB + subgraph hud [HUD persistant — en jeu] + Vitals[Barres vie / mana / XP] + Buffs[Buffs & debuffs] + Hotbar[Barre d'action] + AbilityBar[Barre capacités classe] + end + + subgraph menus [Menus joueur — modaux] + Shell[Coquille MmorpgPlayerShell.ui] + Char[Onglet Personnage] + Inv[Onglet Inventaire] + Skills[Onglet Arbre de compétences] + end + + subgraph server [Serveur Java] + ABS[AbilityBarService] + PIP[PlayerInfoPage] + Future[HudVitalsService — M1] + FutureMenu[PlayerMenuPage — M1] + end + + ABS --> AbilityBar + Future --> Vitals + Future --> Buffs + Future --> Hotbar + FutureMenu --> Shell + Shell --> Char + Shell --> Inv + Shell --> Skills + PIP -.->|fallback actuel| Char +``` + +### Arborescence des assets (cible) + +``` +src/main/resources/Common/UI/Custom/ +├── Huds/ +│ ├── MmorpgVitalsHud.ui # Vie, mana, XP (bas-gauche) +│ ├── MmorpgBuffTrayHud.ui # Buffs / debuffs (sous les barres) +│ ├── MmorpgHotbarHud.ui # Slots objets + raccourcis +│ └── MmorpgAbilityBar.ui # ✅ Implémenté — 3 slots classe +├── Pages/ +│ ├── MmorpgPlayerShell.ui # Fenêtre à onglets (coquille) +│ ├── MmorpgCharacterTab.ui # Onglet Personnage +│ ├── MmorpgInventoryTab.ui # Onglet Inventaire +│ └── MmorpgSkillTreeTab.ui # Onglet Arbre de compétences +└── Widgets/ + ├── MmorpgProgressBar.ui # Barre réutilisable (vie, mana, XP) + ├── MmorpgBuffIcon.ui # Icône buff avec timer + └── MmorpgSkillNode.ui # Nœud d'arbre de compétences +``` + +### Mapping code serveur + +| Composant UI | Classe Java | Statut | +|--------------|-------------|--------| +| Barre capacités | `AbilityBarHud` + `AbilityBarService` | ✅ Actif | +| Profil joueur (liste) | `PlayerInfoPage` | ✅ Actif (layout base-game) | +| Coquille à onglets | `PlayerMenuPage` | 🔲 M1 | +| Barres vitales HUD | `VitalsHud` + `VitalsHudService` | 🔲 M1 | +| Buff tray | `BuffTrayHud` | 🔲 M1 | +| Hotbar étendue | `HotbarHud` | 🔲 M2 | + +--- + +## 1. Affichage principal (HUD) + +Le HUD reste **toujours visible** en exploration et en combat. Les éléments natifs Hytale +(inventaire rapide, minimap) sont conservés ; le plugin ajoute des calques `CustomUIHud` +(`z-order` croissant : vitals → buffs → hotbar → capacités). + +### 1.1 Disposition écran (desktop 16:9) + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ [Minimap native] [Quêtes]* │ +│ │ +│ MONDE / COMBAT │ +│ │ +│ │ +│ ┌─ Vitals ─────────────────┐ │ +│ │ ♥ ████████░░ 840/1000 │ ← Vie │ +│ │ ◆ ██████░░░░ 120/200 │ ← Mana / énergie │ +│ │ ★ Nv.42 ████░ 67 % │ ← Niveau + barre XP │ +│ └──────────────────────────┘ │ +│ [Buff][Buff][Debuff]... ← Buff tray (max 8 icônes visibles) │ +│ │ +│ [1][2][3][4][5][6][7][8][9][0] ← Hotbar objets │ +│ [Cap1][Cap2][Cap3] ← Barre capacités classe │ +└──────────────────────────────────────────────────────────────────────────┘ +* futur module quêtes +``` + +### 1.2 Barres de vie, mana et XP + +**Fichier cible :** `Huds/MmorpgVitalsHud.ui` + +| Élément | ID serveur | Donnée source | Comportement | +|---------|------------|---------------|--------------| +| Barre de vie | `#HealthBar`, `#HealthText` | Composants entité joueur (vanilla) + bonus MMORPG futurs | Remplissage proportionnel ; flash rouge si < 25 % | +| Barre de mana | `#ManaBar`, `#ManaText` | Attribut MMORPG (M1) | Masquée si classe sans mana (ex. Chevalier) | +| Niveau | `#LevelLabel` | `PlayerProfile.level` | `Nv. {n}` | +| Barre XP | `#XpBar`, `#XpText` | `ProgressionService` | `{current}/{required}` + pourcentage | + +**Rafraîchissement :** toutes les 500 ms via `VitalsHudService` ; push immédiat sur gain d'XP ou dégâts. + +**Widget réutilisable `MmorpgProgressBar.ui` :** + +``` +Group #ProgressBar + Group #Fill — largeur dynamique (ui.set Width ou FlexWeight) + Label #ValueText — "840 / 1000" +``` + +### 1.3 Buffs et debuffs actifs + +**Fichier cible :** `Huds/MmorpgBuffTrayHud.ui` + +| Propriété | Valeur | +|-----------|--------| +| Position | Au-dessus des barres vitales, aligné à gauche | +| Capacité | 8 icônes visibles ; défilement horizontal si > 8 | +| Par icône | Image, nom court, timer circulaire ou compte à rebours | +| Sources serveur | `CombatBuffService`, `StunService`, effets de classe | + +**IDs dynamiques :** `#BuffSlot0` … `#BuffSlot7` — le serveur injecte des instances de +`Widgets/MmorpgBuffIcon.ui` via `appendInline`. + +**Code couleur :** bordure verte = buff allié ; rouge = debuff ; or = effet neutre / passif. + +### 1.4 Barre d'action (hotbar) + +**Fichier cible :** `Huds/MmorpgHotbarHud.ui` + +La hotbar MMORPG **complète** la barre native sans la remplacer : + +| Zone | Slots | Contenu | +|------|-------|---------| +| Objets consommables | 1–0 (10) | Potions, nourriture, gadgets MMORPG | +| Capacités classe | Ability 1–3 | Géré par `MmorpgAbilityBar.ui` (déjà séparé) | +| Slot utilitaire | `U` (optionnel) | Monture, outil de métier | + +**Par slot :** + +- Icône de l'objet / compétence +- Raccourci clavier (coin supérieur droit) +- Overlay cooldown (assombrissement + texte secondes) +- Bordure dorée si sélectionné + +**Interaction :** les slots 1–3 déclenchent `mmorpg_cast_ability` ; les autres passent par +l'inventaire natif ou des interactions custom futures. + +### 1.5 Barre de capacités de classe (implémentée) + +**Fichier :** `Common/UI/Custom/MmorpgAbilityBar.ui` +**Service :** `AbilityBarService` — affichage automatique à la connexion si le joueur a une classe. + +| Slot | ID | Touche | Mise à jour | +|------|-----|--------|-------------| +| 1 | `#Slot1Name`, `#Slot1Key` | Ability 1 | Nom + cooldown (`Charge (3s)`) | +| 2 | `#Slot2Name`, `#Slot2Key` | Ability 2 | idem | +| 3 | `#Slot3Name`, `#Slot3Key` | Ability 3 | idem | + +**Commande debug :** `/mmorpg hud` — force la synchronisation. + +--- + +## 2. Menus joueur + +Ouverture cible : **`C`** ou `/mmorpg menu` → coquille à onglets. +État actuel : `/mmorpg menu` ouvre `PlayerInfoPage` (liste verticale, layout `WarpListPage.ui`). + +### 2.1 Coquille — `MmorpgPlayerShell.ui` + +``` +Panel (centré, max 900×600, fond #1b1b1fEE) +├── Header +│ ├── Portrait / icône classe #ClassPortrait +│ ├── Nom joueur #PlayerName +│ └── Monnaie #MoneyLabel +├── TabBar (horizontal) +│ ├── Button #TabCharacter "Personnage" +│ ├── Button #TabInventory "Inventaire" +│ └── Button #TabSkills "Compétences" +├── ContentHost #TabContent (swap le document de l'onglet actif) +└── Footer + └── Button #CloseButton "Fermer" +``` + +**Navigation :** `UIEventBuilder` lie chaque onglet ; le serveur charge le `.ui` de l'onglet +dans `#TabContent` via `ui.clear` + `ui.append`. + +### 2.2 Onglet Personnage + +**Fichier cible :** `Pages/MmorpgCharacterTab.ui` + +| Section | Champs | Source `PlayerProfile` | +|---------|--------|------------------------| +| Identité | Nom, race, classe | `displayName`, `raceId`, `classId` | +| Progression | Niveau, XP, barre XP | `level`, `experience` | +| Attributs* | Force, Agilité, Intelligence… | M1 — stats dérivées | +| Pouvoirs passifs | Liste à puces | `powers` → `PowerCatalog` | +| Métiers | Liste à puces | `jobs` → `JobCatalog` | +| Social | Groupe, guilde | `groupId`, `guildId` | +| Statistiques | Temps de jeu, argent, dates | `PlayerInfoView` (existant) | + +\* Les attributs numériques sont un placeholder visuel jusqu'à l'implémentation M1. + +**Layout :** deux colonnes sur grand écran — gauche : portrait + barres ; droite : listes +pouvoirs / métiers / social. + +### 2.3 Onglet Inventaire + +**Fichier cible :** `Pages/MmorpgInventoryTab.ui` + +> Un plugin serveur **ne peut pas modifier l'inventaire natif**. Cet onglet est une **vue MMORPG** +> complémentaire (items tagués, équipement RPG, filtres). + +| Zone | Description | +|------|-------------| +| `#EquipmentPaperDoll` | Silhouette : tête, torse, mains, jambes, arme principale / secondaire | +| `#InventoryGrid` | Grille 8×5 (40 slots) — items avec rareté (bordure couleur) | +| `#ItemDetails` | Panneau droit : nom, stats, description, boutons Utiliser / Jeter | +| `#CategoryTabs` | Filtres : Tout, Équipement, Consommables, Matériaux, Quête | +| `#WeightBar` | Encumbrance (futur) | + +**Ergonomie :** + +- Clic gauche : sélection + détails +- Double-clic : utiliser (si consommable) +- Shift-clic : déplacer vers équipement (futur) +- Recherche texte `#SearchField` en haut de grille + +### 2.4 Onglet Arbre de compétences + +**Fichier cible :** `Pages/MmorpgSkillTreeTab.ui` + +| Élément | Description | +|---------|-------------| +| `#TreeCanvas` | Zone scrollable / zoomable (pincer sur mobile) | +| `#SkillNode_*` | Nœuds : icône, rang actuel / max, prérequis | +| `#PointsAvailable` | Points de compétence non dépensés | +| `#NodeTooltip` | Survol : nom, effet, coût, prérequis | +| `#ResetButton` | Réinitialisation (coût en monnaie, futur) | + +**États visuels d'un nœud (`Widgets/MmorpgSkillNode.ui`) :** + +| État | Apparence | +|------|-----------| +| Verrouillé | Grisé, cadenas | +| Disponible | Bordure or clignotante légère | +| Appris (rang 1–n) | Rempli, rang affiché | +| Max | Bordure brillante, étoile | + +**Données :** JSON ou table SQLite `skill_nodes` (M1+) ; rendu initial basé sur la classe active +(`ClassCatalog` → 3 branches par classe en V1). + +--- + +## 3. Responsive et multi-résolution + +### Breakpoints (ratio largeur / hauteur viewport client) + +| Profil | Condition | Adaptations | +|--------|-----------|-------------| +| **Desktop** | largeur ≥ 1280 px | Menus 900 px ; HUD complet ; 2 colonnes Personnage | +| **Compact** | 1024–1279 px | Menus 720 px ; buff tray 6 icônes ; texte réduit 1 px | +| **Small** | < 1024 px | Menus plein écran ; vitals empilés ; hotbar 6 slots visibles + scroll | +| **Ultrawide** | ratio > 2.1 | HUD ancré aux tiers gauche/droite, pas aux bords extrêmes | + +### Règles d'ancrage (fichiers `.ui`) + +``` +# HUD bas +Anchor: (Bottom: 24, Left: 24) — vitals +Anchor: (Bottom: 28, Left: 0, Right: 0) — ability bar (centré) + +# Menu modal +Anchor: Center +Style: (MaxWidth: 900, Width: 90%) +``` + +- Préférer `FlexWeight`, `Padding: (Full: n)` et `LayoutMode: Top/Right` aux positions absolues. +- Les groupes vides utilisent `Anchor: (Width: 6)` comme séparateurs (pattern `MmorpgAbilityBar.ui`). +- Tester en 1920×1080, 1366×768 et 1280×720. + +### Accessibilité + +- Contraste texte / fond ≥ 4.5:1 (WCAG AA) +- Taille police minimale 12 px (HUD) / 14 px (menus) +- Les cooldowns affichent **chiffres + assombrissement** (pas la couleur seule) +- Raccourcis clavier listés dans chaque infobulle de slot + +--- + +## 4. Flux utilisateur + +```mermaid +sequenceDiagram + participant J as Joueur + participant C as Client Hytale + participant S as Serveur MMORPG + + J->>C: Connexion + C->>S: PlayerReadyEvent + S->>S: loadOrCreate(profile) + alt a une classe + S->>C: addCustomHud(MmorpgAbilityBar) + end + S->>C: Message bienvenue (niveau, race) + + J->>C: /mmorpg menu ou touche C + C->>S: openCustomPage + S->>C: PlayerInfoPage (puis PlayerMenuPage) + + J->>C: Ability 1 + C->>S: SyncInteractionChains (filtré) + S->>C: CancelInteractionChain + cast + S->>C: update HUD cooldowns +``` + +--- + +## 5. Commandes et raccourcis + +| Action | Commande / touche | Écran | +|--------|-------------------|-------| +| Ouvrir profil | `/mmorpg menu` | Menu (liste → coquille) | +| Profil chat | `/mmorpg info` | Fallback texte | +| Résumé court | `/mmorpg profile` | Chat | +| Forcer HUD capacités | `/mmorpg hud` | HUD | +| Capacité 1–3 | Touches Ability 1/2/3 | HUD + exécution | +| Fermer menu | Échap | — | + +**Raccourcis cibles (M1) :** `C` → menu ; `K` → arbre de compétences ; `I` déjà natif inventaire. + +--- + +## 6. Phases d'implémentation + +| Phase | Livrables UI | Dépendances gameplay | +|-------|--------------|----------------------| +| **M0** ✅ | `MmorpgAbilityBar.ui`, `PlayerInfoPage`, `/mmorpg hud` | Classes, capacités | +| **M1** | `MmorpgVitalsHud`, `MmorpgBuffTray`, `MmorpgPlayerShell` + onglet Personnage | Stats vie/mana, buffs | +| **M2** | Onglet Inventaire MMORPG, `MmorpgHotbarHud` | Items tagués, équipement | +| **M3** | Onglet Arbre de compétences | Points de compétence, déblocages | +| **M4** | Quêtes dans HUD, tooltips riches | Module quêtes | + +--- + +## 7. Validation et tests + +1. **Asset pack** : `IncludesAssetPack: true` dans `manifest.json` — chaque `.ui` doit être + validé en jeu (un fichier invalide **crash le client** à la connexion). +2. **Checklist par écran :** + - [ ] Connexion sans crash client + - [ ] HUD visible après choix de classe + - [ ] Cooldowns décrémentent chaque seconde + - [ ] Déconnexion retire le HUD (`AbilityBarService.dismiss`) + - [ ] Menu s'ouvre et se ferme (Échap) + - [ ] Responsive : pas de chevauchement à 1366×768 +3. **Logs serveur :** rechercher `Ability bar HUD` dans `devserver/logs/`. + +--- + +## 8. Références projet + +- Code HUD actif : `src/main/java/com/disklexar/mmorpg/ui/` +- Asset barre capacités : `src/main/resources/Common/UI/Custom/MmorpgAbilityBar.ui` +- Scaffold pages : `assetpack/ui/Pages/MmorpgPlayerInfo.ui` +- Guide asset pack : [assetpack/README.md](../assetpack/README.md) +- Architecture : [ARCHITECTURE.md](ARCHITECTURE.md) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..886ece6 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,53 @@ +# Feuille de route + +## M0 — Fondation (actuel) + +- [x] Projet Gradle + ScaffoldIt +- [x] Plugin `com.disklexar:MMORPG` +- [x] SQLite + table `player_profiles` +- [x] Sessions joueur (connexion / déconnexion) +- [x] Commandes `/mmorpg help` et `/mmorpg profile` +- [x] Packages stub : `economy`, `quest`, `social` +- [x] Documentation et CI + +## M1 — Personnage et progression + +- [ ] Système d'expérience et montée de niveau +- [ ] Statistiques de base (vie, mana, attributs) +- [ ] Sauvegarde position / monde (spawn MMORPG) +- [ ] Messages et UI côté client (si API disponible) + +## M2 — Économie + +- [ ] Monnaie serveur (wallets SQLite) +- [ ] Transactions atomiques +- [ ] Vendeurs NPC et tables de loot +- [ ] Activer `Features.Economy` dans la config + +## M3 — Quêtes + +- [ ] Définitions de quêtes (JSON ou DB) +- [ ] Objectifs : collecte, dialogue, zone +- [ ] Récompenses XP / items / monnaie +- [ ] Quêtes journalières +- [ ] Activer `Features.Quests` + +## M4 — Social et instances + +- [ ] Groupes / parties +- [ ] Guildes (création, rangs, banque) +- [ ] Instances / donjons dédiés +- [ ] Activer `Features.Guilds` + +## M5 — Production + +- [ ] Migration PostgreSQL optionnelle +- [ ] Cache Redis pour sessions multi-serveur +- [ ] Métriques et monitoring +- [ ] Anti-triche basique (rate limits, validation serveur) + +## Hors périmètre connu + +- Asset pack custom (modèles, textures) +- Interface web d'administration +- Client modifié (hors API serveur) diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..96d799e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.java.home=/usr/lib/jvm/java-25-openjdk diff --git a/gradle.properties.bak b/gradle.properties.bak new file mode 100644 index 0000000..96d799e --- /dev/null +++ b/gradle.properties.bak @@ -0,0 +1,3 @@ +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.java.home=/usr/lib/jvm/java-25-openjdk diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 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�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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bad7c24 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..61b77d7 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..bd8a8c0 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/hytale.properties.example b/hytale.properties.example new file mode 100644 index 0000000..8b66b65 --- /dev/null +++ b/hytale.properties.example @@ -0,0 +1,10 @@ +# Copier ce fichier vers hytale.properties et adapter le chemin. +# Dossier d'installation Hytale (doit contenir install/.../Assets.zip) +# +# Linux (Flatpak) : +# hytale.home_path=/home/VOTRE_USER/.var/app/com.hypixel.HytaleLauncher/data/Hytale +# +# Windows : +# hytale.home_path=C:/Users/VOTRE_USER/AppData/Roaming/Hytale + +hytale.home_path= diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..2192b10 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +rootProject.name = "hytale-mmorpg" + +plugins { + id("dev.scaffoldit") version "0.2.+" +} + +hytale { + usePatchline("release") + useVersion("latest") + + manifest { + Group = "com.disklexar" + Name = "MMORPG" + Main = "com.disklexar.mmorpg.MmorpgPlugin" + } +} diff --git a/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java b/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java new file mode 100644 index 0000000..1f528fc --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java @@ -0,0 +1,148 @@ +package com.disklexar.mmorpg; + +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.bootstrap.ServiceRegistry; +import com.disklexar.mmorpg.combat.ClassAbilityController; +import com.disklexar.mmorpg.combat.CombatBuffService; +import com.disklexar.mmorpg.combat.CombatStateService; +import com.disklexar.mmorpg.combat.PoisonService; +import com.disklexar.mmorpg.combat.system.MinerBlockBreakSystem; +import com.disklexar.mmorpg.combat.system.MmorpgDamageSystem; +import com.disklexar.mmorpg.combat.system.MmorpgDeathSystem; +import com.disklexar.mmorpg.command.MmorpgCommand; +import com.disklexar.mmorpg.economy.EconomyService; +import com.disklexar.mmorpg.events.AbilityPacketFilter; +import com.disklexar.mmorpg.events.PlayerConnectionHandler; +import com.disklexar.mmorpg.interaction.MmorpgCastAbilityInteraction; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.group.GroupService; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.disklexar.mmorpg.ui.PlayerInfoPage; +import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent; +import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent; +import com.hypixel.hytale.server.core.io.adapter.PacketAdapters; +import com.hypixel.hytale.server.core.io.adapter.PacketFilter; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Interaction; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.server.OpenCustomUIInteraction; +import com.hypixel.hytale.server.core.plugin.JavaPlugin; +import com.hypixel.hytale.server.core.plugin.JavaPluginInit; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.logging.Level; + +/** + * Main entry point for the Disklexar Hytale MMORPG plugin. + */ +public final class MmorpgPlugin extends JavaPlugin { + + private static MmorpgPlugin instance; + + private Bootstrap bootstrap; + private ClassAbilityController classAbilityController; + private PlayerConnectionHandler connectionHandler; + private AbilityPacketFilter abilityPacketFilter; + private PacketFilter inboundPacketFilter; + + public MmorpgPlugin(@Nonnull JavaPluginInit init) { + super(init); + } + + @Nullable + public static MmorpgPlugin get() { + return instance; + } + + @Nullable + public Bootstrap getBootstrap() { + return bootstrap; + } + + @Nonnull + public ClassAbilityController getClassAbilityController() { + return classAbilityController; + } + + @Override + protected void setup() { + instance = this; + classAbilityController = new ClassAbilityController(this); + connectionHandler = new PlayerConnectionHandler(this); + abilityPacketFilter = new AbilityPacketFilter(classAbilityController); + + try { + bootstrap = new Bootstrap(this); + bootstrap.initialize(); + } catch (Exception e) { + getLogger().at(Level.SEVERE).log("MMORPG bootstrap failed: %s", e.getMessage()); + throw new IllegalStateException("Failed to initialize MMORPG plugin", e); + } + + getCodecRegistry(Interaction.CODEC).register( + MmorpgCastAbilityInteraction.ID, + MmorpgCastAbilityInteraction.class, + MmorpgCastAbilityInteraction.CODEC); + + getCommandRegistry().registerCommand(new MmorpgCommand(this)); + getEventRegistry().registerGlobal(PlayerReadyEvent.class, connectionHandler::onPlayerReady); + getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, connectionHandler::onPlayerDisconnect); + inboundPacketFilter = PacketAdapters.registerInbound(abilityPacketFilter); + + registerGameplaySystems(); + + getLogger().at(Level.INFO).log("MMORPG plugin setup complete"); + } + + private void registerGameplaySystems() { + ServiceRegistry registry = bootstrap.getRegistry(); + PlayerSessionService sessions = registry.require(PlayerSessionService.class); + CombatStateService combatState = registry.require(CombatStateService.class); + CombatBuffService combatBuffs = registry.require(CombatBuffService.class); + PoisonService poisonService = registry.require(PoisonService.class); + OnlinePlayerRegistry onlinePlayers = registry.require(OnlinePlayerRegistry.class); + ProgressionService progression = registry.require(ProgressionService.class); + GroupService groups = registry.require(GroupService.class); + JobService jobs = registry.require(JobService.class); + EconomyService economy = registry.require(EconomyService.class); + int killExperience = bootstrap.getConfig().getKillExperience(); + + getEntityStoreRegistry().registerSystem( + new MmorpgDamageSystem(sessions, combatState, combatBuffs, poisonService, onlinePlayers)); + getEntityStoreRegistry().registerSystem( + new MmorpgDeathSystem(sessions, progression, groups, jobs, economy, killExperience)); + getEntityStoreRegistry().registerSystem(new MinerBlockBreakSystem(sessions, jobs, economy)); + + OpenCustomUIInteraction.registerSimple(this, PlayerInfoPage.class, "mmorpg_player_info", playerRef -> { + PlayerProfile profile = sessions.getSession(playerRef.getUuid()); + if (profile == null) { + profile = new PlayerProfile(playerRef.getUuid(), playerRef.getUsername(), System.currentTimeMillis()); + } + return new PlayerInfoPage(playerRef, profile); + }); + } + + @Override + protected void start() { + if (bootstrap != null) { + bootstrap.startServices(); + } + getLogger().at(Level.INFO).log("MMORPG plugin started"); + } + + @Override + protected void shutdown() { + if (inboundPacketFilter != null) { + PacketAdapters.deregisterInbound(inboundPacketFilter); + inboundPacketFilter = null; + } + if (bootstrap != null) { + bootstrap.shutdownServices(); + bootstrap = null; + } + instance = null; + getLogger().at(Level.INFO).log("MMORPG plugin shut down"); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/api/package-info.java b/src/main/java/com/disklexar/mmorpg/api/package-info.java new file mode 100644 index 0000000..75ce7f1 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/api/package-info.java @@ -0,0 +1,4 @@ +/** + * Public API interfaces for cross-module integration (future extraction to separate plugins). + */ +package com.disklexar.mmorpg.api; diff --git a/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java b/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java new file mode 100644 index 0000000..693b54e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java @@ -0,0 +1,122 @@ +package com.disklexar.mmorpg.bootstrap; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.combat.AbilityService; +import com.disklexar.mmorpg.combat.CombatBuffService; +import com.disklexar.mmorpg.combat.CombatStateService; +import com.disklexar.mmorpg.combat.MmorpgScheduler; +import com.disklexar.mmorpg.combat.PassiveEffectService; +import com.disklexar.mmorpg.combat.PoisonService; +import com.disklexar.mmorpg.combat.StunService; +import com.disklexar.mmorpg.core.config.MmorpgConfig; +import com.disklexar.mmorpg.economy.EconomyService; +import com.disklexar.mmorpg.persistence.DatabaseManager; +import com.disklexar.mmorpg.persistence.repository.GroupRepository; +import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.rpg.group.GroupService; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.disklexar.mmorpg.rpg.power.PowerService; +import com.disklexar.mmorpg.rpg.race.RaceService; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.util.logging.Level; + +/** + * Wires and starts all MMORPG services. + */ +public final class Bootstrap { + + private final MmorpgPlugin plugin; + private final HytaleLogger logger; + private final ServiceRegistry registry = new ServiceRegistry(); + private MmorpgConfig config; + + public Bootstrap(@Nonnull MmorpgPlugin plugin) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + } + + public void initialize() throws IOException { + config = MmorpgConfig.load(plugin.getDataDirectory()); + + // Persistence (DatabaseManager must start first so repositories can query). + DatabaseManager databaseManager = new DatabaseManager(plugin, config); + PlayerProfileRepository profileRepository = new PlayerProfileRepository(databaseManager); + GroupRepository groupRepository = new GroupRepository(databaseManager); + + // Stateless domain services. + ProgressionService progression = new ProgressionService(config.getBaseXpPerLevel()); + EconomyService economy = new EconomyService(); + ClassService classService = new ClassService(); + PowerService powerService = new PowerService(); + JobService jobService = new JobService(); + RaceService raceService = new RaceService(); + CombatStateService combatState = new CombatStateService(); + CombatBuffService combatBuffs = new CombatBuffService(); + StunService stunService = new StunService(); + OnlinePlayerRegistry onlinePlayers = new OnlinePlayerRegistry(); + + // Session and lifecycle services. + PlayerSessionService sessionService = + new PlayerSessionService(plugin, config, profileRepository); + MmorpgScheduler scheduler = new MmorpgScheduler(plugin); + PoisonService poisonService = new PoisonService(scheduler); + GroupService groupService = + new GroupService(plugin, groupRepository, sessionService, progression); + AbilityService abilityService = + new AbilityService(scheduler, stunService, combatState, combatBuffs); + PassiveEffectService passiveEffects = + new PassiveEffectService(plugin, scheduler, onlinePlayers, sessionService, combatState); + AbilityBarService abilityBarService = new AbilityBarService( + plugin, classService, abilityService, onlinePlayers, sessionService, progression, scheduler); + + registry.register(MmorpgConfig.class, config); + registry.register(DatabaseManager.class, databaseManager); + registry.register(PlayerProfileRepository.class, profileRepository); + registry.register(GroupRepository.class, groupRepository); + registry.register(ProgressionService.class, progression); + registry.register(EconomyService.class, economy); + registry.register(ClassService.class, classService); + registry.register(PowerService.class, powerService); + registry.register(JobService.class, jobService); + registry.register(RaceService.class, raceService); + registry.register(CombatStateService.class, combatState); + registry.register(CombatBuffService.class, combatBuffs); + registry.register(PoisonService.class, poisonService); + registry.register(StunService.class, stunService); + registry.register(OnlinePlayerRegistry.class, onlinePlayers); + registry.register(PlayerSessionService.class, sessionService); + registry.register(MmorpgScheduler.class, scheduler); + registry.register(GroupService.class, groupService); + registry.register(AbilityService.class, abilityService); + registry.register(AbilityBarService.class, abilityBarService); + registry.register(PassiveEffectService.class, passiveEffects); + + logger.at(Level.INFO).log("MMORPG bootstrap initialized"); + } + + public void startServices() { + registry.startAll(); + } + + public void shutdownServices() { + registry.shutdownAll(); + } + + @Nonnull + public ServiceRegistry getRegistry() { + return registry; + } + + @Nonnull + public MmorpgConfig getConfig() { + return config; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/bootstrap/ServiceRegistry.java b/src/main/java/com/disklexar/mmorpg/bootstrap/ServiceRegistry.java new file mode 100644 index 0000000..0a6d775 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/bootstrap/ServiceRegistry.java @@ -0,0 +1,52 @@ +package com.disklexar.mmorpg.bootstrap; + +import com.disklexar.mmorpg.core.service.Service; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Simple service locator for MMORPG components. + */ +public final class ServiceRegistry { + + private final Map, Object> services = new HashMap<>(); + private final List lifecycleServices = new ArrayList<>(); + + public void register(@Nonnull Class type, @Nonnull T instance) { + services.put(type, instance); + if (instance instanceof Service service) { + lifecycleServices.add(service); + } + } + + @Nullable + public T get(@Nonnull Class type) { + return type.cast(services.get(type)); + } + + @Nonnull + public T require(@Nonnull Class type) { + T service = get(type); + if (service == null) { + throw new IllegalStateException("Service not registered: " + type.getName()); + } + return service; + } + + public void startAll() { + for (Service service : lifecycleServices) { + service.start(); + } + } + + public void shutdownAll() { + for (int i = lifecycleServices.size() - 1; i >= 0; i--) { + lifecycleServices.get(i).shutdown(); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java b/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java new file mode 100644 index 0000000..f9948cf --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/AbilityService.java @@ -0,0 +1,747 @@ +package com.disklexar.mmorpg.combat; + +import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition; +import com.disklexar.mmorpg.rpg.clazz.ClassCatalog; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.modules.entity.component.HeadRotation; +import com.hypixel.hytale.server.core.inventory.InventoryComponent; +import com.hypixel.hytale.server.core.inventory.ItemStack; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems; +import com.hypixel.hytale.server.core.modules.physics.component.Velocity; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.server.core.util.TargetUtil; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Executes the active class abilities. Effects are applied with the verified server APIs + * ({@link TargetUtil}, {@link DamageSystems#executeDamage}, {@link Velocity}); exact numbers + * (dash speed, radii, damage) are tuned via {@code config.json}-style constants and may need + * in-game adjustment. + */ +public final class AbilityService { + + // Knockback impulse for the forward dash. The vertical component matters a lot: too little and + // ground friction kills the lunge instantly (the previous ~0.5 block issue). Tunable. + private static final double DASH_KNOCKBACK = 16.0; + private static final double DASH_KNOCKBACK_Y = 4.0; + private static final double DASH_RADIUS = 2.5; + private static final long DASH_SCAN_PERIOD = 100L; + private static final int DASH_SCANS = 5; + private static final long STUN_DURATION = 3_000L; + + private static final double SPIN_RADIUS = 3.0; + private static final float SPIN_DAMAGE = 3.0f; + private static final long SPIN_DURATION = 3_000L; + private static final long SPIN_PERIOD = 300L; + + private static final double SHOUT_RADIUS = 5.0; + private static final double SHOUT_KNOCKBACK = 18.0; + private static final double SHOUT_KNOCKBACK_Y = 4.0; + private static final float SHOUT_STUNNED_BONUS_DAMAGE = 6.0f; + private static final long RESISTANCE_DEBUFF_DURATION = 20_000L; + + // --- Archer --- + private static final long RAPID_FIRE_DURATION = 10_000L; + private static final int VOLLEY_ARROWS = 6; + private static final double VOLLEY_SPREAD_DEG = 9.0; + private static final float VOLLEY_ARROW_DAMAGE = 4.0f; + private static final double VOLLEY_RANGE = 18.0; + private static final double VOLLEY_BACK_KNOCKBACK = 12.0; + private static final double VOLLEY_BACK_KNOCKBACK_Y = 3.0; + + // --- Mage --- + private static final int FIREBALL_COUNT = 3; + private static final double FIREBALL_SPREAD_DEG = 12.0; + private static final float FIREBALL_DAMAGE = 7.0f; + private static final double FIREBALL_RANGE = 20.0; + private static final double FIREBALL_HIT_RADIUS = 1.6; + private static final double TELEPORT_DISTANCE = 8.0; + private static final double TORNADO_DISTANCE = 15.0; + private static final double TORNADO_RADIUS = 3.5; + private static final float TORNADO_DAMAGE = 4.0f; + private static final double TORNADO_PULL = 6.0; + private static final long TORNADO_DURATION = 4_000L; + private static final long TORNADO_PERIOD = 350L; + + // --- Assassin --- + private static final long CAMOUFLAGE_DURATION = 6_000L; + private static final double SHADOWSTEP_RANGE = 12.0; + private static final double SHADOWSTEP_BEHIND = 1.5; + private static final long POISON_BLADE_DURATION = 10_000L; + + // Projectile / spell visuals (shipped game particle systems; unknown ids fail silently). + private static final String VFX_ARROW_TRAIL = "Bow_Signature_Projectile_Sparks"; + private static final String VFX_ARROW_HIT = "Example_Projectile_Impact"; + private static final String VFX_FIRE_TRAIL = "Fire_Charged1"; + private static final String VFX_FIRE_HIT = "Impact_Explosion"; + private static final String VFX_TELEPORT = "Teleport"; + private static final String VFX_TORNADO = "Example_Tornado"; + private static final String VFX_TORNADO_HIT = "Impact_Battleaxe_Bash"; + private static final String VFX_SMOKE = "Smoke_Black"; + private static final String VFX_BUFF = "Test_Cast_Buff"; + private static final String SFX_BOW = "SFX_Bow_T1_Shoot"; + private static final String SFX_FIRE = "SFX_Staff_Flame_Fireball_Launch"; + private static final String SFX_TELEPORT = "SFX_Portal_Neutral"; + + // Visual / audio asset ids (shipped game assets). See CombatFx. + private static final String VFX_DASH = "Daggers_Signature_Dash"; + private static final String VFX_HIT = "Impact_Blade_01"; + private static final String VFX_SPIN = "Battleaxe_Signature_Whirlwind"; + private static final String VFX_SPIN_HIT = "Impact_Battleaxe_Bash"; + private static final String VFX_SHOUT = "Impact_Explosion"; + + private static final String SFX_ACTIVATE = "SFX_Avatar_Powers_Enable"; + private static final String SFX_SWING = "SFX_Golem_Earth_Swing"; + private static final String SFX_HIT = "SFX_Golem_Earth_Swing_Impact"; + private static final String SFX_SHOUT = "SFX_Deer_Stag_Roar"; + private static final String SFX_SHOUT_IMPACT = "SFX_Golem_Earth_Slam_Impact"; + + // Weapon animation moves (see Server/Item/Animations/{Battleaxe,Longsword}.json). + private static final String ANIM_SET_BATTLEAXE = "Battleaxe"; + private static final String ANIM_SET_LONGSWORD = "Longsword"; + private static final String ANIM_DASH = "SwingDown"; + private static final String ANIM_SHOUT = "GuardBash"; + private static final String ANIM_SPIN_BATTLEAXE = "WhirlwindChargedSpin"; + private static final String ANIM_SPIN_LONGSWORD_A = "SwingLeft"; + private static final String ANIM_SPIN_LONGSWORD_B = "SwingRight"; + + private final MmorpgScheduler scheduler; + private final StunService stunService; + private final CombatStateService combatState; + private final CombatBuffService combatBuffs; + + private final Map> cooldowns = new ConcurrentHashMap<>(); + + public AbilityService( + @Nonnull MmorpgScheduler scheduler, + @Nonnull StunService stunService, + @Nonnull CombatStateService combatState, + @Nonnull CombatBuffService combatBuffs) { + this.scheduler = scheduler; + this.stunService = stunService; + this.combatState = combatState; + this.combatBuffs = combatBuffs; + } + + public long cooldownRemaining(@Nonnull UUID player, @Nonnull String abilityId) { + Map map = cooldowns.get(player); + if (map == null) { + return 0L; + } + Long readyAt = map.get(abilityId); + if (readyAt == null) { + return 0L; + } + return Math.max(0L, readyAt - System.currentTimeMillis()); + } + + /** + * Attempts to cast {@code ability} for the player. The caller is responsible for verifying the + * player owns the class; this method enforces the weapon requirement and the cooldown. + */ + @Nonnull + public CastResult cast( + @Nonnull PlayerRef playerRef, + @Nonnull World world, + @Nonnull PlayerClass playerClass, + @Nonnull AbilityDefinition ability) { + return cast(playerRef, world, playerClass, ability, null); + } + + /** + * Casts {@code ability}. {@code aimedTarget} is the entity the player is currently looking at + * (supplied by the input-action handler, used by abilities such as the assassin's Shadow Step); + * it may be {@code null} when triggered from the command, in which case the target is resolved + * from the caster's aim. + */ + @Nonnull + public CastResult cast( + @Nonnull PlayerRef playerRef, + @Nonnull World world, + @Nonnull PlayerClass playerClass, + @Nonnull AbilityDefinition ability, + @Nullable Ref aimedTarget) { + UUID caster = playerRef.getUuid(); + Store store = world.getEntityStore().getStore(); + Ref casterRef = playerRef.getReference(); + + if (!hasRequiredWeapon(store, casterRef, playerClass)) { + return CastResult.WRONG_WEAPON; + } + if (cooldownRemaining(caster, ability.id()) > 0) { + return CastResult.ON_COOLDOWN; + } + + String weaponSet = resolveWeaponAnimSet(store, casterRef); + switch (ability.id()) { + case ClassCatalog.ABILITY_DASH -> castDash(world, store, casterRef, weaponSet); + case ClassCatalog.ABILITY_SPIN -> castSpin(world, store, casterRef, weaponSet); + case ClassCatalog.ABILITY_SHOUT -> castShout(world, store, casterRef, weaponSet); + + case ClassCatalog.ABILITY_ARCHER_RAPIDFIRE -> castRapidFire(world, store, casterRef, caster); + case ClassCatalog.ABILITY_ARCHER_VOLLEY -> castVolley(world, store, casterRef); + case ClassCatalog.ABILITY_ARCHER_POWERSHOT -> castPowerShot(world, store, casterRef, caster); + + case ClassCatalog.ABILITY_MAGE_FIREBALLS -> castFireballs(world, store, casterRef); + case ClassCatalog.ABILITY_MAGE_TELEPORT -> castTeleport(world, store, casterRef); + case ClassCatalog.ABILITY_MAGE_TORNADO -> castTornado(world, store, casterRef); + + case ClassCatalog.ABILITY_ASSASSIN_CAMOUFLAGE -> castCamouflage(world, store, casterRef, playerRef); + case ClassCatalog.ABILITY_ASSASSIN_SHADOWSTEP -> castShadowStep(world, store, casterRef, aimedTarget); + case ClassCatalog.ABILITY_ASSASSIN_POISONBLADE -> castPoisonBlade(world, store, casterRef, caster); + default -> { + return CastResult.UNKNOWN; + } + } + + combatState.markCombat(caster); + cooldowns.computeIfAbsent(caster, k -> new ConcurrentHashMap<>()) + .put(ability.id(), System.currentTimeMillis() + ability.cooldownMillis()); + return CastResult.OK; + } + + /** + * Maps the currently held weapon to its animation set id ({@code Battleaxe} / {@code Longsword}), + * so we can trigger the matching swing/whirlwind move. Returns {@code null} if it cannot be + * resolved (the animation step is then skipped, effects still play). + */ + private String resolveWeaponAnimSet( + @Nonnull Store store, + @Nonnull Ref casterRef) { + try { + ItemStack held = InventoryComponent.getItemInHand(store, casterRef); + if (ItemStack.isEmpty(held)) { + return null; + } + String itemId = held.getItemId(); + if (itemId == null) { + return null; + } + String lower = itemId.toLowerCase(); + if (lower.contains("battleaxe") || (lower.contains("axe") && !lower.contains("pickaxe"))) { + return ANIM_SET_BATTLEAXE; + } + return ANIM_SET_LONGSWORD; + } catch (Throwable t) { + return null; + } + } + + private boolean hasRequiredWeapon( + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull PlayerClass playerClass) { + try { + ItemStack held = InventoryComponent.getItemInHand(store, casterRef); + if (ItemStack.isEmpty(held)) { + return false; + } + String itemId = held.getItemId(); + if (itemId == null) { + return false; + } + for (String weapon : playerClass.weapons()) { + if (itemId.toLowerCase().contains(weapon.toLowerCase())) { + return true; + } + } + return false; + } catch (Throwable t) { + // If the inventory API shape differs at runtime, fail open so the ability is still usable. + return true; + } + } + + private void castDash( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + String weaponSet) { + world.execute(() -> { + Vector3d direction = horizontalLookDirection(store, casterRef); + // Player movement is client-authoritative, so push the caster via the engine knockback + // system (syncs to the client) instead of writing Velocity directly. + CombatFx.knockback(store, casterRef, new Vector3d( + direction.x * DASH_KNOCKBACK, DASH_KNOCKBACK_Y, direction.z * DASH_KNOCKBACK)); + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + Vector3d pos = transform != null ? transform.getPosition() : new Vector3d(); + CombatFx.animation(store, casterRef, weaponSet, ANIM_DASH); + CombatFx.vfx(store, VFX_DASH, pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.2f); + CombatFx.sound(store, SFX_SWING, pos); + }); + + // Stun every enemy the dash passes through over its travel window. + for (int scan = 1; scan <= DASH_SCANS; scan++) { + scheduler.runLater(() -> world.execute(() -> { + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (transform == null) { + return; + } + Vector3d pos = transform.getPosition(); + for (Ref target : TargetUtil.getAllEntitiesInSphere( + pos, DASH_RADIUS, store)) { + if (target.equals(casterRef)) { + continue; + } + applyStun(world, store, target); + dealDamage(store, casterRef, target, 2.0f); + CombatFx.vfx(store, VFX_HIT, targetPosition(store, target, pos)); + CombatFx.sound(store, SFX_HIT, pos); + } + }), (long) scan * DASH_SCAN_PERIOD); + } + } + + private void castSpin( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + String weaponSet) { + String spinAnimA = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_A; + String spinAnimB = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_B; + java.util.concurrent.atomic.AtomicInteger tick = new java.util.concurrent.atomic.AtomicInteger(); + + var future = scheduler.runRepeating(() -> world.execute(() -> { + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (transform == null) { + return; + } + Vector3d pos = transform.getPosition(); + int n = tick.getAndIncrement(); + // Keep the whirlwind spinning visually and audibly for its whole duration. Each puff is + // spawned with a short, explicit lifetime so it follows the player and fully disappears + // when the spin ends (otherwise the looping aura would stay on screen forever). + CombatFx.animation(store, casterRef, weaponSet, (n % 2 == 0) ? spinAnimA : spinAnimB); + CombatFx.vfxTimed(store, VFX_SPIN, pos, 1.0f, (SPIN_PERIOD / 1000.0f) * 1.2f); + CombatFx.sound(store, SFX_SWING, pos, 0.8f, 1.1f); + for (Ref target : TargetUtil.getAllEntitiesInSphere(pos, SPIN_RADIUS, store)) { + if (target.equals(casterRef)) { + continue; + } + dealDamage(store, casterRef, target, SPIN_DAMAGE); + CombatFx.vfx(store, VFX_SPIN_HIT, targetPosition(store, target, pos)); + } + }), 0L, SPIN_PERIOD); + scheduler.runLater(() -> future.cancel(false), SPIN_DURATION); + } + + private void castShout( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + String weaponSet) { + world.execute(() -> { + TransformComponent casterTransform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (casterTransform == null) { + return; + } + Vector3d origin = casterTransform.getPosition(); + // Fallback push direction (caster's facing) for targets standing right on top of the + // player, where origin-to-target has no usable direction. + Vector3d facing = horizontalLookDirection(store, casterRef); + CombatFx.animation(store, casterRef, weaponSet, ANIM_SHOUT); + CombatFx.vfx(store, VFX_SHOUT, origin); + CombatFx.sound(store, SFX_SHOUT, origin, 1.2f, 0.9f); + CombatFx.sound(store, SFX_SHOUT_IMPACT, origin); + for (Ref target : TargetUtil.getAllEntitiesInSphere(origin, SHOUT_RADIUS, store)) { + if (target.equals(casterRef)) { + continue; + } + TransformComponent targetTransform = + store.getComponent(target, TransformComponent.getComponentType()); + if (targetTransform != null) { + Vector3d push = new Vector3d(targetTransform.getPosition()).sub(origin); + push.y = 0; + if (push.lengthSquared() < 1.0e-4) { + // Mob glued to the player: shove it away along the caster's look direction. + push.set(facing.x, 0, facing.z); + } + if (push.lengthSquared() < 1.0e-4) { + push.set(0, 0, 1); + } + push.normalize(); + // Client-synced knockback so both players and mobs are actually pushed back. + CombatFx.knockback(store, target, new Vector3d( + push.x * SHOUT_KNOCKBACK, SHOUT_KNOCKBACK_Y, push.z * SHOUT_KNOCKBACK)); + } + + UUID targetUuid = entityUuid(store, target); + if (targetUuid != null) { + if (stunService.isStunned(targetUuid)) { + dealDamage(store, casterRef, target, SHOUT_STUNNED_BONUS_DAMAGE); + } + // -20% resistance == takes +20% damage for 20s (applied in the damage system). + combatState.applyResistanceDebuff(targetUuid, RESISTANCE_DEBUFF_DURATION); + } + } + }); + } + + // ------------------------------------------------------------------------------------------ + // Archer + // ------------------------------------------------------------------------------------------ + + private void castRapidFire( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull UUID caster) { + combatBuffs.grantRapidFire(caster, RAPID_FIRE_DURATION); + world.execute(() -> { + Vector3d pos = position(store, casterRef); + CombatFx.vfx(store, VFX_BUFF, pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.4f); + }); + } + + private void castVolley( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef) { + world.execute(() -> { + Vector3d facing = horizontalLookDirection(store, casterRef); + // Bond en arrière. + CombatFx.knockback(store, casterRef, new Vector3d( + -facing.x * VOLLEY_BACK_KNOCKBACK, VOLLEY_BACK_KNOCKBACK_Y, -facing.z * VOLLEY_BACK_KNOCKBACK)); + Vector3d eye = eyePosition(store, casterRef); + CombatFx.sound(store, SFX_BOW, eye, 1.0f, 1.1f); + double start = -(VOLLEY_ARROWS - 1) / 2.0; + for (int i = 0; i < VOLLEY_ARROWS; i++) { + Vector3d dir = rotatedHorizontal(facing, (start + i) * VOLLEY_SPREAD_DEG); + fireProjectile(store, casterRef, eye, dir, VOLLEY_RANGE, 1.2, + VOLLEY_ARROW_DAMAGE, VFX_ARROW_TRAIL, VFX_ARROW_HIT); + } + }); + } + + private void castPowerShot( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull UUID caster) { + combatBuffs.chargePowerShot(caster); + world.execute(() -> { + Vector3d pos = position(store, casterRef); + CombatFx.vfx(store, VFX_BUFF, pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.8f); + }); + } + + // ------------------------------------------------------------------------------------------ + // Mage + // ------------------------------------------------------------------------------------------ + + private void castFireballs( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef) { + world.execute(() -> { + Vector3d facing = horizontalLookDirection(store, casterRef); + Vector3d eye = eyePosition(store, casterRef); + CombatFx.sound(store, SFX_FIRE, eye, 1.1f, 1.0f); + double start = -(FIREBALL_COUNT - 1) / 2.0; + for (int i = 0; i < FIREBALL_COUNT; i++) { + Vector3d dir = rotatedHorizontal(facing, (start + i) * FIREBALL_SPREAD_DEG); + fireProjectile(store, casterRef, eye, dir, FIREBALL_RANGE, FIREBALL_HIT_RADIUS, + FIREBALL_DAMAGE, VFX_FIRE_TRAIL, VFX_FIRE_HIT); + } + }); + } + + private void castTeleport( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef) { + world.execute(() -> { + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (transform == null) { + return; + } + Vector3d from = new Vector3d(transform.getPosition()); + Vector3d dir = horizontalLookDirection(store, casterRef); + Vector3d to = new Vector3d(from).add(dir.x * TELEPORT_DISTANCE, 0, dir.z * TELEPORT_DISTANCE); + CombatFx.vfx(store, VFX_TELEPORT, from); + CombatFx.sound(store, SFX_TELEPORT, from, 1.0f, 1.0f); + transform.teleportPosition(to); + CombatFx.vfx(store, VFX_TELEPORT, to); + CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.2f); + }); + } + + private void castTornado( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef) { + Vector3d[] center = new Vector3d[1]; + world.execute(() -> { + Vector3d eye = eyePosition(store, casterRef); + Vector3d dir = horizontalLookDirection(store, casterRef); + center[0] = new Vector3d(eye).add(dir.x * TORNADO_DISTANCE, -1.0, dir.z * TORNADO_DISTANCE); + CombatFx.sound(store, SFX_FIRE, center[0], 1.3f, 0.7f); + }); + var future = scheduler.runRepeating(() -> world.execute(() -> { + if (center[0] == null) { + return; + } + Vector3d pos = center[0]; + CombatFx.vfxTimed(store, VFX_TORNADO, pos, 1.5f, (TORNADO_PERIOD / 1000.0f) * 1.3f); + for (Ref target : TargetUtil.getAllEntitiesInSphere(pos, TORNADO_RADIUS, store)) { + if (target.equals(casterRef)) { + continue; + } + Vector3d tpos = targetPosition(store, target, pos); + Vector3d pull = new Vector3d(pos).sub(tpos); + pull.y = 0; + if (pull.lengthSquared() > 1.0e-4) { + pull.normalize(); + } + CombatFx.knockback(store, target, new Vector3d( + pull.x * TORNADO_PULL, 2.5, pull.z * TORNADO_PULL)); + dealDamage(store, casterRef, target, TORNADO_DAMAGE); + CombatFx.vfx(store, VFX_TORNADO_HIT, tpos); + } + }), 0L, TORNADO_PERIOD); + scheduler.runLater(() -> future.cancel(false), TORNADO_DURATION); + } + + // ------------------------------------------------------------------------------------------ + // Assassin + // ------------------------------------------------------------------------------------------ + + private void castCamouflage( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull PlayerRef playerRef) { + UUID caster = playerRef.getUuid(); + combatBuffs.markInvisible(caster, CAMOUFLAGE_DURATION); + world.execute(() -> { + Vector3d pos = position(store, casterRef); + CombatFx.vfx(store, VFX_SMOKE, pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.7f); + for (PlayerRef viewer : world.getPlayerRefs()) { + try { + viewer.getHiddenPlayersManager().hidePlayer(caster); + } catch (Throwable ignored) { + // viewer not ready: skip. + } + } + }); + scheduler.runLater(() -> world.execute(() -> { + for (PlayerRef viewer : world.getPlayerRefs()) { + try { + viewer.getHiddenPlayersManager().showPlayer(caster); + } catch (Throwable ignored) { + // viewer gone: skip. + } + } + CombatFx.vfx(store, VFX_SMOKE, position(store, casterRef)); + }), CAMOUFLAGE_DURATION); + } + + private void castShadowStep( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nullable Ref aimedTarget) { + world.execute(() -> { + Ref target = aimedTarget; + if (target == null || !target.isValid() || target.equals(casterRef)) { + target = TargetUtil.getTargetEntity(casterRef, (float) SHADOWSTEP_RANGE, store); + } + TransformComponent casterTransform = + store.getComponent(casterRef, TransformComponent.getComponentType()); + if (casterTransform == null) { + return; + } + if (target == null || !target.isValid() || target.equals(casterRef)) { + // No target in sight: short blink forward instead of failing outright. + Vector3d dir = horizontalLookDirection(store, casterRef); + Vector3d from = new Vector3d(casterTransform.getPosition()); + Vector3d to = new Vector3d(from).add(dir.x * 3.0, 0, dir.z * 3.0); + CombatFx.vfx(store, VFX_TELEPORT, from); + casterTransform.teleportPosition(to); + CombatFx.vfx(store, VFX_TELEPORT, to); + CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.0f); + return; + } + TransformComponent targetTransform = + store.getComponent(target, TransformComponent.getComponentType()); + if (targetTransform == null) { + return; + } + Vector3d targetPos = new Vector3d(targetTransform.getPosition()); + Vector3d targetFacing = horizontalLookDirection(store, target); + Vector3d behind = new Vector3d(targetPos) + .sub(targetFacing.x * SHADOWSTEP_BEHIND, 0, targetFacing.z * SHADOWSTEP_BEHIND); + Vector3d from = new Vector3d(casterTransform.getPosition()); + CombatFx.vfx(store, VFX_SMOKE, from); + casterTransform.teleportPosition(behind); + CombatFx.vfx(store, VFX_TELEPORT, behind); + CombatFx.sound(store, SFX_TELEPORT, behind, 1.0f, 0.9f); + }); + } + + private void castPoisonBlade( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull UUID caster) { + combatBuffs.grantPoisonBlade(caster, POISON_BLADE_DURATION); + world.execute(() -> { + Vector3d pos = position(store, casterRef); + CombatFx.vfx(store, "Impact_Poison", pos); + CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.9f); + }); + } + + // ------------------------------------------------------------------------------------------ + // Shared helpers for the new abilities + // ------------------------------------------------------------------------------------------ + + /** + * Casts a straight projectile by stepping along {@code dir} from {@code origin}, leaving a + * particle streak and damaging the first entity within {@code hitRadius} of the path. A + * server-side approximation of a real arrow/fireball (no engine projectile entity is spawned), + * but it reads as a projectile and deals damage. + */ + private void fireProjectile( + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull Vector3d origin, + @Nonnull Vector3d dir, + double range, + double hitRadius, + float damage, + @Nonnull String trailVfx, + @Nonnull String hitVfx) { + Vector3d point = new Vector3d(origin); + Vector3d step = new Vector3d(dir).mul(1.0); + int steps = (int) Math.ceil(range); + for (int i = 0; i < steps; i++) { + point.add(step); + CombatFx.vfxTimed(store, trailVfx, new Vector3d(point), 0.5f, 0.35f); + for (Ref target : TargetUtil.getAllEntitiesInSphere(point, hitRadius, store)) { + if (target.equals(casterRef)) { + continue; + } + dealDamage(store, casterRef, target, damage); + CombatFx.vfx(store, hitVfx, targetPosition(store, target, point)); + return; + } + } + } + + @Nonnull + private Vector3d rotatedHorizontal(@Nonnull Vector3d base, double degrees) { + Vector3d v = new Vector3d(base); + v.y = 0; + v.rotateY((float) Math.toRadians(degrees)); + if (v.lengthSquared() < 1.0e-4) { + return new Vector3d(0, 0, 1); + } + return v.normalize(); + } + + @Nonnull + private Vector3d eyePosition(@Nonnull Store store, @Nonnull Ref ref) { + return new Vector3d(position(store, ref)).add(0, 1.5, 0); + } + + @Nonnull + private Vector3d position(@Nonnull Store store, @Nonnull Ref ref) { + TransformComponent transform = store.getComponent(ref, TransformComponent.getComponentType()); + return transform != null ? new Vector3d(transform.getPosition()) : new Vector3d(); + } + + private void applyStun( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref target) { + UUID uuid = entityUuid(store, target); + if (uuid != null) { + if (stunService.isStunned(uuid)) { + return; + } + stunService.stun(uuid, STUN_DURATION); + } + var future = scheduler.runRepeating(() -> world.execute(() -> { + Velocity velocity = store.getComponent(target, Velocity.getComponentType()); + if (velocity != null) { + velocity.setZero(); + } + }), 0L, 100L); + scheduler.runLater(() -> future.cancel(false), STUN_DURATION); + } + + private void dealDamage( + @Nonnull Store store, + @Nonnull Ref casterRef, + @Nonnull Ref target, + float amount) { + Damage damage = new Damage(new Damage.EntitySource(casterRef), DamageCause.PHYSICAL, amount); + DamageSystems.executeDamage(target, store, damage); + } + + @Nonnull + private Vector3d horizontalLookDirection( + @Nonnull Store store, + @Nonnull Ref casterRef) { + // Use the HEAD rotation (precise camera yaw), not the body TransformComponent rotation which + // is coarse/axis-snapped — that snapping made the dash align to X/Z instead of the true aim. + // This mirrors how the engine itself derives a knockback direction (see KnockbackApplyCommand). + Float yaw = null; + HeadRotation head = store.getComponent(casterRef, HeadRotation.getComponentType()); + if (head != null && head.getRotation() != null) { + yaw = head.getRotation().yaw(); + } else { + TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType()); + if (transform != null && transform.getRotation() != null) { + yaw = transform.getRotation().yaw(); + } + } + if (yaw == null) { + return new Vector3d(0, 0, 1); + } + Vector3d direction = new Vector3d(0, 0, -1).rotateY(yaw); + direction.y = 0; + if (direction.lengthSquared() < 1.0e-4) { + return new Vector3d(0, 0, 1); + } + return direction.normalize(); + } + + @Nonnull + private Vector3d targetPosition( + @Nonnull Store store, + @Nonnull Ref target, + @Nonnull Vector3d fallback) { + TransformComponent transform = store.getComponent(target, TransformComponent.getComponentType()); + return transform != null ? transform.getPosition() : fallback; + } + + private UUID entityUuid(@Nonnull Store store, @Nonnull Ref ref) { + UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType()); + return component == null ? null : component.getUuid(); + } + + public enum CastResult { + OK, ON_COOLDOWN, WRONG_WEAPON, UNKNOWN + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/ClassAbilityController.java b/src/main/java/com/disklexar/mmorpg/combat/ClassAbilityController.java new file mode 100644 index 0000000..b5ab98e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/ClassAbilityController.java @@ -0,0 +1,119 @@ +package com.disklexar.mmorpg.combat; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Central entry point for routing Ability 1/2/3 inputs to MMORPG class abilities. + * Used by the packet filter, native {@code mmorpg_cast_ability} interaction, and commands. + */ +public final class ClassAbilityController { + + private final MmorpgPlugin plugin; + + public ClassAbilityController(@Nonnull MmorpgPlugin plugin) { + this.plugin = plugin; + } + + /** + * @return {@code true} when the player has an MMORPG class and vanilla ability interactions + * should be suppressed for this slot (even if the cast fails, e.g. wrong weapon). + */ + public boolean shouldSuppressVanilla(@Nonnull PlayerRef playerRef, int slot) { + return resolveAbility(playerRef, slot) != null; + } + + /** + * Attempts to cast the class ability bound to {@code slot}. + * + * @return {@code true} if vanilla should be blocked (player has a class with this slot). + */ + public boolean tryCast( + @Nonnull PlayerRef playerRef, + @Nonnull World world, + int slot, + @Nullable Ref aimedTarget) { + ResolvedAbility resolved = resolveAbility(playerRef, slot); + if (resolved == null) { + return false; + } + + AbilityService.CastResult result = resolved.abilities().cast( + playerRef, world, resolved.playerClass(), resolved.ability(), aimedTarget); + + switch (result) { + case OK -> playerRef.sendMessage(Message.raw("Capacité : " + resolved.ability().displayName())); + case ON_COOLDOWN -> { + long remaining = resolved.abilities().cooldownRemaining( + playerRef.getUuid(), resolved.ability().id()); + playerRef.sendMessage(Message.raw("En recharge (" + (remaining / 1000 + 1) + "s).")); + } + case WRONG_WEAPON -> playerRef.sendMessage(Message.raw( + "Mauvaise arme. Équipez : " + String.join(", ", resolved.playerClass().weapons()))); + case UNKNOWN -> playerRef.sendMessage(Message.raw("Capacité inconnue.")); + } + return true; + } + + public static int slotFor(@Nonnull InteractionType type) { + return switch (type) { + case Ability1 -> 1; + case Ability2 -> 2; + case Ability3 -> 3; + default -> 0; + }; + } + + public static boolean isClassAbilityType(@Nonnull InteractionType type) { + return slotFor(type) != 0; + } + + @Nullable + private ResolvedAbility resolveAbility(@Nonnull PlayerRef playerRef, int slot) { + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + return null; + } + + PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); + ClassService classService = bootstrap.getRegistry().require(ClassService.class); + AbilityService abilities = bootstrap.getRegistry().require(AbilityService.class); + + PlayerProfile profile = sessions.getSession(playerRef.getUuid()); + if (profile == null) { + return null; + } + PlayerClass playerClass = classService.getClass(profile); + if (playerClass == null) { + return null; + } + AbilityDefinition ability = playerClass.abilities().stream() + .filter(a -> a.slot() == slot) + .findFirst() + .orElse(null); + if (ability == null) { + return null; + } + return new ResolvedAbility(playerClass, ability, abilities); + } + + private record ResolvedAbility( + PlayerClass playerClass, + AbilityDefinition ability, + AbilityService abilities) { + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java b/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java new file mode 100644 index 0000000..39ca1fa --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/CombatBuffService.java @@ -0,0 +1,72 @@ +package com.disklexar.mmorpg.combat; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks the temporary offensive buffs granted by class abilities and read back by the damage + * system when the buffed player lands a basic attack: + *
    + *
  • Archer "Tir rapide": a flat outgoing-damage bonus for a window (server-side proxy for + * attack speed, which is otherwise client-authoritative),
  • + *
  • Archer "Tir puissant": a one-shot +200% multiplier on the next basic attack,
  • + *
  • Assassin "Lame Empoisonnée": a window during which every hit applies a stacking poison,
  • + *
  • Assassin "Camouflage": bookkeeping of the current invisibility window.
  • + *
+ */ +public final class CombatBuffService { + + private final Map rapidFireUntil = new ConcurrentHashMap<>(); + private final Set powerShotCharged = ConcurrentHashMap.newKeySet(); + private final Map poisonBladeUntil = new ConcurrentHashMap<>(); + private final Map invisibleUntil = new ConcurrentHashMap<>(); + + public void grantRapidFire(@Nonnull UUID uuid, long durationMillis) { + rapidFireUntil.put(uuid, System.currentTimeMillis() + durationMillis); + } + + public boolean hasRapidFire(@Nonnull UUID uuid) { + Long until = rapidFireUntil.get(uuid); + return until != null && System.currentTimeMillis() < until; + } + + public void chargePowerShot(@Nonnull UUID uuid) { + powerShotCharged.add(uuid); + } + + public boolean hasPowerShot(@Nonnull UUID uuid) { + return powerShotCharged.contains(uuid); + } + + public boolean consumePowerShot(@Nonnull UUID uuid) { + return powerShotCharged.remove(uuid); + } + + public void grantPoisonBlade(@Nonnull UUID uuid, long durationMillis) { + poisonBladeUntil.put(uuid, System.currentTimeMillis() + durationMillis); + } + + public boolean hasPoisonBlade(@Nonnull UUID uuid) { + Long until = poisonBladeUntil.get(uuid); + return until != null && System.currentTimeMillis() < until; + } + + public void markInvisible(@Nonnull UUID uuid, long durationMillis) { + invisibleUntil.put(uuid, System.currentTimeMillis() + durationMillis); + } + + public boolean isInvisible(@Nonnull UUID uuid) { + Long until = invisibleUntil.get(uuid); + return until != null && System.currentTimeMillis() < until; + } + + public void clear(@Nonnull UUID uuid) { + rapidFireUntil.remove(uuid); + powerShotCharged.remove(uuid); + poisonBladeUntil.remove(uuid); + invisibleUntil.remove(uuid); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/CombatFx.java b/src/main/java/com/disklexar/mmorpg/combat/CombatFx.java new file mode 100644 index 0000000..8471948 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/CombatFx.java @@ -0,0 +1,148 @@ +package com.disklexar.mmorpg.combat; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.AnimationSlot; +import com.hypixel.hytale.protocol.ChangeVelocityType; +import com.hypixel.hytale.protocol.SoundCategory; +import com.hypixel.hytale.protocol.VelocityThresholdStyle; +import com.hypixel.hytale.server.core.asset.type.soundevent.config.SoundEvent; +import com.hypixel.hytale.server.core.entity.AnimationUtils; +import com.hypixel.hytale.server.core.entity.knockback.KnockbackComponent; +import com.hypixel.hytale.server.core.modules.splitvelocity.VelocityConfig; +import com.hypixel.hytale.server.core.universe.world.ParticleUtil; +import com.hypixel.hytale.server.core.universe.world.SoundUtil; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Thin, failure-safe wrappers around the verified server effect helpers + * ({@link ParticleUtil}, {@link SoundUtil}, {@link AnimationUtils}). Each call is guarded so a + * missing asset id or a runtime API mismatch can never break the ability or disconnect the player — + * the worst case is simply that one effect is skipped. + *

+ * Asset ids (particle systems / sound events / weapon animation moves) are taken from the shipped + * game assets, e.g. {@code Battleaxe_Signature_Whirlwind}, {@code Impact_Explosion}, + * {@code SFX_Deer_Stag_Roar}, animation move {@code WhirlwindChargedSpin}. + */ +public final class CombatFx { + + private CombatFx() { + } + + /** Spawns a world particle system (broadcast to nearby players) at a position. */ + public static void vfx( + @Nonnull Store store, + @Nonnull String particleSystemId, + @Nonnull Vector3d position) { + try { + ParticleUtil.spawnParticleEffect(particleSystemId, new Vector3d(position), store); + } catch (Throwable ignored) { + // Unknown particle id or API shape mismatch: skip the effect silently. + } + } + + /** + * Spawns a particle system with an explicit lifetime ({@code durationSeconds}) so it is + * guaranteed to disappear — used for looping/aura effects (e.g. the whirlwind) that would + * otherwise stay on screen forever. + */ + public static void vfxTimed( + @Nonnull Store store, + @Nonnull String particleSystemId, + @Nonnull Vector3d position, + float scale, + float durationSeconds) { + try { + ParticleUtil.spawnParticleEffect( + particleSystemId, new Vector3d(position), 0f, 0f, 0f, scale, durationSeconds, store); + } catch (Throwable ignored) { + // Unknown particle id or API shape mismatch: skip the effect silently. + } + } + + /** + * Applies a client-synced knockback/impulse to an entity (player or mob) via the engine's + * {@link KnockbackComponent}. This is the only reliable way to move a player from the + * server: directly writing {@code Velocity} is ignored because player movement is + * client-authoritative. Must be called on the world thread. + */ + public static void knockback( + @Nonnull Store store, + @Nonnull Ref entity, + @Nonnull Vector3d velocity) { + try { + KnockbackComponent kb = store.ensureAndGetComponent(entity, KnockbackComponent.getComponentType()); + kb.setVelocity(new Vector3d(velocity)); + kb.setVelocityType(ChangeVelocityType.Set); + kb.setVelocityConfig(defaultVelocityConfig()); + kb.setDuration(0f); + kb.setTimer(0f); + } catch (Throwable ignored) { + // Knockback API mismatch: skip silently (ability still deals damage / plays effects). + } + } + + @Nonnull + private static VelocityConfig defaultVelocityConfig() { + // Mirrors the engine's default knockback decay preset (see KnockbackApplyCommand). + VelocityConfig config = new VelocityConfig(); + config.setAirResistance(0.99f); + config.setAirResistanceMax(0.98f); + config.setGroundResistance(0.94f); + config.setGroundResistanceMax(0.3f); + config.setThreshold(3.0f); + config.setStyle(VelocityThresholdStyle.Linear); + return config; + } + + /** Plays a 3D sound event (broadcast to nearby players) at a position. */ + public static void sound( + @Nonnull Store store, + @Nonnull String soundEventId, + @Nonnull Vector3d position) { + sound(store, soundEventId, position, 1.0f, 1.0f); + } + + public static void sound( + @Nonnull Store store, + @Nonnull String soundEventId, + @Nonnull Vector3d position, + float volume, + float pitch) { + try { + int index = SoundEvent.getAssetMap().getIndexOrDefault(soundEventId, -1); + if (index < 0) { + return; + } + SoundUtil.playSoundEvent3d( + index, SoundCategory.SFX, position.x, position.y, position.z, volume, pitch, store); + } catch (Throwable ignored) { + // Unknown sound id or API shape mismatch: skip the sound silently. + } + } + + /** + * Plays a weapon animation move on the entity (e.g. {@code WhirlwindChargedSpin}). The move must + * belong to the supplied weapon animation set ({@code Battleaxe} / {@code Longsword}); a null set + * skips the animation. + */ + public static void animation( + @Nonnull Store store, + @Nonnull Ref entity, + @Nullable String weaponAnimationSet, + @Nonnull String animationMove) { + if (weaponAnimationSet == null) { + return; + } + try { + AnimationUtils.playAnimation( + entity, AnimationSlot.Action, weaponAnimationSet, animationMove, store); + } catch (Throwable ignored) { + // Animation set/move not loaded for this entity: skip silently. + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/CombatStateService.java b/src/main/java/com/disklexar/mmorpg/combat/CombatStateService.java new file mode 100644 index 0000000..93d3ab6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/CombatStateService.java @@ -0,0 +1,58 @@ +package com.disklexar.mmorpg.combat; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks lightweight combat state per player: + *

    + *
  • last time the player took damage (drives the "Résilient" power and out-of-combat detection),
  • + *
  • temporary resistance debuffs applied by abilities (e.g. the Knight war cry).
  • + *
+ */ +public final class CombatStateService { + + /** A player is considered "in combat" for this long after taking or dealing damage. */ + public static final long IN_COMBAT_WINDOW_MILLIS = 6_000L; + + private final Map lastDamageTakenAt = new ConcurrentHashMap<>(); + private final Map lastCombatAt = new ConcurrentHashMap<>(); + private final Map resistanceDebuffUntil = new ConcurrentHashMap<>(); + + public void markDamageTaken(@Nonnull UUID uuid) { + long now = System.currentTimeMillis(); + lastDamageTakenAt.put(uuid, now); + lastCombatAt.put(uuid, now); + } + + public void markCombat(@Nonnull UUID uuid) { + lastCombatAt.put(uuid, System.currentTimeMillis()); + } + + public boolean wasRecentlyHit(@Nonnull UUID uuid, long windowMillis) { + Long at = lastDamageTakenAt.get(uuid); + return at != null && System.currentTimeMillis() - at <= windowMillis; + } + + public boolean isInCombat(@Nonnull UUID uuid) { + Long at = lastCombatAt.get(uuid); + return at != null && System.currentTimeMillis() - at <= IN_COMBAT_WINDOW_MILLIS; + } + + public void applyResistanceDebuff(@Nonnull UUID uuid, long durationMillis) { + resistanceDebuffUntil.put(uuid, System.currentTimeMillis() + durationMillis); + } + + public boolean hasResistanceDebuff(@Nonnull UUID uuid) { + Long until = resistanceDebuffUntil.get(uuid); + return until != null && System.currentTimeMillis() < until; + } + + public void clear(@Nonnull UUID uuid) { + lastDamageTakenAt.remove(uuid); + lastCombatAt.remove(uuid); + resistanceDebuffUntil.remove(uuid); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/MmorpgScheduler.java b/src/main/java/com/disklexar/mmorpg/combat/MmorpgScheduler.java new file mode 100644 index 0000000..2ffba43 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/MmorpgScheduler.java @@ -0,0 +1,69 @@ +package com.disklexar.mmorpg.combat; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.service.Service; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +/** + * A small dedicated scheduler for timed gameplay effects (ability ticks, buff reconciliation). + *

+ * The scheduler runs on its own thread; callbacks that touch ECS state must hop onto the world + * thread via {@link com.hypixel.hytale.server.core.universe.world.World#execute(Runnable)}. + */ +public final class MmorpgScheduler implements Service { + + private final HytaleLogger logger; + private ScheduledExecutorService executor; + + public MmorpgScheduler(@Nonnull MmorpgPlugin plugin) { + this.logger = plugin.getLogger(); + } + + @Override + public void start() { + executor = Executors.newScheduledThreadPool(2, runnable -> { + Thread thread = new Thread(runnable, "mmorpg-scheduler"); + thread.setDaemon(true); + return thread; + }); + } + + @Override + public void shutdown() { + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } + + public void runLater(@Nonnull Runnable task, long delayMillis) { + if (executor == null) { + return; + } + executor.schedule(wrap(task), Math.max(0L, delayMillis), TimeUnit.MILLISECONDS); + } + + @Nonnull + public ScheduledFuture runRepeating(@Nonnull Runnable task, long initialDelayMillis, long periodMillis) { + return executor.scheduleAtFixedRate( + wrap(task), Math.max(0L, initialDelayMillis), Math.max(1L, periodMillis), TimeUnit.MILLISECONDS); + } + + @Nonnull + private Runnable wrap(@Nonnull Runnable task) { + return () -> { + try { + task.run(); + } catch (Throwable t) { + logger.at(Level.WARNING).log("MMORPG scheduled task failed: %s", t.getMessage()); + } + }; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java b/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java new file mode 100644 index 0000000..0b05871 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java @@ -0,0 +1,92 @@ +package com.disklexar.mmorpg.combat; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.power.PowerCatalog; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.MovementSettings; +import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.concurrent.ScheduledFuture; + +/** + * Reconciles passive movement effects every couple of seconds. + *

+ * The "Sprinter" power grants {@code +10%} base movement speed while the player is out of combat; + * the bonus is removed while in combat and restored afterwards. + */ +public final class PassiveEffectService implements Service { + + private static final long RECONCILE_PERIOD = 2_000L; + + private final MmorpgScheduler scheduler; + private final OnlinePlayerRegistry online; + private final PlayerSessionService sessions; + private final CombatStateService combatState; + + private ScheduledFuture task; + + public PassiveEffectService( + @Nonnull MmorpgPlugin plugin, + @Nonnull MmorpgScheduler scheduler, + @Nonnull OnlinePlayerRegistry online, + @Nonnull PlayerSessionService sessions, + @Nonnull CombatStateService combatState) { + this.scheduler = scheduler; + this.online = online; + this.sessions = sessions; + this.combatState = combatState; + } + + @Override + public void start() { + task = scheduler.runRepeating(this::reconcileAll, RECONCILE_PERIOD, RECONCILE_PERIOD); + } + + @Override + public void shutdown() { + if (task != null) { + task.cancel(false); + task = null; + } + } + + private void reconcileAll() { + for (OnlinePlayer player : online.all()) { + PlayerProfile profile = sessions.getSession(player.uuid()); + if (profile == null) { + continue; + } + boolean sprinter = profile.getPowers().contains(PowerCatalog.SPRINTER.id()); + boolean active = sprinter && !combatState.isInCombat(player.uuid()); + player.world().execute(() -> applySpeed(player, active)); + } + } + + private void applySpeed(@Nonnull OnlinePlayer player, boolean buffed) { + Store store = player.world().getEntityStore().getStore(); + Ref ref = player.entityRef(); + MovementManager movement = store.getComponent(ref, MovementManager.getComponentType()); + if (movement == null) { + return; + } + MovementSettings defaults = movement.getDefaultSettings(); + MovementSettings current = movement.getSettings(); + if (defaults == null || current == null) { + return; + } + float baseline = defaults.baseSpeed; + float target = buffed ? baseline * (float) (1.0 + PowerCatalog.SPRINTER_SPEED_BONUS) : baseline; + if (Math.abs(current.baseSpeed - target) > 0.0001f) { + current.baseSpeed = target; + movement.update(player.playerRef().getPacketHandler()); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/PoisonService.java b/src/main/java/com/disklexar/mmorpg/combat/PoisonService.java new file mode 100644 index 0000000..2274184 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/PoisonService.java @@ -0,0 +1,64 @@ +package com.disklexar.mmorpg.combat; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Applies a damage-over-time poison to a target. Each application is independent (so the assassin's + * "Lame Empoisonnée" stacks without limit): calling {@link #applyPoison} again simply schedules + * another series of ticks on top of the existing ones. + */ +public final class PoisonService { + + private static final String VFX_POISON = "Impact_Poison"; + + private final MmorpgScheduler scheduler; + + public PoisonService(@Nonnull MmorpgScheduler scheduler) { + this.scheduler = scheduler; + } + + /** + * Deals {@code damagePerTick} magic damage to {@code victim} once per {@code periodMillis}, + * {@code ticks} times. The caster keeps the kill credit via the damage source. + */ + public void applyPoison( + @Nonnull World world, + @Nonnull Store store, + @Nonnull Ref caster, + @Nonnull Ref victim, + int ticks, + float damagePerTick, + long periodMillis) { + AtomicInteger remaining = new AtomicInteger(ticks); + for (int i = 1; i <= ticks; i++) { + scheduler.runLater(() -> world.execute(() -> { + if (remaining.decrementAndGet() < 0) { + return; + } + if (!victim.isValid()) { + return; + } + // No dedicated "magic" cause exists; ENVIRONMENT keeps the tick from re-triggering + // the on-hit (PHYSICAL) buff logic, so the poison cannot recursively re-poison. + Damage damage = new Damage(new Damage.EntitySource(caster), DamageCause.ENVIRONMENT, damagePerTick); + DamageSystems.executeDamage(victim, store, damage); + TransformComponent transform = + store.getComponent(victim, TransformComponent.getComponentType()); + if (transform != null) { + CombatFx.vfx(store, VFX_POISON, new Vector3d(transform.getPosition())); + } + }), (long) i * periodMillis); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/StunService.java b/src/main/java/com/disklexar/mmorpg/combat/StunService.java new file mode 100644 index 0000000..0f48cb5 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/StunService.java @@ -0,0 +1,32 @@ +package com.disklexar.mmorpg.combat; + +import javax.annotation.Nonnull; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks which entities are currently stunned (used so the Knight war cry can deal bonus damage + * to already-stunned targets). The actual movement lock is enforced by repeatedly zeroing the + * target's velocity for the stun duration. + */ +public final class StunService { + + private final Map stunnedUntil = new ConcurrentHashMap<>(); + + public void stun(@Nonnull UUID entityUuid, long durationMillis) { + stunnedUntil.put(entityUuid, System.currentTimeMillis() + durationMillis); + } + + public boolean isStunned(@Nonnull UUID entityUuid) { + Long until = stunnedUntil.get(entityUuid); + if (until == null) { + return false; + } + if (System.currentTimeMillis() >= until) { + stunnedUntil.remove(entityUuid); + return false; + } + return true; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/system/MinerBlockBreakSystem.java b/src/main/java/com/disklexar/mmorpg/combat/system/MinerBlockBreakSystem.java new file mode 100644 index 0000000..f1a6af8 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/system/MinerBlockBreakSystem.java @@ -0,0 +1,66 @@ +package com.disklexar.mmorpg.combat.system; + +import com.disklexar.mmorpg.economy.EconomyService; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.job.JobCatalog; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.component.system.EntityEventSystem; +import com.hypixel.hytale.server.core.event.events.ecs.BreakBlockEvent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * Grants money to players with the "Mineur" job each time they break a block. + */ +public final class MinerBlockBreakSystem extends EntityEventSystem { + + private static final Query QUERY = Query.any(); + + private final PlayerSessionService sessions; + private final JobService jobs; + private final EconomyService economy; + + public MinerBlockBreakSystem( + @Nonnull PlayerSessionService sessions, + @Nonnull JobService jobs, + @Nonnull EconomyService economy) { + super(BreakBlockEvent.class); + this.sessions = sessions; + this.jobs = jobs; + this.economy = economy; + } + + @Override + public Query getQuery() { + return QUERY; + } + + @Override + public void handle( + int index, + @Nonnull ArchetypeChunk chunk, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer, + @Nonnull BreakBlockEvent event) { + Ref breaker = chunk.getReferenceTo(index); + PlayerRef player = store.getComponent(breaker, PlayerRef.getComponentType()); + if (player == null) { + return; + } + PlayerProfile profile = sessions.getSession(player.getUuid()); + if (profile == null) { + return; + } + if (jobs.has(profile, JobCatalog.MINER.id())) { + economy.deposit(profile, JobCatalog.MINER_REWARD); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java b/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java new file mode 100644 index 0000000..88c18cb --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDamageSystem.java @@ -0,0 +1,143 @@ +package com.disklexar.mmorpg.combat.system; + +import com.disklexar.mmorpg.combat.CombatBuffService; +import com.disklexar.mmorpg.combat.CombatStateService; +import com.disklexar.mmorpg.combat.PoisonService; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.power.PowerCatalog; +import com.hypixel.hytale.component.ArchetypeChunk; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.SystemGroup; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageEventSystem; +import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.UUID; + +/** + * Adjusts incoming damage according to MMORPG state and tracks combat for passive powers: + *

    + *
  • victims with an active resistance debuff (Knight war cry) take +20% damage,
  • + *
  • attackers with the "Résilient" power deal +10% damage for 5s after being hit.
  • + *
+ */ +public final class MmorpgDamageSystem extends DamageEventSystem { + + private static final Query QUERY = Query.any(); + private static final double RESISTANCE_DEBUFF_FACTOR = 1.20; + + // Archer "Tir rapide": flat outgoing-damage bonus (server proxy for attack speed). + private static final double RAPID_FIRE_FACTOR = 1.20; + // Archer "Tir puissant": +200% on the next basic attack. + private static final double POWER_SHOT_FACTOR = 3.0; + // Assassin "Lame Empoisonnée": stacking poison applied on each basic hit. + private static final int POISON_TICKS = 5; + private static final float POISON_DAMAGE_PER_TICK = 2.0f; + private static final long POISON_PERIOD_MILLIS = 1_000L; + + private final PlayerSessionService sessions; + private final CombatStateService combatState; + private final CombatBuffService combatBuffs; + private final PoisonService poisonService; + private final OnlinePlayerRegistry onlinePlayers; + + public MmorpgDamageSystem( + @Nonnull PlayerSessionService sessions, + @Nonnull CombatStateService combatState, + @Nonnull CombatBuffService combatBuffs, + @Nonnull PoisonService poisonService, + @Nonnull OnlinePlayerRegistry onlinePlayers) { + this.sessions = sessions; + this.combatState = combatState; + this.combatBuffs = combatBuffs; + this.poisonService = poisonService; + this.onlinePlayers = onlinePlayers; + } + + @Override + public Query getQuery() { + return QUERY; + } + + @Override + public SystemGroup getGroup() { + return DamageModule.get().getInspectDamageGroup(); + } + + @Override + public void handle( + int index, + @Nonnull ArchetypeChunk chunk, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer, + @Nonnull Damage damage) { + Ref victim = chunk.getReferenceTo(index); + double amount = damage.getAmount(); + + UUID victimUuid = uuid(store, victim); + if (victimUuid != null && combatState.hasResistanceDebuff(victimUuid)) { + amount *= RESISTANCE_DEBUFF_FACTOR; + } + + if (damage.getSource() instanceof Damage.EntitySource source) { + Ref attackerRef = source.getRef(); + if (attackerRef != null) { + PlayerRef attacker = store.getComponent(attackerRef, PlayerRef.getComponentType()); + if (attacker != null) { + UUID attackerUuid = attacker.getUuid(); + combatState.markCombat(attackerUuid); + PlayerProfile profile = sessions.getSession(attackerUuid); + if (profile != null + && profile.getPowers().contains(PowerCatalog.RESILIENT.id()) + && combatState.wasRecentlyHit(attackerUuid, PowerCatalog.RESILIENT_WINDOW_MILLIS)) { + amount *= (1.0 + PowerCatalog.RESILIENT_DAMAGE_BONUS); + } + + // Offensive class buffs only apply to direct physical hits, never to the + // damage-over-time / spell ticks our own abilities deal (those are MAGIC), so a + // single sword swing doesn't waste the archer's charged shot, etc. + if (damage.getCause() == DamageCause.PHYSICAL) { + if (combatBuffs.hasRapidFire(attackerUuid)) { + amount *= RAPID_FIRE_FACTOR; + } + if (combatBuffs.consumePowerShot(attackerUuid)) { + amount *= POWER_SHOT_FACTOR; + } + if (combatBuffs.hasPoisonBlade(attackerUuid)) { + OnlinePlayer online = onlinePlayers.get(attackerUuid); + if (online != null) { + poisonService.applyPoison(online.world(), store, attackerRef, victim, + POISON_TICKS, POISON_DAMAGE_PER_TICK, POISON_PERIOD_MILLIS); + } + } + } + } + } + } + + if (Math.abs(amount - damage.getAmount()) > 0.0001) { + damage.setAmount((float) amount); + } + + PlayerRef victimPlayer = store.getComponent(victim, PlayerRef.getComponentType()); + if (victimPlayer != null) { + combatState.markDamageTaken(victimPlayer.getUuid()); + } + } + + private UUID uuid(@Nonnull Store store, @Nonnull Ref ref) { + UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType()); + return component == null ? null : component.getUuid(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDeathSystem.java b/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDeathSystem.java new file mode 100644 index 0000000..50f157a --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/combat/system/MmorpgDeathSystem.java @@ -0,0 +1,105 @@ +package com.disklexar.mmorpg.combat.system; + +import com.disklexar.mmorpg.economy.EconomyService; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.group.GroupService; +import com.disklexar.mmorpg.rpg.job.JobCatalog; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.hypixel.hytale.component.CommandBuffer; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.component.query.Query; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.modules.entity.damage.Damage; +import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent; +import com.hypixel.hytale.server.core.modules.entity.damage.DeathSystems; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.UUID; + +/** + * Rewards a player for killing a mob: kill XP (scaled by race), shared XP for group mates, and + * money when the killer holds the "Tueur de monstre" job. + */ +public final class MmorpgDeathSystem extends DeathSystems.OnDeathSystem { + + private static final Query QUERY = Query.any(); + + private final PlayerSessionService sessions; + private final ProgressionService progression; + private final GroupService groups; + private final JobService jobs; + private final EconomyService economy; + private final int killExperience; + + public MmorpgDeathSystem( + @Nonnull PlayerSessionService sessions, + @Nonnull ProgressionService progression, + @Nonnull GroupService groups, + @Nonnull JobService jobs, + @Nonnull EconomyService economy, + int killExperience) { + this.sessions = sessions; + this.progression = progression; + this.groups = groups; + this.jobs = jobs; + this.economy = economy; + this.killExperience = Math.max(0, killExperience); + } + + @Override + public Query getQuery() { + return QUERY; + } + + @Override + public void onComponentAdded( + @Nonnull Ref deadRef, + @Nonnull DeathComponent death, + @Nonnull Store store, + @Nonnull CommandBuffer commandBuffer) { + // Only mobs award rewards; ignore player deaths. + if (store.getComponent(deadRef, PlayerRef.getComponentType()) != null) { + return; + } + Damage info = death.getDeathInfo(); + if (info == null || !(info.getSource() instanceof Damage.EntitySource source)) { + return; + } + Ref killerRef = source.getRef(); + if (killerRef == null) { + return; + } + PlayerRef killer = store.getComponent(killerRef, PlayerRef.getComponentType()); + if (killer == null) { + return; + } + UUID killerUuid = killer.getUuid(); + PlayerProfile profile = sessions.getSession(killerUuid); + if (profile == null) { + return; + } + + long gained = progression.grantExperience(profile, killExperience, true); + int shared = groups.shareKillExperience(killerUuid, gained); + + long reward = 0L; + if (jobs.has(profile, JobCatalog.MONSTER_SLAYER.id())) { + reward = JobCatalog.MONSTER_SLAYER_REWARD; + economy.deposit(profile, reward); + } + + StringBuilder message = new StringBuilder("Monstre vaincu — +" + gained + " XP"); + if (reward > 0) { + message.append(" | +").append(reward).append(" money"); + } + if (shared > 0) { + message.append(" | XP partagée avec ").append(shared).append(" allié(s)"); + } + killer.sendMessage(Message.raw(message.toString())); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/AbilityCommand.java b/src/main/java/com/disklexar/mmorpg/command/AbilityCommand.java new file mode 100644 index 0000000..e6d6d27 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/AbilityCommand.java @@ -0,0 +1,43 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.combat.ClassAbilityController; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg ability <1|2|3>} — uses one of your class abilities. + */ +public final class AbilityCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + private final RequiredArg slotArg; + + public AbilityCommand(@Nonnull MmorpgPlugin plugin) { + super("ability", "Utiliser une capacité de classe (slot 1, 2 ou 3)"); + this.plugin = plugin; + this.slotArg = withRequiredArg("slot", "Numéro de la capacité (1-3)", ArgTypes.INTEGER); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + ClassAbilityController controller = plugin.getClassAbilityController(); + int slot = context.get(slotArg); + if (!controller.shouldSuppressVanilla(playerRef, slot)) { + context.sendMessage(Message.raw("Vous n'avez pas de classe. Utilisez /mmorpg class choisir .")); + return; + } + controller.tryCast(playerRef, world, slot, null); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java b/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java new file mode 100644 index 0000000..c68325c --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/ClassCommand.java @@ -0,0 +1,159 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.disklexar.mmorpg.rpg.clazz.ClassCatalog; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg class } + */ +public final class ClassCommand extends AbstractCommandCollection { + + public ClassCommand(@Nonnull MmorpgPlugin plugin) { + super("class", "Gérer votre classe"); + addSubCommand(new Choose(plugin)); + addSubCommand(new Leave(plugin)); + addSubCommand(new Info(plugin)); + } + + private static final class Choose extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg classArg; + + Choose(@Nonnull MmorpgPlugin plugin) { + super("choisir", "Choisir une classe"); + this.plugin = plugin; + this.classArg = withRequiredArg("classe", "Identifiant de la classe", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + ClassService classService = CommandSupport.service(plugin, ClassService.class); + if (profile == null || classService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(classArg); + if (classService.choose(profile, id)) { + PlayerClass chosen = classService.getClass(profile); + context.sendMessage(Message.raw("Classe choisie : " + (chosen != null ? chosen.displayName() : id))); + syncAbilityBar(plugin, playerRef, profile); + } else { + context.sendMessage(Message.raw("Classe inconnue. Disponibles : " + classList())); + } + } + } + + private static final class Leave extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Leave(@Nonnull MmorpgPlugin plugin) { + super("quitter", "Quitter votre classe"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + ClassService classService = CommandSupport.service(plugin, ClassService.class); + if (profile == null || classService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + if (classService.leave(profile)) { + context.sendMessage(Message.raw("Vous avez quitté votre classe.")); + dismissAbilityBar(plugin, playerRef); + } else { + context.sendMessage(Message.raw("Vous n'avez aucune classe.")); + } + } + } + + private static final class Info extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Info(@Nonnull MmorpgPlugin plugin) { + super("info", "Informations sur les classes"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + ClassService classService = CommandSupport.service(plugin, ClassService.class); + StringBuilder builder = new StringBuilder("=== Classes ==="); + if (profile != null && classService != null) { + PlayerClass current = classService.getClass(profile); + builder.append("\nClasse actuelle : ").append(current != null ? current.displayName() : "aucune"); + } + for (PlayerClass playerClass : ClassCatalog.all()) { + builder.append("\n- ").append(playerClass.displayName()) + .append(" (").append(playerClass.id()).append(") — ") + .append(playerClass.description()) + .append("\n Armes : ").append(String.join(", ", playerClass.weapons())); + for (AbilityDefinition ability : playerClass.abilities()) { + builder.append("\n • Capacité ").append(ability.slot()).append(" — ") + .append(ability.displayName()).append(" : ").append(ability.description()); + } + } + context.sendMessage(Message.raw(builder.toString())); + } + } + + private static void syncAbilityBar( + @Nonnull MmorpgPlugin plugin, + @Nonnull PlayerRef playerRef, + @Nonnull PlayerProfile profile) { + AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class); + OnlinePlayerRegistry onlinePlayers = CommandSupport.service(plugin, OnlinePlayerRegistry.class); + if (abilityBar == null || onlinePlayers == null) { + return; + } + OnlinePlayer online = onlinePlayers.get(playerRef.getUuid()); + if (online != null) { + abilityBar.sync(online, profile); + } + } + + private static void dismissAbilityBar(@Nonnull MmorpgPlugin plugin, @Nonnull PlayerRef playerRef) { + AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class); + if (abilityBar != null) { + abilityBar.dismiss(playerRef.getUuid()); + } + } + + @Nonnull + private static String classList() { + StringBuilder builder = new StringBuilder(); + for (PlayerClass playerClass : ClassCatalog.all()) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(playerClass.id()); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/CommandSupport.java b/src/main/java/com/disklexar/mmorpg/command/CommandSupport.java new file mode 100644 index 0000000..0fea12a --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/CommandSupport.java @@ -0,0 +1,51 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; + +/** + * Shared helpers for MMORPG commands: service lookup and current-player profile resolution. + */ +final class CommandSupport { + + private CommandSupport() { + } + + @Nullable + static T service(@Nonnull MmorpgPlugin plugin, @Nonnull Class type) { + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + return null; + } + return bootstrap.getRegistry().get(type); + } + + @Nullable + static UUID uuid(@Nonnull Store store, @Nonnull Ref ref) { + UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType()); + return component == null ? null : component.getUuid(); + } + + @Nullable + static PlayerProfile profile( + @Nonnull MmorpgPlugin plugin, + @Nonnull Store store, + @Nonnull Ref ref) { + UUID uuid = uuid(store, ref); + if (uuid == null) { + return null; + } + PlayerSessionService sessions = service(plugin, PlayerSessionService.class); + return sessions == null ? null : sessions.getSession(uuid); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/GroupCommand.java b/src/main/java/com/disklexar/mmorpg/command/GroupCommand.java new file mode 100644 index 0000000..cf202ed --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/GroupCommand.java @@ -0,0 +1,180 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.rpg.group.Group; +import com.disklexar.mmorpg.rpg.group.GroupService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.UUID; + +/** + * {@code /mmorpg group } + */ +public final class GroupCommand extends AbstractCommandCollection { + + public GroupCommand(@Nonnull MmorpgPlugin plugin) { + super("group", "Gérer votre groupe (partage d'XP)"); + addSubCommand(new Invite(plugin)); + addSubCommand(new Accept(plugin)); + addSubCommand(new Leave(plugin)); + addSubCommand(new Kick(plugin)); + addSubCommand(new Info(plugin)); + } + + private static final class Invite extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg targetArg; + + Invite(@Nonnull MmorpgPlugin plugin) { + super("inviter", "Inviter un joueur dans votre groupe"); + this.plugin = plugin; + this.targetArg = withRequiredArg("joueur", "Joueur à inviter", ArgTypes.PLAYER_REF); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID inviter = CommandSupport.uuid(store, ref); + if (groups == null || inviter == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + PlayerRef target = context.get(targetArg); + switch (groups.invite(inviter, target.getUuid())) { + case INVITED -> { + context.sendMessage(Message.raw("Invitation envoyée à " + target.getUsername() + ".")); + target.sendMessage(Message.raw(playerRef.getUsername() + + " vous invite dans son groupe. Tapez /mmorpg group accepter")); + } + case SELF -> context.sendMessage(Message.raw("Vous ne pouvez pas vous inviter vous-même.")); + case NOT_OWNER -> context.sendMessage(Message.raw("Seul le créateur peut inviter.")); + case TARGET_ALREADY_GROUPED -> + context.sendMessage(Message.raw("Ce joueur est déjà dans un groupe.")); + } + } + } + + private static final class Accept extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Accept(@Nonnull MmorpgPlugin plugin) { + super("accepter", "Accepter une invitation de groupe"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID uuid = CommandSupport.uuid(store, ref); + if (groups == null || uuid == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + switch (groups.accept(uuid)) { + case JOINED -> context.sendMessage(Message.raw("Vous avez rejoint le groupe.")); + case NO_INVITE -> context.sendMessage(Message.raw("Aucune invitation en attente.")); + case ALREADY_GROUPED -> context.sendMessage(Message.raw("Vous êtes déjà dans un groupe.")); + } + } + } + + private static final class Leave extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Leave(@Nonnull MmorpgPlugin plugin) { + super("quitter", "Quitter votre groupe"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID uuid = CommandSupport.uuid(store, ref); + if (groups == null || uuid == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + context.sendMessage(Message.raw(groups.leave(uuid) + ? "Vous avez quitté votre groupe." + : "Vous n'êtes dans aucun groupe.")); + } + } + + private static final class Kick extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg targetArg; + + Kick(@Nonnull MmorpgPlugin plugin) { + super("kick", "Exclure un joueur (créateur uniquement)"); + this.plugin = plugin; + this.targetArg = withRequiredArg("joueur", "Joueur à exclure", ArgTypes.PLAYER_REF); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID owner = CommandSupport.uuid(store, ref); + if (groups == null || owner == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + PlayerRef target = context.get(targetArg); + switch (groups.kick(owner, target.getUuid())) { + case KICKED -> { + context.sendMessage(Message.raw(target.getUsername() + " a été exclu du groupe.")); + target.sendMessage(Message.raw("Vous avez été exclu du groupe.")); + } + case NOT_OWNER -> context.sendMessage(Message.raw("Seul le créateur peut exclure.")); + case NOT_MEMBER -> context.sendMessage(Message.raw("Ce joueur n'est pas dans votre groupe.")); + case CANNOT_KICK_SELF -> + context.sendMessage(Message.raw("Utilisez /mmorpg group quitter pour partir.")); + } + } + } + + private static final class Info extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Info(@Nonnull MmorpgPlugin plugin) { + super("info", "Informations sur votre groupe"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + GroupService groups = CommandSupport.service(plugin, GroupService.class); + UUID uuid = CommandSupport.uuid(store, ref); + if (groups == null || uuid == null) { + context.sendMessage(Message.raw("Service de groupe indisponible.")); + return; + } + Group group = groups.getGroupOf(uuid); + if (group == null) { + context.sendMessage(Message.raw("Vous n'êtes dans aucun groupe. " + + "Le partage d'XP donne " + GroupService.SHARED_XP_FRACTION_PERCENT + + "% de l'XP d'un kill aux autres membres.")); + return; + } + context.sendMessage(Message.raw("=== Groupe ===\nCréateur : " + + (group.isOwner(uuid) ? "vous" : group.getOwner()) + + "\nMembres : " + group.getMembers().size() + + "\nPartage d'XP : " + GroupService.SHARED_XP_FRACTION_PERCENT + "%")); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/HudCommand.java b/src/main/java/com/disklexar/mmorpg/command/HudCommand.java new file mode 100644 index 0000000..e7e573c --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/HudCommand.java @@ -0,0 +1,64 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg hud} — force-affiche la barre de capacités (debug / test manuel). + */ +public final class HudCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public HudCommand(@Nonnull MmorpgPlugin plugin) { + super("hud", "Afficher la barre de capacités MMORPG"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous.")); + return; + } + + ClassService classService = CommandSupport.service(plugin, ClassService.class); + AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class); + OnlinePlayerRegistry onlinePlayers = CommandSupport.service(plugin, OnlinePlayerRegistry.class); + if (classService == null || abilityBar == null || onlinePlayers == null) { + context.sendMessage(Message.raw("Service HUD indisponible.")); + return; + } + + if (!classService.hasClass(profile)) { + context.sendMessage(Message.raw("Choisissez d'abord une classe : /mmorpg class choisir ")); + return; + } + + OnlinePlayer online = onlinePlayers.get(playerRef.getUuid()); + if (online == null) { + context.sendMessage(Message.raw("Joueur non enregistré. Reconnectez-vous.")); + return; + } + + abilityBar.dismiss(online); + abilityBar.sync(online, profile); + context.sendMessage(Message.raw("Barre de capacités renvoyée (colonne gauche).")); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/InfoCommand.java b/src/main/java/com/disklexar/mmorpg/command/InfoCommand.java new file mode 100644 index 0000000..8ee4e02 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/InfoCommand.java @@ -0,0 +1,39 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.PlayerInfoView; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg info} — prints the full player profile in chat (reliable fallback for the UI). + */ +public final class InfoCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public InfoCommand(@Nonnull MmorpgPlugin plugin) { + super("info", "Afficher toutes vos informations de joueur"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous.")); + return; + } + context.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/JobCommand.java b/src/main/java/com/disklexar/mmorpg/command/JobCommand.java new file mode 100644 index 0000000..e57641e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/JobCommand.java @@ -0,0 +1,131 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.job.JobCatalog; +import com.disklexar.mmorpg.rpg.job.JobDefinition; +import com.disklexar.mmorpg.rpg.job.JobService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg job } + */ +public final class JobCommand extends AbstractCommandCollection { + + public JobCommand(@Nonnull MmorpgPlugin plugin) { + super("job", "Gérer vos métiers"); + addSubCommand(new Join(plugin)); + addSubCommand(new Leave(plugin)); + addSubCommand(new Info(plugin)); + } + + private static final class Join extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg jobArg; + + Join(@Nonnull MmorpgPlugin plugin) { + super("rejoindre", "Rejoindre un métier"); + this.plugin = plugin; + this.jobArg = withRequiredArg("metier", "Identifiant du métier", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + JobService jobService = CommandSupport.service(plugin, JobService.class); + if (profile == null || jobService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(jobArg); + JobDefinition job = JobCatalog.byId(id); + if (job == null) { + context.sendMessage(Message.raw("Métier inconnu. Disponibles : " + jobList())); + return; + } + context.sendMessage(Message.raw(jobService.join(profile, id) + ? "Métier rejoint : " + job.displayName() + : "Vous exercez déjà " + job.displayName() + ".")); + } + } + + private static final class Leave extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg jobArg; + + Leave(@Nonnull MmorpgPlugin plugin) { + super("quitter", "Quitter un métier"); + this.plugin = plugin; + this.jobArg = withRequiredArg("metier", "Identifiant du métier", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + JobService jobService = CommandSupport.service(plugin, JobService.class); + if (profile == null || jobService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(jobArg); + JobDefinition job = JobCatalog.byId(id); + if (job == null) { + context.sendMessage(Message.raw("Métier inconnu. Disponibles : " + jobList())); + return; + } + context.sendMessage(Message.raw(jobService.leave(profile, id) + ? "Métier quitté : " + job.displayName() + : "Vous n'exercez pas " + job.displayName() + ".")); + } + } + + private static final class Info extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Info(@Nonnull MmorpgPlugin plugin) { + super("info", "Informations sur les métiers"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + StringBuilder builder = new StringBuilder("=== Métiers ==="); + for (JobDefinition job : JobCatalog.all()) { + boolean active = profile != null && profile.getJobs().contains(job.id()); + builder.append("\n- ").append(job.displayName()) + .append(" (").append(job.id()).append(") ") + .append(active ? "[actif]" : "") + .append("\n ").append(job.description()); + } + context.sendMessage(Message.raw(builder.toString())); + } + } + + @Nonnull + private static String jobList() { + StringBuilder builder = new StringBuilder(); + for (JobDefinition job : JobCatalog.all()) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(job.id()); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java b/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java new file mode 100644 index 0000000..4f0a130 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MenuCommand.java @@ -0,0 +1,55 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.PlayerInfoView; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.ui.PlayerInfoPage; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg menu} — opens the custom UI page listing all player info. + * Falls back to the chat panel if the UI page cannot be opened. + */ +public final class MenuCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public MenuCommand(@Nonnull MmorpgPlugin plugin) { + super("menu", "Ouvrir l'interface d'informations du joueur"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous.")); + return; + } + Player player = store.getComponent(ref, Player.getComponentType()); + if (player == null) { + context.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + return; + } + PageManager pageManager = player.getPageManager(); + world.execute(() -> { + try { + pageManager.openCustomPage(ref, store, new PlayerInfoPage(playerRef, profile, false)); + } catch (Throwable t) { + playerRef.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + } + }); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MenuCustomCommand.java b/src/main/java/com/disklexar/mmorpg/command/MenuCustomCommand.java new file mode 100644 index 0000000..0975e30 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MenuCustomCommand.java @@ -0,0 +1,55 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.PlayerInfoView; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.ui.PlayerInfoPage; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg menucustom} — opens the fully custom player-info window backed by this plugin's + * Opens the designed asset-pack page ({@code Pages/MmorpgPlayerInfo.ui}). + */ +public final class MenuCustomCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public MenuCustomCommand(@Nonnull MmorpgPlugin plugin) { + super("menucustom", "Ouvrir l'interface joueur personnalisée (asset pack)"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous.")); + return; + } + Player player = store.getComponent(ref, Player.getComponentType()); + if (player == null) { + context.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + return; + } + PageManager pageManager = player.getPageManager(); + world.execute(() -> { + try { + pageManager.openCustomPage(ref, store, new PlayerInfoPage(playerRef, profile, true)); + } catch (Throwable t) { + playerRef.sendMessage(Message.raw(PlayerInfoView.asText(profile))); + } + }); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java b/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java new file mode 100644 index 0000000..0df31e2 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java @@ -0,0 +1,26 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; + +import javax.annotation.Nonnull; + +/** + * Root command: /mmorpg + */ +public final class MmorpgCommand extends AbstractCommandCollection { + + public MmorpgCommand(@Nonnull MmorpgPlugin plugin) { + super("mmorpg", "Commandes du serveur MMORPG"); + addSubCommand(new MmorpgHelpCommand()); + addSubCommand(new MmorpgProfileCommand(plugin)); + addSubCommand(new InfoCommand(plugin)); + addSubCommand(new MenuCommand(plugin)); + addSubCommand(new HudCommand(plugin)); + addSubCommand(new ClassCommand(plugin)); + addSubCommand(new PowerCommand(plugin)); + addSubCommand(new JobCommand(plugin)); + addSubCommand(new GroupCommand(plugin)); + addSubCommand(new AbilityCommand(plugin)); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java b/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java new file mode 100644 index 0000000..d06fd4b --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java @@ -0,0 +1,40 @@ +package com.disklexar.mmorpg.command; + +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.concurrent.CompletableFuture; + +/** + * /mmorpg help + */ +public final class MmorpgHelpCommand extends AbstractAsyncCommand { + + public MmorpgHelpCommand() { + super("help", "Affiche l'aide MMORPG"); + } + + @Nullable + @Override + protected CompletableFuture executeAsync(@Nonnull CommandContext context) { + context.sendMessage(Message.raw(""" + === MMORPG === + /mmorpg help — cette aide + /mmorpg profile — résumé court de votre profil + /mmorpg info — toutes vos informations (chat) + /mmorpg menu — interface des informations du joueur + /mmorpg menucustom — interface personnalisée (asset pack, test) + /mmorpg class [classe] + /mmorpg power [pouvoir] + /mmorpg job [metier] + /mmorpg group [joueur] + /mmorpg ability <1|2|3> — utiliser une capacité de classe + (ou utilisez directement les touches « Use ability 1/2/3 » en jeu) + Classes : Chevalier, Archer, Mage, Assassin (voir /mmorpg class info) + """)); + return CompletableFuture.completedFuture(null); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/MmorpgProfileCommand.java b/src/main/java/com/disklexar/mmorpg/command/MmorpgProfileCommand.java new file mode 100644 index 0000000..5aab27e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/MmorpgProfileCommand.java @@ -0,0 +1,65 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * /mmorpg profile + */ +public final class MmorpgProfileCommand extends AbstractPlayerCommand { + + private final MmorpgPlugin plugin; + + public MmorpgProfileCommand(@Nonnull MmorpgPlugin plugin) { + super("profile", "Affiche votre profil MMORPG"); + this.plugin = plugin; + } + + @Override + protected void execute( + @Nonnull CommandContext context, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef playerRef, + @Nonnull World world) { + UUIDComponent uuidComponent = store.getComponent(ref, UUIDComponent.getComponentType()); + + if (uuidComponent == null) { + context.sendMessage(Message.raw("Impossible de lire votre profil.")); + return; + } + + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + context.sendMessage(Message.raw("Le plugin MMORPG n'est pas encore prêt.")); + return; + } + + PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); + PlayerProfile profile = sessions.getSession(uuidComponent.getUuid()); + + if (profile == null) { + context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous au serveur.")); + return; + } + + context.sendMessage(Message.raw(String.format( + "Profil MMORPG — %s | Niveau %d | XP %d", + profile.getDisplayName(), + profile.getLevel(), + profile.getExperience()))); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/command/PowerCommand.java b/src/main/java/com/disklexar/mmorpg/command/PowerCommand.java new file mode 100644 index 0000000..01dde1b --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/command/PowerCommand.java @@ -0,0 +1,131 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.power.PowerCatalog; +import com.disklexar.mmorpg.rpg.power.PowerDefinition; +import com.disklexar.mmorpg.rpg.power.PowerService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * {@code /mmorpg power } + */ +public final class PowerCommand extends AbstractCommandCollection { + + public PowerCommand(@Nonnull MmorpgPlugin plugin) { + super("power", "Gérer vos pouvoirs passifs"); + addSubCommand(new Grant(plugin)); + addSubCommand(new Revoke(plugin)); + addSubCommand(new Info(plugin)); + } + + private static final class Grant extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg powerArg; + + Grant(@Nonnull MmorpgPlugin plugin) { + super("recevoir", "Recevoir un pouvoir"); + this.plugin = plugin; + this.powerArg = withRequiredArg("pouvoir", "Identifiant du pouvoir", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + PowerService powerService = CommandSupport.service(plugin, PowerService.class); + if (profile == null || powerService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(powerArg); + PowerDefinition power = PowerCatalog.byId(id); + if (power == null) { + context.sendMessage(Message.raw("Pouvoir inconnu. Disponibles : " + powerList())); + return; + } + context.sendMessage(Message.raw(powerService.grant(profile, id) + ? "Pouvoir reçu : " + power.displayName() + : "Vous possédez déjà " + power.displayName() + ".")); + } + } + + private static final class Revoke extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg powerArg; + + Revoke(@Nonnull MmorpgPlugin plugin) { + super("retirer", "Retirer un pouvoir"); + this.plugin = plugin; + this.powerArg = withRequiredArg("pouvoir", "Identifiant du pouvoir", ArgTypes.STRING); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + PowerService powerService = CommandSupport.service(plugin, PowerService.class); + if (profile == null || powerService == null) { + context.sendMessage(Message.raw("Profil indisponible.")); + return; + } + String id = context.get(powerArg); + PowerDefinition power = PowerCatalog.byId(id); + if (power == null) { + context.sendMessage(Message.raw("Pouvoir inconnu. Disponibles : " + powerList())); + return; + } + context.sendMessage(Message.raw(powerService.remove(profile, id) + ? "Pouvoir retiré : " + power.displayName() + : "Vous ne possédez pas " + power.displayName() + ".")); + } + } + + private static final class Info extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Info(@Nonnull MmorpgPlugin plugin) { + super("info", "Informations sur les pouvoirs"); + this.plugin = plugin; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull Store store, + @Nonnull Ref ref, @Nonnull PlayerRef playerRef, @Nonnull World world) { + PlayerProfile profile = CommandSupport.profile(plugin, store, ref); + StringBuilder builder = new StringBuilder("=== Pouvoirs (passifs, aucun par défaut) ==="); + for (PowerDefinition power : PowerCatalog.all()) { + boolean owned = profile != null && profile.getPowers().contains(power.id()); + builder.append("\n- ").append(power.displayName()) + .append(" (").append(power.id()).append(") ") + .append(owned ? "[possédé]" : "") + .append("\n ").append(power.description()); + } + context.sendMessage(Message.raw(builder.toString())); + } + } + + @Nonnull + private static String powerList() { + StringBuilder builder = new StringBuilder(); + for (PowerDefinition power : PowerCatalog.all()) { + if (builder.length() > 0) { + builder.append(", "); + } + builder.append(power.id()); + } + return builder.toString(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java b/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java new file mode 100644 index 0000000..9f2cc5e --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java @@ -0,0 +1,112 @@ +package com.disklexar.mmorpg.core.config; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Plugin configuration loaded from {@code config.json} in the plugin data directory. + */ +public final class MmorpgConfig { + + private static final Gson GSON = new Gson(); + + private boolean debug; + @SerializedName("DefaultLevel") + private int defaultLevel = 1; + @SerializedName("MaxPlayers") + private int maxPlayers = 500; + @SerializedName("BaseXpPerLevel") + private int baseXpPerLevel = 100; + @SerializedName("KillExperience") + private int killExperience = 10; + private DatabaseConfig database = new DatabaseConfig(); + private FeaturesConfig features = new FeaturesConfig(); + + public static MmorpgConfig load(Path dataDirectory) throws IOException { + Files.createDirectories(dataDirectory); + Path configFile = dataDirectory.resolve("config.json"); + + if (!Files.exists(configFile)) { + try (InputStream defaults = MmorpgConfig.class.getResourceAsStream("/config.json")) { + if (defaults == null) { + throw new IOException("Default config.json not found in plugin resources"); + } + Files.copy(defaults, configFile); + } + } + + try (InputStreamReader reader = new InputStreamReader( + Files.newInputStream(configFile), StandardCharsets.UTF_8)) { + MmorpgConfig config = GSON.fromJson(reader, MmorpgConfig.class); + if (config.database == null) { + config.database = new DatabaseConfig(); + } + if (config.features == null) { + config.features = new FeaturesConfig(); + } + return config; + } + } + + public boolean isDebug() { + return debug; + } + + public int getDefaultLevel() { + return defaultLevel; + } + + public int getMaxPlayers() { + return maxPlayers; + } + + public int getBaseXpPerLevel() { + return baseXpPerLevel; + } + + public int getKillExperience() { + return killExperience; + } + + public DatabaseConfig getDatabase() { + return database; + } + + public FeaturesConfig getFeatures() { + return features; + } + + public static final class DatabaseConfig { + @SerializedName("FileName") + private String fileName = "mmorpg.db"; + + public String getFileName() { + return fileName; + } + } + + public static final class FeaturesConfig { + private boolean economy; + private boolean quests; + private boolean guilds; + + public boolean isEconomyEnabled() { + return economy; + } + + public boolean isQuestsEnabled() { + return quests; + } + + public boolean isGuildsEnabled() { + return guilds; + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/core/service/Service.java b/src/main/java/com/disklexar/mmorpg/core/service/Service.java new file mode 100644 index 0000000..cc7cbc6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/core/service/Service.java @@ -0,0 +1,11 @@ +package com.disklexar.mmorpg.core.service; + +/** + * Lifecycle contract for MMORPG services. + */ +public interface Service { + + void start(); + + void shutdown(); +} diff --git a/src/main/java/com/disklexar/mmorpg/economy/EconomyService.java b/src/main/java/com/disklexar/mmorpg/economy/EconomyService.java new file mode 100644 index 0000000..904330a --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/economy/EconomyService.java @@ -0,0 +1,35 @@ +package com.disklexar.mmorpg.economy; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; + +/** + * Minimal currency operations on a {@link PlayerProfile}. Money is persisted with the profile. + */ +public final class EconomyService { + + public long getBalance(@Nonnull PlayerProfile profile) { + return profile.getMoney(); + } + + public void deposit(@Nonnull PlayerProfile profile, long amount) { + if (amount <= 0) { + return; + } + profile.setMoney(profile.getMoney() + amount); + profile.touch(); + } + + public boolean withdraw(@Nonnull PlayerProfile profile, long amount) { + if (amount <= 0) { + return true; + } + if (profile.getMoney() < amount) { + return false; + } + profile.setMoney(profile.getMoney() - amount); + profile.touch(); + return true; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/economy/package-info.java b/src/main/java/com/disklexar/mmorpg/economy/package-info.java new file mode 100644 index 0000000..ffb2b4b --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/economy/package-info.java @@ -0,0 +1,7 @@ +/** + * Economy system (currency, shops, auctions). + *

+ * Disabled by default via {@code Features.Economy} in config.json. + * Planned: wallets, transactions, NPC vendors. + */ +package com.disklexar.mmorpg.economy; diff --git a/src/main/java/com/disklexar/mmorpg/events/AbilityPacketFilter.java b/src/main/java/com/disklexar/mmorpg/events/AbilityPacketFilter.java new file mode 100644 index 0000000..c8f4857 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/events/AbilityPacketFilter.java @@ -0,0 +1,88 @@ +package com.disklexar.mmorpg.events; + +import com.disklexar.mmorpg.combat.ClassAbilityController; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.ForkedChainId; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.protocol.Packet; +import com.hypixel.hytale.protocol.packets.interaction.CancelInteractionChain; +import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChain; +import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChains; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.io.adapter.PlayerPacketFilter; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; + +/** + * Intercepts {@link SyncInteractionChains} before the vanilla interaction pipeline runs. + * When a player with an MMORPG class presses Ability 1/2/3, the vanilla weapon ability is + * cancelled client-side and the plugin casts the class ability instead. + */ +public final class AbilityPacketFilter implements PlayerPacketFilter { + + private final ClassAbilityController controller; + + public AbilityPacketFilter(@Nonnull ClassAbilityController controller) { + this.controller = controller; + } + + @Override + public boolean test(@Nonnull PlayerRef playerRef, @Nonnull Packet packet) { + if (!(packet instanceof SyncInteractionChains syncPacket) + || syncPacket.updates == null + || syncPacket.updates.length == 0) { + return false; + } + + List keep = new ArrayList<>(syncPacket.updates.length); + boolean blockedAny = false; + + for (SyncInteractionChain chain : syncPacket.updates) { + int slot = ClassAbilityController.slotFor(chain.interactionType); + if (slot == 0 || !chain.initial || !controller.shouldSuppressVanilla(playerRef, slot)) { + keep.add(chain); + continue; + } + + blockedAny = true; + cancelAndCast(playerRef, chain, slot); + } + + if (!blockedAny) { + return false; + } + if (keep.isEmpty()) { + return true; + } + syncPacket.updates = keep.toArray(new SyncInteractionChain[0]); + return false; + } + + private void cancelAndCast( + @Nonnull PlayerRef playerRef, + @Nonnull SyncInteractionChain chain, + int slot) { + ForkedChainId forkedId = chain.forkedId; + playerRef.getPacketHandler().writeNoCache(new CancelInteractionChain(chain.chainId, forkedId)); + + Ref entityRef = playerRef.getReference(); + if (entityRef == null || !entityRef.isValid()) { + return; + } + + Store store = entityRef.getStore(); + Player player = store.getComponent(entityRef, Player.getComponentType()); + if (player == null) { + return; + } + + World world = player.getWorld(); + world.execute(() -> controller.tryCast(playerRef, world, slot, null)); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java b/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java new file mode 100644 index 0000000..bb82741 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java @@ -0,0 +1,133 @@ +package com.disklexar.mmorpg.events; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.bootstrap.Bootstrap; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.combat.MmorpgScheduler; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.entity.UUIDComponent; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent; +import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.sql.SQLException; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Handles player join and leave for MMORPG session management. + */ +public final class PlayerConnectionHandler { + + private final MmorpgPlugin plugin; + private final HytaleLogger logger; + + public PlayerConnectionHandler(@Nonnull MmorpgPlugin plugin) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + } + + public void onPlayerReady(@Nonnull PlayerReadyEvent event) { + Player player = event.getPlayer(); + Ref entityRef = event.getPlayerRef(); + + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + return; + } + + Store store = player.getWorld().getEntityStore().getStore(); + PlayerRef universePlayerRef = store.getComponent(entityRef, PlayerRef.getComponentType()); + PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); + + try { + UUID uuid = resolveUuid(store, entityRef, player); + String displayName = resolveDisplayName(store, entityRef, player); + PlayerProfile profile = sessions.loadOrCreate(uuid, displayName); + + if (universePlayerRef != null) { + OnlinePlayerRegistry onlinePlayers = + bootstrap.getRegistry().require(OnlinePlayerRegistry.class); + OnlinePlayer online = new OnlinePlayer(uuid, universePlayerRef, entityRef, player.getWorld()); + onlinePlayers.add(online); + + ClassService classService = bootstrap.getRegistry().require(ClassService.class); + AbilityBarService abilityBar = bootstrap.getRegistry().require(AbilityBarService.class); + MmorpgScheduler scheduler = bootstrap.getRegistry().require(MmorpgScheduler.class); + + universePlayerRef.sendMessage(Message.raw(String.format( + "Bienvenue sur le serveur MMORPG — Niveau %d | Race: %s", + profile.getLevel(), + com.disklexar.mmorpg.rpg.race.RaceCatalog.byId(profile.getRaceId()).displayName()))); + universePlayerRef.sendMessage(Message.raw( + "Commandes : /mmorpg menu (profil) | /mmorpg class info")); + + if (classService.hasClass(profile)) { + scheduler.runLater(() -> abilityBar.sync(uuid, profile), 5_000L); + } + } + } catch (SQLException e) { + logger.at(Level.SEVERE).log( + "Failed to load profile for %s: %s", + resolveDisplayName(store, entityRef, player), + e.getMessage()); + if (universePlayerRef != null) { + universePlayerRef.sendMessage(Message.raw( + "Erreur lors du chargement de votre profil MMORPG.")); + } + } + } + + public void onPlayerDisconnect(@Nonnull PlayerDisconnectEvent event) { + Bootstrap bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + return; + } + + PlayerRef playerRef = event.getPlayerRef(); + OnlinePlayerRegistry onlinePlayers = bootstrap.getRegistry().require(OnlinePlayerRegistry.class); + onlinePlayers.remove(playerRef.getUuid()); + bootstrap.getRegistry().require(AbilityBarService.class).dismiss(playerRef.getUuid()); + PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); + sessions.unload(playerRef.getUuid()); + } + + @Nonnull + private UUID resolveUuid( + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull Player player) { + UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType()); + if (component != null) { + return component.getUuid(); + } + PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null) { + return playerRef.getUuid(); + } + throw new IllegalStateException("UUID not found for player: " + resolveDisplayName(store, ref, player)); + } + + @Nonnull + private String resolveDisplayName( + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull Player player) { + PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType()); + if (playerRef != null) { + return playerRef.getUsername(); + } + return "Joueur"; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/interaction/MmorpgCastAbilityInteraction.java b/src/main/java/com/disklexar/mmorpg/interaction/MmorpgCastAbilityInteraction.java new file mode 100644 index 0000000..2adb930 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/interaction/MmorpgCastAbilityInteraction.java @@ -0,0 +1,90 @@ +package com.disklexar.mmorpg.interaction; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.combat.ClassAbilityController; +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.InteractionState; +import com.hypixel.hytale.protocol.InteractionType; +import com.hypixel.hytale.server.core.entity.InteractionContext; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler; +import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +/** + * Native interaction ({@code mmorpg_cast_ability}) wired from item asset overrides. + * Client and server stay in sync when weapon JSON maps Ability1/2/3 to this type. + */ +public final class MmorpgCastAbilityInteraction extends SimpleInstantInteraction { + + public static final String ID = "mmorpg_cast_ability"; + + public static final BuilderCodec CODEC = BuilderCodec.builder( + MmorpgCastAbilityInteraction.class, + MmorpgCastAbilityInteraction::new, + SimpleInstantInteraction.CODEC) + .append(new KeyedCodec<>("Slot", Codec.INTEGER), + (interaction, slot) -> interaction.slot = slot, + interaction -> interaction.slot) + .add() + .build(); + + private int slot = 1; + + public MmorpgCastAbilityInteraction() { + } + + public MmorpgCastAbilityInteraction(@Nonnull String id) { + super(id); + } + + @Override + protected void firstRun( + @Nonnull InteractionType interactionType, + @Nonnull InteractionContext interactionContext, + @Nonnull CooldownHandler cooldownHandler) { + int abilitySlot = slot; + if (abilitySlot == 0) { + abilitySlot = ClassAbilityController.slotFor(interactionType); + } + if (abilitySlot == 0) { + interactionContext.getState().state = InteractionState.Failed; + return; + } + + Ref entityRef = interactionContext.getEntity(); + if (entityRef == null || !entityRef.isValid()) { + interactionContext.getState().state = InteractionState.Failed; + return; + } + + Store store = entityRef.getStore(); + PlayerRef playerRef = store.getComponent(entityRef, PlayerRef.getComponentType()); + Player player = store.getComponent(entityRef, Player.getComponentType()); + if (playerRef == null || player == null) { + interactionContext.getState().state = InteractionState.Failed; + return; + } + + MmorpgPlugin plugin = MmorpgPlugin.get(); + if (plugin == null) { + interactionContext.getState().state = InteractionState.Failed; + return; + } + + World world = player.getWorld(); + Ref target = interactionContext.getTargetEntity(); + ClassAbilityController controller = plugin.getClassAbilityController(); + if (!controller.tryCast(playerRef, world, abilitySlot, target)) { + interactionContext.getState().state = InteractionState.Failed; + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/persistence/DatabaseManager.java b/src/main/java/com/disklexar/mmorpg/persistence/DatabaseManager.java new file mode 100644 index 0000000..a34ee06 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/persistence/DatabaseManager.java @@ -0,0 +1,72 @@ +package com.disklexar.mmorpg.persistence; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.config.MmorpgConfig; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.persistence.schema.SchemaInitializer; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.logging.Level; + +/** + * Manages the SQLite database connection for the MMORPG plugin. + */ +public final class DatabaseManager implements Service { + + private final MmorpgPlugin plugin; + private final MmorpgConfig config; + private final HytaleLogger logger; + private Connection connection; + + public DatabaseManager( + @Nonnull MmorpgPlugin plugin, + @Nonnull MmorpgConfig config) { + this.plugin = plugin; + this.config = config; + this.logger = plugin.getLogger(); + } + + @Override + public void start() { + try { + Path dbPath = plugin.getDataDirectory().resolve(config.getDatabase().getFileName()); + String jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath(); + + connection = DriverManager.getConnection(jdbcUrl); + connection.setAutoCommit(true); + + SchemaInitializer.apply(connection); + + logger.at(Level.INFO).log("SQLite database ready at %s", dbPath); + } catch (SQLException e) { + throw new IllegalStateException("Failed to initialize SQLite database", e); + } + } + + @Override + public void shutdown() { + if (connection != null) { + try { + connection.close(); + logger.at(Level.INFO).log("SQLite connection closed"); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Error closing SQLite connection: %s", e.getMessage()); + } finally { + connection = null; + } + } + } + + @Nonnull + public Connection getConnection() { + if (connection == null) { + throw new IllegalStateException("Database is not initialized"); + } + return connection; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/persistence/repository/GroupRepository.java b/src/main/java/com/disklexar/mmorpg/persistence/repository/GroupRepository.java new file mode 100644 index 0000000..a6685d4 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/persistence/repository/GroupRepository.java @@ -0,0 +1,100 @@ +package com.disklexar.mmorpg.persistence.repository; + +import com.disklexar.mmorpg.persistence.DatabaseManager; +import com.disklexar.mmorpg.rpg.group.Group; + +import javax.annotation.Nonnull; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * SQLite persistence for {@link Group} parties and their members. + */ +public final class GroupRepository { + + private final DatabaseManager databaseManager; + + public GroupRepository(@Nonnull DatabaseManager databaseManager) { + this.databaseManager = databaseManager; + } + + @Nonnull + public List loadAll() throws SQLException { + List groups = new ArrayList<>(); + try (PreparedStatement statement = connection().prepareStatement( + "SELECT id, owner_uuid, created_at FROM groups"); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + Group group = new Group( + UUID.fromString(resultSet.getString("id")), + UUID.fromString(resultSet.getString("owner_uuid")), + resultSet.getLong("created_at")); + groups.add(group); + } + } + for (Group group : groups) { + try (PreparedStatement statement = connection().prepareStatement( + "SELECT player_uuid FROM group_members WHERE group_id = ? ORDER BY joined_at")) { + statement.setString(1, group.getId().toString()); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + group.getMembers().add(UUID.fromString(resultSet.getString("player_uuid"))); + } + } + } + } + return groups; + } + + public void insertGroup(@Nonnull Group group) throws SQLException { + try (PreparedStatement statement = connection().prepareStatement( + "INSERT OR REPLACE INTO groups (id, owner_uuid, created_at) VALUES (?, ?, ?)")) { + statement.setString(1, group.getId().toString()); + statement.setString(2, group.getOwner().toString()); + statement.setLong(3, group.getCreatedAt()); + statement.executeUpdate(); + } + } + + public void deleteGroup(@Nonnull UUID groupId) throws SQLException { + try (PreparedStatement members = connection().prepareStatement( + "DELETE FROM group_members WHERE group_id = ?")) { + members.setString(1, groupId.toString()); + members.executeUpdate(); + } + try (PreparedStatement statement = connection().prepareStatement( + "DELETE FROM groups WHERE id = ?")) { + statement.setString(1, groupId.toString()); + statement.executeUpdate(); + } + } + + public void addMember(@Nonnull UUID groupId, @Nonnull UUID player) throws SQLException { + try (PreparedStatement statement = connection().prepareStatement( + "INSERT OR REPLACE INTO group_members (group_id, player_uuid, joined_at) VALUES (?, ?, ?)")) { + statement.setString(1, groupId.toString()); + statement.setString(2, player.toString()); + statement.setLong(3, System.currentTimeMillis()); + statement.executeUpdate(); + } + } + + public void removeMember(@Nonnull UUID groupId, @Nonnull UUID player) throws SQLException { + try (PreparedStatement statement = connection().prepareStatement( + "DELETE FROM group_members WHERE group_id = ? AND player_uuid = ?")) { + statement.setString(1, groupId.toString()); + statement.setString(2, player.toString()); + statement.executeUpdate(); + } + } + + @Nonnull + private Connection connection() { + return databaseManager.getConnection(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java b/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java new file mode 100644 index 0000000..95736d8 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java @@ -0,0 +1,148 @@ +package com.disklexar.mmorpg.persistence.repository; + +import com.disklexar.mmorpg.persistence.DatabaseManager; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import javax.annotation.Nonnull; +import java.lang.reflect.Type; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * SQLite persistence for {@link PlayerProfile}. + */ +public final class PlayerProfileRepository { + + private static final Gson GSON = new Gson(); + private static final Type STRING_LIST = new TypeToken>() { + }.getType(); + + private final DatabaseManager databaseManager; + + public PlayerProfileRepository(@Nonnull DatabaseManager databaseManager) { + this.databaseManager = databaseManager; + } + + @Nonnull + public Optional findByUuid(@Nonnull UUID uuid) throws SQLException { + String sql = """ + SELECT uuid, display_name, level, experience, class_id, powers, jobs, + guild_id, group_id, is_connected, total_time_play, last_date_connected, + date_creation, money, race_id, updated_at + FROM player_profiles + WHERE uuid = ? + """; + + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return Optional.empty(); + } + return Optional.of(mapRow(resultSet)); + } + } + } + + @Nonnull + public PlayerProfile save(@Nonnull PlayerProfile profile) throws SQLException { + String sql = """ + INSERT INTO player_profiles ( + uuid, display_name, level, experience, class_id, powers, jobs, + guild_id, group_id, is_connected, total_time_play, last_date_connected, + date_creation, money, race_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uuid) DO UPDATE SET + display_name = excluded.display_name, + level = excluded.level, + experience = excluded.experience, + class_id = excluded.class_id, + powers = excluded.powers, + jobs = excluded.jobs, + guild_id = excluded.guild_id, + group_id = excluded.group_id, + is_connected = excluded.is_connected, + total_time_play = excluded.total_time_play, + last_date_connected = excluded.last_date_connected, + money = excluded.money, + race_id = excluded.race_id, + updated_at = excluded.updated_at + """; + + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, profile.getUuid().toString()); + statement.setString(2, profile.getDisplayName()); + statement.setInt(3, profile.getLevel()); + statement.setLong(4, profile.getExperience()); + statement.setString(5, profile.getClassId()); + statement.setString(6, GSON.toJson(profile.getPowers())); + statement.setString(7, GSON.toJson(profile.getJobs())); + statement.setString(8, profile.getGuildId()); + statement.setString(9, profile.getGroupId()); + statement.setInt(10, profile.isConnected() ? 1 : 0); + statement.setLong(11, profile.getTotalTimePlay()); + statement.setLong(12, profile.getLastDateConnected()); + statement.setLong(13, profile.getDateCreation()); + statement.setLong(14, profile.getMoney()); + statement.setString(15, profile.getRaceId()); + // created_at mirrors date_creation to keep the original 001 column populated. + statement.setLong(16, profile.getDateCreation()); + statement.setLong(17, profile.getUpdatedAt()); + statement.executeUpdate(); + } + + return profile; + } + + /** Resets {@code is_connected} for every profile (called on boot to recover from crashes). */ + public void clearConnectedFlags() throws SQLException { + try (PreparedStatement statement = connection().prepareStatement( + "UPDATE player_profiles SET is_connected = 0 WHERE is_connected <> 0")) { + statement.executeUpdate(); + } + } + + @Nonnull + private PlayerProfile mapRow(@Nonnull ResultSet resultSet) throws SQLException { + PlayerProfile profile = new PlayerProfile( + UUID.fromString(resultSet.getString("uuid")), + resultSet.getString("display_name"), + resultSet.getLong("date_creation")); + profile.setLevel(resultSet.getInt("level")); + profile.setExperience(resultSet.getLong("experience")); + profile.setClassId(resultSet.getString("class_id")); + profile.getPowers().addAll(parseList(resultSet.getString("powers"))); + profile.getJobs().addAll(parseList(resultSet.getString("jobs"))); + profile.setGuildId(resultSet.getString("guild_id")); + profile.setGroupId(resultSet.getString("group_id")); + profile.setConnected(resultSet.getInt("is_connected") != 0); + profile.setTotalTimePlay(resultSet.getLong("total_time_play")); + profile.setLastDateConnected(resultSet.getLong("last_date_connected")); + profile.setMoney(resultSet.getLong("money")); + String raceId = resultSet.getString("race_id"); + profile.setRaceId(raceId != null ? raceId : "human"); + profile.setUpdatedAt(resultSet.getLong("updated_at")); + return profile; + } + + @Nonnull + private List parseList(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + List parsed = GSON.fromJson(json, STRING_LIST); + return parsed != null ? parsed : List.of(); + } + + @Nonnull + private Connection connection() { + return databaseManager.getConnection(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java b/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java new file mode 100644 index 0000000..c52beb5 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java @@ -0,0 +1,113 @@ +package com.disklexar.mmorpg.persistence.schema; + +import javax.annotation.Nonnull; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Applies ordered SQL migrations from classpath resources. + *

+ * Each migration is applied at most once. A {@code schema_migrations} table records which + * migrations already ran, so new files can be added without re-running previous ones (which + * matters for non-idempotent statements such as {@code ALTER TABLE ... ADD COLUMN}). + */ +public final class SchemaInitializer { + + private static final List MIGRATIONS = List.of( + "001_init.sql", + "002_rpg.sql"); + + private SchemaInitializer() { + } + + public static void apply(@Nonnull Connection connection) throws SQLException { + ensureMigrationsTable(connection); + for (String migration : MIGRATIONS) { + if (isApplied(connection, migration)) { + continue; + } + runMigration(connection, migration); + markApplied(connection, migration); + } + } + + private static void ensureMigrationsTable(@Nonnull Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL + ) + """); + } + } + + private static boolean isApplied(@Nonnull Connection connection, @Nonnull String migration) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT 1 FROM schema_migrations WHERE id = ?")) { + statement.setString(1, migration); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next(); + } + } + } + + private static void markApplied(@Nonnull Connection connection, @Nonnull String migration) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)")) { + statement.setString(1, migration); + statement.setLong(2, System.currentTimeMillis()); + statement.executeUpdate(); + } + } + + private static void runMigration(@Nonnull Connection connection, @Nonnull String migration) throws SQLException { + String sql = loadMigration("/db/migrations/" + migration); + try (Statement statement = connection.createStatement()) { + for (String chunk : sql.split(";")) { + String trimmed = stripComments(chunk).trim(); + if (!trimmed.isEmpty()) { + statement.execute(trimmed); + } + } + } + } + + @Nonnull + private static String stripComments(@Nonnull String sql) { + StringBuilder builder = new StringBuilder(); + for (String line : sql.split("\n")) { + String trimmed = line.trim(); + if (trimmed.startsWith("--")) { + continue; + } + builder.append(line).append('\n'); + } + return builder.toString(); + } + + @Nonnull + private static String loadMigration(@Nonnull String resourcePath) throws SQLException { + try (InputStream input = SchemaInitializer.class.getResourceAsStream(resourcePath)) { + if (input == null) { + throw new SQLException("Migration resource not found: " + resourcePath); + } + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(input, StandardCharsets.UTF_8))) { + return reader.lines().collect(Collectors.joining("\n")); + } + } catch (IOException e) { + throw new SQLException("Failed to read migration: " + resourcePath, e); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/player/OnlinePlayer.java b/src/main/java/com/disklexar/mmorpg/player/OnlinePlayer.java new file mode 100644 index 0000000..d03dc05 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/OnlinePlayer.java @@ -0,0 +1,19 @@ +package com.disklexar.mmorpg.player; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.UUID; + +/** + * A snapshot of the runtime handles for an online player, used to apply live effects. + */ +public record OnlinePlayer( + @Nonnull UUID uuid, + @Nonnull PlayerRef playerRef, + @Nonnull Ref entityRef, + @Nonnull World world) { +} diff --git a/src/main/java/com/disklexar/mmorpg/player/OnlinePlayerRegistry.java b/src/main/java/com/disklexar/mmorpg/player/OnlinePlayerRegistry.java new file mode 100644 index 0000000..287fd8d --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/OnlinePlayerRegistry.java @@ -0,0 +1,35 @@ +package com.disklexar.mmorpg.player; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks runtime handles ({@link OnlinePlayer}) for connected players so services can apply + * live effects (movement buffs, abilities) by UUID. + */ +public final class OnlinePlayerRegistry { + + private final Map online = new ConcurrentHashMap<>(); + + public void add(@Nonnull OnlinePlayer player) { + online.put(player.uuid(), player); + } + + public void remove(@Nonnull UUID uuid) { + online.remove(uuid); + } + + @Nullable + public OnlinePlayer get(@Nonnull UUID uuid) { + return online.get(uuid); + } + + @Nonnull + public Collection all() { + return online.values(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java b/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java new file mode 100644 index 0000000..2a8d731 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java @@ -0,0 +1,115 @@ +package com.disklexar.mmorpg.player; + +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.ClassCatalog; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.disklexar.mmorpg.rpg.job.JobCatalog; +import com.disklexar.mmorpg.rpg.job.JobDefinition; +import com.disklexar.mmorpg.rpg.power.PowerCatalog; +import com.disklexar.mmorpg.rpg.power.PowerDefinition; +import com.disklexar.mmorpg.rpg.race.RaceCatalog; + +import javax.annotation.Nonnull; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + +/** + * Builds a human-readable summary of every MMORPG attribute of a player. Shared by the chat + * {@code /mmorpg info} command and the custom UI page so both stay consistent. + */ +public final class PlayerInfoView { + + private static final DateTimeFormatter DATE_FORMAT = + DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault()); + + public record Entry(@Nonnull String label, @Nonnull String value) { + } + + private PlayerInfoView() { + } + + @Nonnull + public static List entries(@Nonnull PlayerProfile profile) { + List entries = new ArrayList<>(); + entries.add(new Entry("Nom", profile.getDisplayName())); + entries.add(new Entry("Niveau", String.valueOf(profile.getLevel()))); + entries.add(new Entry("Expérience", String.valueOf(profile.getExperience()))); + entries.add(new Entry("Argent", profile.getMoney() + " money")); + entries.add(new Entry("Race", RaceCatalog.byId(profile.getRaceId()).displayName())); + entries.add(new Entry("Classe", className(profile))); + entries.add(new Entry("Pouvoirs", powerNames(profile))); + entries.add(new Entry("Métiers", jobNames(profile))); + entries.add(new Entry("Groupe", profile.getGroupId() != null ? "Oui" : "Aucun")); + entries.add(new Entry("Guilde", profile.getGuildId() != null ? profile.getGuildId() : "Aucune")); + entries.add(new Entry("Connecté", profile.isConnected() ? "Oui" : "Non")); + entries.add(new Entry("Temps de jeu", formatDuration(profile.getTotalTimePlay()))); + entries.add(new Entry("Dernière connexion", formatDate(profile.getLastDateConnected()))); + entries.add(new Entry("Création", formatDate(profile.getDateCreation()))); + return entries; + } + + @Nonnull + public static String asText(@Nonnull PlayerProfile profile) { + StringBuilder builder = new StringBuilder("=== Profil MMORPG ==="); + for (Entry entry : entries(profile)) { + builder.append('\n').append(entry.label()).append(" : ").append(entry.value()); + } + return builder.toString(); + } + + @Nonnull + private static String className(@Nonnull PlayerProfile profile) { + PlayerClass playerClass = ClassCatalog.byId(profile.getClassId()); + return playerClass != null ? playerClass.displayName() : "Aucune"; + } + + @Nonnull + private static String powerNames(@Nonnull PlayerProfile profile) { + if (profile.getPowers().isEmpty()) { + return "Aucun"; + } + StringJoiner joiner = new StringJoiner(", "); + for (String id : profile.getPowers()) { + PowerDefinition power = PowerCatalog.byId(id); + joiner.add(power != null ? power.displayName() : id); + } + return joiner.toString(); + } + + @Nonnull + private static String jobNames(@Nonnull PlayerProfile profile) { + if (profile.getJobs().isEmpty()) { + return "Aucun"; + } + StringJoiner joiner = new StringJoiner(", "); + for (String id : profile.getJobs()) { + JobDefinition job = JobCatalog.byId(id); + joiner.add(job != null ? job.displayName() : id); + } + return joiner.toString(); + } + + @Nonnull + private static String formatDuration(long millis) { + if (millis <= 0) { + return "0 min"; + } + Duration duration = Duration.ofMillis(millis); + long hours = duration.toHours(); + long minutes = duration.toMinutesPart(); + return hours > 0 ? hours + "h " + minutes + "min" : minutes + "min"; + } + + @Nonnull + private static String formatDate(long epochMillis) { + if (epochMillis <= 0) { + return "—"; + } + return DATE_FORMAT.format(Instant.ofEpochMilli(epochMillis)); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java b/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java new file mode 100644 index 0000000..fb74dc8 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java @@ -0,0 +1,122 @@ +package com.disklexar.mmorpg.player; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.config.MmorpgConfig; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.race.RaceCatalog; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.SQLException; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; + +/** + * Tracks online player sessions and synchronizes profiles with SQLite. + */ +public final class PlayerSessionService implements Service { + + private final MmorpgPlugin plugin; + private final MmorpgConfig config; + private final PlayerProfileRepository repository; + private final HytaleLogger logger; + private final Map sessions = new ConcurrentHashMap<>(); + private final Map connectedAt = new ConcurrentHashMap<>(); + + public PlayerSessionService( + @Nonnull MmorpgPlugin plugin, + @Nonnull MmorpgConfig config, + @Nonnull PlayerProfileRepository repository) { + this.plugin = plugin; + this.config = config; + this.repository = repository; + this.logger = plugin.getLogger(); + } + + @Override + public void start() { + try { + repository.clearConnectedFlags(); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to reset connection flags: %s", e.getMessage()); + } + logger.at(Level.INFO).log("Player session service started"); + } + + @Override + public void shutdown() { + for (PlayerProfile profile : sessions.values()) { + accumulatePlaytime(profile.getUuid(), profile); + profile.setConnected(false); + saveQuietly(profile); + } + sessions.clear(); + connectedAt.clear(); + logger.at(Level.INFO).log("Player session service stopped"); + } + + /** + * Loads (or creates) a profile and marks the player connected. + */ + @Nonnull + public PlayerProfile loadOrCreate(@Nonnull UUID uuid, @Nonnull String displayName) throws SQLException { + long now = System.currentTimeMillis(); + Optional existing = repository.findByUuid(uuid); + PlayerProfile profile; + if (existing.isPresent()) { + profile = existing.get(); + profile.setDisplayName(displayName); + } else { + profile = new PlayerProfile(uuid, displayName, now); + profile.setLevel(config.getDefaultLevel()); + profile.setRaceId(RaceCatalog.DEFAULT_RACE_ID); + logger.at(Level.INFO).log("Created new MMORPG profile for %s", displayName); + } + + profile.setConnected(true); + profile.setLastDateConnected(now); + profile.touch(); + sessions.put(uuid, profile); + connectedAt.put(uuid, now); + saveQuietly(profile); + return profile; + } + + @Nullable + public PlayerProfile getSession(@Nonnull UUID uuid) { + return sessions.get(uuid); + } + + public void unload(@Nonnull UUID uuid) { + PlayerProfile profile = sessions.remove(uuid); + if (profile == null) { + return; + } + accumulatePlaytime(uuid, profile); + profile.setConnected(false); + profile.touch(); + saveQuietly(profile); + } + + private void accumulatePlaytime(@Nonnull UUID uuid, @Nonnull PlayerProfile profile) { + Long start = connectedAt.remove(uuid); + if (start != null) { + profile.addTimePlay(System.currentTimeMillis() - start); + } + } + + private void saveQuietly(@Nonnull PlayerProfile profile) { + try { + repository.save(profile); + } catch (SQLException e) { + logger.at(Level.WARNING).log( + "Failed to save profile for %s: %s", profile.getUuid(), e.getMessage()); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java b/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java new file mode 100644 index 0000000..4193556 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java @@ -0,0 +1,175 @@ +package com.disklexar.mmorpg.player.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +/** + * Persistent MMORPG player profile. + *

+ * Mutable so gameplay services can update progression in-session; the + * {@code updatedAt} timestamp is refreshed by {@link #touch()} on every change and the whole + * profile is flushed to SQLite on unload / shutdown. + */ +public final class PlayerProfile { + + private final UUID uuid; + private String displayName; + private int level; + private long experience; + + @Nullable + private String classId; + private final Set powers = new LinkedHashSet<>(); + private final Set jobs = new LinkedHashSet<>(); + @Nullable + private String guildId; + @Nullable + private String groupId; + private boolean connected; + private long totalTimePlay; + private long lastDateConnected; + private long money; + private String raceId = "human"; + + private final long dateCreation; + private long updatedAt; + + public PlayerProfile(@Nonnull UUID uuid, @Nonnull String displayName, long dateCreation) { + this.uuid = uuid; + this.displayName = displayName; + this.dateCreation = dateCreation; + this.updatedAt = dateCreation; + } + + @Nonnull + public UUID getUuid() { + return uuid; + } + + @Nonnull + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(@Nonnull String displayName) { + this.displayName = displayName; + } + + public int getLevel() { + return level; + } + + public void setLevel(int level) { + this.level = level; + } + + public long getExperience() { + return experience; + } + + public void setExperience(long experience) { + this.experience = experience; + } + + @Nullable + public String getClassId() { + return classId; + } + + public void setClassId(@Nullable String classId) { + this.classId = classId; + } + + @Nonnull + public Set getPowers() { + return powers; + } + + @Nonnull + public Set getJobs() { + return jobs; + } + + @Nullable + public String getGuildId() { + return guildId; + } + + public void setGuildId(@Nullable String guildId) { + this.guildId = guildId; + } + + @Nullable + public String getGroupId() { + return groupId; + } + + public void setGroupId(@Nullable String groupId) { + this.groupId = groupId; + } + + public boolean isConnected() { + return connected; + } + + public void setConnected(boolean connected) { + this.connected = connected; + } + + public long getTotalTimePlay() { + return totalTimePlay; + } + + public void setTotalTimePlay(long totalTimePlay) { + this.totalTimePlay = totalTimePlay; + } + + public void addTimePlay(long deltaMillis) { + this.totalTimePlay += Math.max(0L, deltaMillis); + } + + public long getLastDateConnected() { + return lastDateConnected; + } + + public void setLastDateConnected(long lastDateConnected) { + this.lastDateConnected = lastDateConnected; + } + + public long getMoney() { + return money; + } + + public void setMoney(long money) { + this.money = Math.max(0L, money); + } + + @Nonnull + public String getRaceId() { + return raceId; + } + + public void setRaceId(@Nonnull String raceId) { + this.raceId = raceId; + } + + public long getDateCreation() { + return dateCreation; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + /** Refreshes {@link #updatedAt} to now; call after mutating any field. */ + public void touch() { + this.updatedAt = System.currentTimeMillis(); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java b/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java new file mode 100644 index 0000000..f60d385 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/progression/ProgressionService.java @@ -0,0 +1,53 @@ +package com.disklexar.mmorpg.progression; + +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.race.RaceCatalog; +import com.disklexar.mmorpg.rpg.race.RaceDefinition; + +import javax.annotation.Nonnull; + +/** + * Experience and leveling. Applies the player's race XP multiplier (Humain x2). + */ +public final class ProgressionService { + + private final int baseXpPerLevel; + + public ProgressionService(int baseXpPerLevel) { + this.baseXpPerLevel = Math.max(1, baseXpPerLevel); + } + + /** XP required to advance from {@code level} to {@code level + 1}. */ + public long xpForLevel(int level) { + return (long) baseXpPerLevel * Math.max(1, level); + } + + /** + * Grants {@code baseAmount} XP, scaled by the player's race multiplier when requested. + * + * @return the amount of XP actually added (after multiplier). + */ + public long grantExperience(@Nonnull PlayerProfile profile, long baseAmount, boolean applyRaceMultiplier) { + if (baseAmount <= 0) { + return 0L; + } + long granted = baseAmount; + if (applyRaceMultiplier) { + RaceDefinition race = RaceCatalog.byId(profile.getRaceId()); + granted = Math.round(baseAmount * race.xpMultiplier()); + } + profile.setExperience(profile.getExperience() + granted); + applyLevelUps(profile); + profile.touch(); + return granted; + } + + private void applyLevelUps(@Nonnull PlayerProfile profile) { + long needed = xpForLevel(profile.getLevel()); + while (profile.getExperience() >= needed) { + profile.setExperience(profile.getExperience() - needed); + profile.setLevel(profile.getLevel() + 1); + needed = xpForLevel(profile.getLevel()); + } + } +} diff --git a/src/main/java/com/disklexar/mmorpg/quest/package-info.java b/src/main/java/com/disklexar/mmorpg/quest/package-info.java new file mode 100644 index 0000000..4c8227d --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/quest/package-info.java @@ -0,0 +1,7 @@ +/** + * Quest system (objectives, rewards, quest chains). + *

+ * Disabled by default via {@code Features.Quests} in config.json. + * Planned: quest definitions, progress tracking, daily quests. + */ +package com.disklexar.mmorpg.quest; diff --git a/src/main/java/com/disklexar/mmorpg/rpg/clazz/AbilityDefinition.java b/src/main/java/com/disklexar/mmorpg/rpg/clazz/AbilityDefinition.java new file mode 100644 index 0000000..919a2bf --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/clazz/AbilityDefinition.java @@ -0,0 +1,14 @@ +package com.disklexar.mmorpg.rpg.clazz; + +import javax.annotation.Nonnull; + +/** + * A single class ability (active skill) with its cooldown. + */ +public record AbilityDefinition( + @Nonnull String id, + int slot, + @Nonnull String displayName, + @Nonnull String description, + long cooldownMillis) { +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java b/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java new file mode 100644 index 0000000..44bd9ae --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassCatalog.java @@ -0,0 +1,119 @@ +package com.disklexar.mmorpg.rpg.clazz; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Registry of all playable classes. + */ +public final class ClassCatalog { + + public static final String WEAPON_LONGSWORD = "LongSword"; + public static final String WEAPON_BATTLEAXE = "BattleAxe"; + public static final String WEAPON_BOW = "Bow"; + public static final String WEAPON_CROSSBOW = "Crossbow"; + public static final String WEAPON_STAFF = "Staff"; + public static final String WEAPON_DAGGERS = "Daggers"; + + public static final String ABILITY_DASH = "knight_dash"; + public static final String ABILITY_SPIN = "knight_spin"; + public static final String ABILITY_SHOUT = "knight_shout"; + + public static final String ABILITY_ARCHER_RAPIDFIRE = "archer_rapidfire"; + public static final String ABILITY_ARCHER_VOLLEY = "archer_volley"; + public static final String ABILITY_ARCHER_POWERSHOT = "archer_powershot"; + + public static final String ABILITY_MAGE_FIREBALLS = "mage_fireballs"; + public static final String ABILITY_MAGE_TELEPORT = "mage_teleport"; + public static final String ABILITY_MAGE_TORNADO = "mage_tornado"; + + public static final String ABILITY_ASSASSIN_CAMOUFLAGE = "assassin_camouflage"; + public static final String ABILITY_ASSASSIN_SHADOWSTEP = "assassin_shadowstep"; + public static final String ABILITY_ASSASSIN_POISONBLADE = "assassin_poisonblade"; + + public static final PlayerClass KNIGHT = new PlayerClass( + "knight", + "Chevalier", + "Combattant lourd au corps-à-corps. Utilise l'Épée longue et la Hache de bataille.", + List.of(WEAPON_LONGSWORD, WEAPON_BATTLEAXE), + List.of( + new AbilityDefinition(ABILITY_DASH, 1, "Charge", + "Petit dash en avant qui étourdit 3s tous les ennemis touchés.", 8_000L), + new AbilityDefinition(ABILITY_SPIN, 2, "Toupie", + "Tournoiement infligeant des dégâts autour de vous pendant 3s.", 12_000L), + new AbilityDefinition(ABILITY_SHOUT, 3, "Cri de guerre", + "Repousse les entités proches (dégâts bonus si étourdies) et réduit leur résistance de 20% pendant 20s.", + 15_000L))); + + public static final PlayerClass ARCHER = new PlayerClass( + "archer", + "Archer", + "Tireur à distance. Utilise l'Arc et l'Arbalète.", + List.of(WEAPON_BOW, WEAPON_CROSSBOW), + List.of( + new AbilityDefinition(ABILITY_ARCHER_RAPIDFIRE, 1, "Tir rapide", + "Augmente votre cadence de tir (+20% de dégâts) pendant 10s.", 12_000L), + new AbilityDefinition(ABILITY_ARCHER_VOLLEY, 2, "Salve", + "Bond en arrière puis décoche instantanément 6 flèches devant vous.", 10_000L), + new AbilityDefinition(ABILITY_ARCHER_POWERSHOT, 3, "Tir puissant", + "Votre prochaine attaque de base inflige +200% de dégâts.", 9_000L))); + + public static final PlayerClass MAGE = new PlayerClass( + "mage", + "Mage", + "Lanceur de sorts à distance. Utilise le Bâton.", + List.of(WEAPON_STAFF), + List.of( + new AbilityDefinition(ABILITY_MAGE_FIREBALLS, 1, "Nova de Givre", + "Envoie instantanément 3 boules de feu vers l'avant.", 8_000L), + new AbilityDefinition(ABILITY_MAGE_TELEPORT, 2, "Téléportation", + "Vous téléporte instantanément de 8 blocs vers l'avant.", 10_000L), + new AbilityDefinition(ABILITY_MAGE_TORNADO, 3, "Surcharge Arcane", + "Crée une tornade dévastatrice 15 blocs devant vous.", 16_000L))); + + public static final PlayerClass ASSASSIN = new PlayerClass( + "assassin", + "Assassin", + "Tueur furtif au corps-à-corps. Utilise les Dagues.", + List.of(WEAPON_DAGGERS), + List.of( + new AbilityDefinition(ABILITY_ASSASSIN_CAMOUFLAGE, 1, "Camouflage", + "Devient totalement invisible pendant 6s.", 14_000L), + new AbilityDefinition(ABILITY_ASSASSIN_SHADOWSTEP, 2, "Pas de l'Ombre", + "Se téléporte instantanément dans le dos de la cible visée.", 9_000L), + new AbilityDefinition(ABILITY_ASSASSIN_POISONBLADE, 3, "Lame Empoisonnée", + "Pendant 10s, chaque coup applique un poison (dégâts magiques/seconde pendant 5s, cumulable).", + 13_000L))); + + private static final Map BY_ID = new LinkedHashMap<>(); + + static { + register(KNIGHT); + register(ARCHER); + register(MAGE); + register(ASSASSIN); + } + + private ClassCatalog() { + } + + private static void register(@Nonnull PlayerClass playerClass) { + BY_ID.put(playerClass.id().toLowerCase(), playerClass); + } + + @Nullable + public static PlayerClass byId(@Nullable String id) { + if (id == null) { + return null; + } + return BY_ID.get(id.toLowerCase()); + } + + @Nonnull + public static List all() { + return List.copyOf(BY_ID.values()); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassService.java b/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassService.java new file mode 100644 index 0000000..f37d078 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/clazz/ClassService.java @@ -0,0 +1,45 @@ +package com.disklexar.mmorpg.rpg.clazz; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Selecting / leaving a player class. + */ +public final class ClassService { + + @Nullable + public PlayerClass getClass(@Nonnull PlayerProfile profile) { + return ClassCatalog.byId(profile.getClassId()); + } + + public boolean hasClass(@Nonnull PlayerProfile profile) { + return getClass(profile) != null; + } + + /** + * Assigns a class to the player. + * + * @return {@code true} if the class exists and was assigned. + */ + public boolean choose(@Nonnull PlayerProfile profile, @Nonnull String classId) { + PlayerClass playerClass = ClassCatalog.byId(classId); + if (playerClass == null) { + return false; + } + profile.setClassId(playerClass.id()); + profile.touch(); + return true; + } + + public boolean leave(@Nonnull PlayerProfile profile) { + if (profile.getClassId() == null) { + return false; + } + profile.setClassId(null); + profile.touch(); + return true; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java b/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java new file mode 100644 index 0000000..1a818af --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/clazz/PlayerClass.java @@ -0,0 +1,24 @@ +package com.disklexar.mmorpg.rpg.clazz; + +import javax.annotation.Nonnull; +import java.util.List; + +/** + * Definition of a playable class: its usable weapons and its abilities. + */ +public record PlayerClass( + @Nonnull String id, + @Nonnull String displayName, + @Nonnull String description, + @Nonnull List weapons, + @Nonnull List abilities) { + + public boolean usesWeapon(@Nonnull String weaponId) { + for (String weapon : weapons) { + if (weapon.equalsIgnoreCase(weaponId)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/group/Group.java b/src/main/java/com/disklexar/mmorpg/rpg/group/Group.java new file mode 100644 index 0000000..e9cb8e4 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/group/Group.java @@ -0,0 +1,61 @@ +package com.disklexar.mmorpg.rpg.group; + +import javax.annotation.Nonnull; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A party of players. When a member kills a mob, the other members receive a share of the XP. + */ +public final class Group { + + private final UUID id; + private final UUID owner; + private final long createdAt; + private final Set members = ConcurrentHashMap.newKeySet(); + private final Set pendingInvites = ConcurrentHashMap.newKeySet(); + + public Group(@Nonnull UUID id, @Nonnull UUID owner, long createdAt) { + this.id = id; + this.owner = owner; + this.createdAt = createdAt; + this.members.add(owner); + } + + @Nonnull + public UUID getId() { + return id; + } + + @Nonnull + public UUID getOwner() { + return owner; + } + + public long getCreatedAt() { + return createdAt; + } + + @Nonnull + public Set getMembers() { + return members; + } + + @Nonnull + public Set getPendingInvites() { + return pendingInvites; + } + + public boolean isOwner(@Nonnull UUID uuid) { + return owner.equals(uuid); + } + + @Nonnull + public Set membersExcept(@Nonnull UUID uuid) { + Set others = new LinkedHashSet<>(members); + others.remove(uuid); + return others; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/group/GroupService.java b/src/main/java/com/disklexar/mmorpg/rpg/group/GroupService.java new file mode 100644 index 0000000..b7cce67 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/group/GroupService.java @@ -0,0 +1,257 @@ +package com.disklexar.mmorpg.rpg.group; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.persistence.repository.GroupRepository; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.hypixel.hytale.logger.HytaleLogger; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.SQLException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; + +/** + * Manages parties (groups) and shared kill experience. + *

+ * When a member kills a mob, the other online members receive + * {@value #SHARED_XP_FRACTION_PERCENT}% of the XP the killer gained. + */ +public final class GroupService implements Service { + + public static final int SHARED_XP_FRACTION_PERCENT = 40; + private static final double SHARED_XP_FRACTION = SHARED_XP_FRACTION_PERCENT / 100.0; + + private final HytaleLogger logger; + private final GroupRepository repository; + private final PlayerSessionService sessions; + private final ProgressionService progression; + + private final Map groupsById = new ConcurrentHashMap<>(); + private final Map groupByPlayer = new ConcurrentHashMap<>(); + + public GroupService( + @Nonnull MmorpgPlugin plugin, + @Nonnull GroupRepository repository, + @Nonnull PlayerSessionService sessions, + @Nonnull ProgressionService progression) { + this.logger = plugin.getLogger(); + this.repository = repository; + this.sessions = sessions; + this.progression = progression; + } + + @Override + public void start() { + try { + for (Group group : repository.loadAll()) { + groupsById.put(group.getId(), group); + for (UUID member : group.getMembers()) { + groupByPlayer.put(member, group.getId()); + } + } + logger.at(Level.INFO).log("Loaded %d MMORPG groups", groupsById.size()); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to load groups: %s", e.getMessage()); + } + } + + @Override + public void shutdown() { + groupsById.clear(); + groupByPlayer.clear(); + } + + @Nullable + public Group getGroupOf(@Nonnull UUID player) { + UUID groupId = groupByPlayer.get(player); + return groupId == null ? null : groupsById.get(groupId); + } + + @Nullable + public Group getById(@Nonnull UUID groupId) { + return groupsById.get(groupId); + } + + /** Invites {@code target} to {@code inviter}'s group, creating the group if needed. */ + @Nonnull + public InviteResult invite(@Nonnull UUID inviter, @Nonnull UUID target) { + if (inviter.equals(target)) { + return InviteResult.SELF; + } + if (getGroupOf(target) != null) { + return InviteResult.TARGET_ALREADY_GROUPED; + } + Group group = getGroupOf(inviter); + if (group == null) { + group = createGroup(inviter); + } else if (!group.isOwner(inviter)) { + return InviteResult.NOT_OWNER; + } + group.getPendingInvites().add(target); + return InviteResult.INVITED; + } + + /** Accepts a pending invitation. */ + @Nonnull + public AcceptResult accept(@Nonnull UUID player) { + if (getGroupOf(player) != null) { + return AcceptResult.ALREADY_GROUPED; + } + for (Group group : groupsById.values()) { + if (group.getPendingInvites().remove(player)) { + addMember(group, player); + return AcceptResult.JOINED; + } + } + return AcceptResult.NO_INVITE; + } + + /** Removes the player from their group; disbands it if the owner leaves or it becomes empty. */ + public boolean leave(@Nonnull UUID player) { + Group group = getGroupOf(player); + if (group == null) { + return false; + } + if (group.isOwner(player)) { + disband(group); + return true; + } + removeMember(group, player); + if (group.getMembers().size() <= 1) { + disband(group); + } + return true; + } + + @Nonnull + public KickResult kick(@Nonnull UUID owner, @Nonnull UUID target) { + Group group = getGroupOf(owner); + if (group == null || !group.isOwner(owner)) { + return KickResult.NOT_OWNER; + } + if (owner.equals(target)) { + return KickResult.CANNOT_KICK_SELF; + } + if (!group.getMembers().contains(target)) { + return KickResult.NOT_MEMBER; + } + removeMember(group, target); + if (group.getMembers().size() <= 1) { + disband(group); + } + return KickResult.KICKED; + } + + /** + * Distributes shared XP to the killer's online group mates. + * + * @return number of members who received XP. + */ + public int shareKillExperience(@Nonnull UUID killer, long killerGainedXp) { + if (killerGainedXp <= 0) { + return 0; + } + Group group = getGroupOf(killer); + if (group == null) { + return 0; + } + long shared = Math.round(killerGainedXp * SHARED_XP_FRACTION); + if (shared <= 0) { + return 0; + } + int rewarded = 0; + for (UUID member : group.membersExcept(killer)) { + PlayerProfile profile = sessions.getSession(member); + if (profile == null) { + continue; + } + progression.grantExperience(profile, shared, false); + rewarded++; + } + return rewarded; + } + + @Nonnull + private Group createGroup(@Nonnull UUID owner) { + Group group = new Group(UUID.randomUUID(), owner, System.currentTimeMillis()); + groupsById.put(group.getId(), group); + groupByPlayer.put(owner, group.getId()); + persistGroup(group); + addMemberPersistence(group.getId(), owner); + setProfileGroup(owner, group.getId()); + return group; + } + + private void addMember(@Nonnull Group group, @Nonnull UUID player) { + group.getMembers().add(player); + groupByPlayer.put(player, group.getId()); + addMemberPersistence(group.getId(), player); + setProfileGroup(player, group.getId()); + } + + private void removeMember(@Nonnull Group group, @Nonnull UUID player) { + group.getMembers().remove(player); + groupByPlayer.remove(player); + try { + repository.removeMember(group.getId(), player); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to remove group member: %s", e.getMessage()); + } + setProfileGroup(player, null); + } + + private void disband(@Nonnull Group group) { + for (UUID member : group.getMembers()) { + groupByPlayer.remove(member); + setProfileGroup(member, null); + } + groupsById.remove(group.getId()); + try { + repository.deleteGroup(group.getId()); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to delete group: %s", e.getMessage()); + } + } + + private void persistGroup(@Nonnull Group group) { + try { + repository.insertGroup(group); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to persist group: %s", e.getMessage()); + } + } + + private void addMemberPersistence(@Nonnull UUID groupId, @Nonnull UUID player) { + try { + repository.addMember(groupId, player); + } catch (SQLException e) { + logger.at(Level.WARNING).log("Failed to persist group member: %s", e.getMessage()); + } + } + + private void setProfileGroup(@Nonnull UUID player, @Nullable UUID groupId) { + PlayerProfile profile = sessions.getSession(player); + if (profile != null) { + profile.setGroupId(groupId == null ? null : groupId.toString()); + profile.touch(); + } + } + + public enum InviteResult { + INVITED, SELF, NOT_OWNER, TARGET_ALREADY_GROUPED + } + + public enum AcceptResult { + JOINED, NO_INVITE, ALREADY_GROUPED + } + + public enum KickResult { + KICKED, NOT_OWNER, NOT_MEMBER, CANNOT_KICK_SELF + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/job/JobCatalog.java b/src/main/java/com/disklexar/mmorpg/rpg/job/JobCatalog.java new file mode 100644 index 0000000..f449954 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/job/JobCatalog.java @@ -0,0 +1,55 @@ +package com.disklexar.mmorpg.rpg.job; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Registry of all jobs. + */ +public final class JobCatalog { + + /** Money granted per mob killed while the Monster Slayer job is active. */ + public static final long MONSTER_SLAYER_REWARD = 1L; + /** Money granted per block broken while the Miner job is active. */ + public static final long MINER_REWARD = 1L; + + public static final JobDefinition MONSTER_SLAYER = new JobDefinition( + "monster_slayer", + "Tueur de monstre", + "Gagne 1 money à chaque monstre tué."); + + public static final JobDefinition MINER = new JobDefinition( + "miner", + "Mineur", + "Gagne 1 money à chaque bloc cassé."); + + private static final Map BY_ID = new LinkedHashMap<>(); + + static { + register(MONSTER_SLAYER); + register(MINER); + } + + private JobCatalog() { + } + + private static void register(@Nonnull JobDefinition job) { + BY_ID.put(job.id().toLowerCase(), job); + } + + @Nullable + public static JobDefinition byId(@Nullable String id) { + if (id == null) { + return null; + } + return BY_ID.get(id.toLowerCase()); + } + + @Nonnull + public static List all() { + return List.copyOf(BY_ID.values()); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/job/JobDefinition.java b/src/main/java/com/disklexar/mmorpg/rpg/job/JobDefinition.java new file mode 100644 index 0000000..a19a8ff --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/job/JobDefinition.java @@ -0,0 +1,12 @@ +package com.disklexar.mmorpg.rpg.job; + +import javax.annotation.Nonnull; + +/** + * A job a player can join to earn money from a specific activity. + */ +public record JobDefinition( + @Nonnull String id, + @Nonnull String displayName, + @Nonnull String description) { +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/job/JobService.java b/src/main/java/com/disklexar/mmorpg/rpg/job/JobService.java new file mode 100644 index 0000000..880a1a6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/job/JobService.java @@ -0,0 +1,49 @@ +package com.disklexar.mmorpg.rpg.job; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; + +/** + * Joining / leaving jobs. + */ +public final class JobService { + + public boolean has(@Nonnull PlayerProfile profile, @Nonnull String jobId) { + return profile.getJobs().contains(jobId.toLowerCase()); + } + + /** + * Joins a job. + * + * @return {@code true} if the job exists and was newly joined. + */ + public boolean join(@Nonnull PlayerProfile profile, @Nonnull String jobId) { + JobDefinition job = JobCatalog.byId(jobId); + if (job == null) { + return false; + } + boolean added = profile.getJobs().add(job.id()); + if (added) { + profile.touch(); + } + return added; + } + + /** + * Leaves a job. + * + * @return {@code true} if the job was active and was left. + */ + public boolean leave(@Nonnull PlayerProfile profile, @Nonnull String jobId) { + JobDefinition job = JobCatalog.byId(jobId); + if (job == null) { + return false; + } + boolean removed = profile.getJobs().remove(job.id()); + if (removed) { + profile.touch(); + } + return removed; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/power/PowerCatalog.java b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerCatalog.java new file mode 100644 index 0000000..15e0ffc --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerCatalog.java @@ -0,0 +1,57 @@ +package com.disklexar.mmorpg.rpg.power; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Registry of all passive powers. + */ +public final class PowerCatalog { + + /** Constant out-of-combat movement bonus. */ + public static final double SPRINTER_SPEED_BONUS = 0.10; + /** Damage bonus applied for a short window after being hit. */ + public static final double RESILIENT_DAMAGE_BONUS = 0.10; + /** Window during which {@link #RESILIENT} grants its bonus, in milliseconds. */ + public static final long RESILIENT_WINDOW_MILLIS = 5_000L; + + public static final PowerDefinition SPRINTER = new PowerDefinition( + "sprinter", + "Sprinteur", + "+10% de vitesse en permanence hors combat."); + + public static final PowerDefinition RESILIENT = new PowerDefinition( + "resilient", + "Résilient", + "+10% de dégâts pendant 5s après avoir été touché."); + + private static final Map BY_ID = new LinkedHashMap<>(); + + static { + register(SPRINTER); + register(RESILIENT); + } + + private PowerCatalog() { + } + + private static void register(@Nonnull PowerDefinition power) { + BY_ID.put(power.id().toLowerCase(), power); + } + + @Nullable + public static PowerDefinition byId(@Nullable String id) { + if (id == null) { + return null; + } + return BY_ID.get(id.toLowerCase()); + } + + @Nonnull + public static List all() { + return List.copyOf(BY_ID.values()); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/power/PowerDefinition.java b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerDefinition.java new file mode 100644 index 0000000..535c1c9 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerDefinition.java @@ -0,0 +1,12 @@ +package com.disklexar.mmorpg.rpg.power; + +import javax.annotation.Nonnull; + +/** + * A passive power a player can own. Players start with none. + */ +public record PowerDefinition( + @Nonnull String id, + @Nonnull String displayName, + @Nonnull String description) { +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/power/PowerService.java b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerService.java new file mode 100644 index 0000000..409edb6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/power/PowerService.java @@ -0,0 +1,49 @@ +package com.disklexar.mmorpg.rpg.power; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; + +/** + * Granting / removing passive powers. Players start with none. + */ +public final class PowerService { + + public boolean has(@Nonnull PlayerProfile profile, @Nonnull String powerId) { + return profile.getPowers().contains(powerId.toLowerCase()); + } + + /** + * Grants a power. + * + * @return {@code true} if the power exists and was newly added. + */ + public boolean grant(@Nonnull PlayerProfile profile, @Nonnull String powerId) { + PowerDefinition power = PowerCatalog.byId(powerId); + if (power == null) { + return false; + } + boolean added = profile.getPowers().add(power.id()); + if (added) { + profile.touch(); + } + return added; + } + + /** + * Removes a power. + * + * @return {@code true} if the power was present and removed. + */ + public boolean remove(@Nonnull PlayerProfile profile, @Nonnull String powerId) { + PowerDefinition power = PowerCatalog.byId(powerId); + if (power == null) { + return false; + } + boolean removed = profile.getPowers().remove(power.id()); + if (removed) { + profile.touch(); + } + return removed; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/race/RaceCatalog.java b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceCatalog.java new file mode 100644 index 0000000..9d4b171 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceCatalog.java @@ -0,0 +1,46 @@ +package com.disklexar.mmorpg.rpg.race; + +import javax.annotation.Nonnull; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Registry of all races. Every player defaults to {@link #HUMAN}. + */ +public final class RaceCatalog { + + public static final String DEFAULT_RACE_ID = "human"; + + public static final RaceDefinition HUMAN = new RaceDefinition( + DEFAULT_RACE_ID, + "Humain", + "Race par défaut. Gagne le double d'expérience.", + 2.0); + + private static final Map BY_ID = new LinkedHashMap<>(); + + static { + register(HUMAN); + } + + private RaceCatalog() { + } + + private static void register(@Nonnull RaceDefinition race) { + BY_ID.put(race.id().toLowerCase(), race); + } + + @Nonnull + public static RaceDefinition byId(String id) { + if (id == null) { + return HUMAN; + } + return BY_ID.getOrDefault(id.toLowerCase(), HUMAN); + } + + @Nonnull + public static List all() { + return List.copyOf(BY_ID.values()); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/race/RaceDefinition.java b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceDefinition.java new file mode 100644 index 0000000..73849d4 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceDefinition.java @@ -0,0 +1,13 @@ +package com.disklexar.mmorpg.rpg.race; + +import javax.annotation.Nonnull; + +/** + * A playable race with its global XP multiplier. + */ +public record RaceDefinition( + @Nonnull String id, + @Nonnull String displayName, + @Nonnull String description, + double xpMultiplier) { +} diff --git a/src/main/java/com/disklexar/mmorpg/rpg/race/RaceService.java b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceService.java new file mode 100644 index 0000000..d1a19ca --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/rpg/race/RaceService.java @@ -0,0 +1,35 @@ +package com.disklexar.mmorpg.rpg.race; + +import com.disklexar.mmorpg.player.model.PlayerProfile; + +import javax.annotation.Nonnull; + +/** + * Race access. Every player defaults to Humain (set at profile creation). + */ +public final class RaceService { + + @Nonnull + public RaceDefinition getRace(@Nonnull PlayerProfile profile) { + return RaceCatalog.byId(profile.getRaceId()); + } + + /** Ensures the profile has a valid race, defaulting to Humain. */ + public void ensureDefault(@Nonnull PlayerProfile profile) { + if (RaceCatalog.byId(profile.getRaceId()) == RaceCatalog.HUMAN + && !RaceCatalog.DEFAULT_RACE_ID.equals(profile.getRaceId())) { + profile.setRaceId(RaceCatalog.DEFAULT_RACE_ID); + profile.touch(); + } + } + + public boolean setRace(@Nonnull PlayerProfile profile, @Nonnull String raceId) { + RaceDefinition race = RaceCatalog.byId(raceId); + if (!race.id().equalsIgnoreCase(raceId)) { + return false; + } + profile.setRaceId(race.id()); + profile.touch(); + return true; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/social/package-info.java b/src/main/java/com/disklexar/mmorpg/social/package-info.java new file mode 100644 index 0000000..6b4ada7 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/social/package-info.java @@ -0,0 +1,7 @@ +/** + * Social systems (parties, guilds, friends). + *

+ * Disabled by default via {@code Features.Guilds} in config.json. + * Planned: guild creation, ranks, shared storage, party instances. + */ +package com.disklexar.mmorpg.social; diff --git a/src/main/java/com/disklexar/mmorpg/ui/AbilityBarHud.java b/src/main/java/com/disklexar/mmorpg/ui/AbilityBarHud.java new file mode 100644 index 0000000..6c26eb6 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/ui/AbilityBarHud.java @@ -0,0 +1,83 @@ +package com.disklexar.mmorpg.ui; + +import com.disklexar.mmorpg.combat.AbilityService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud; +import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; +import com.hypixel.hytale.server.core.universe.PlayerRef; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; + +/** + * Persistent MMORPG HUD: vitals (health, stamina, XP) and class ability bar (top-left). + */ +public final class AbilityBarHud extends CustomUIHud { + + public static final String KEY = "mmorpg_ability_bar"; + private static final String DOCUMENT = "MmorpgAbilityBar.ui"; + public AbilityBarHud(@Nonnull PlayerRef playerRef) { + super(playerRef, KEY); + } + + @Override + protected void build(@Nonnull UICommandBuilder ui) { + ui.append(DOCUMENT); + } + + public void sync( + @Nonnull PlayerProfile profile, + @Nonnull PlayerClass playerClass, + @Nonnull AbilityService abilities, + @Nonnull ProgressionService progression, + @Nullable PlayerVitalsReader.Vitals vitals, + @Nonnull UUID playerId) { + UICommandBuilder ui = new UICommandBuilder(); + + ui.set("#HeaderLabel.Text", escape( + "Nv." + profile.getLevel() + " — " + playerClass.displayName())); + + if (vitals != null) { + applyBar(ui, "#HealthBar", vitals.current(), vitals.max()); + ui.set("#HealthValue.Text", escape(vitals.current() + " / " + vitals.max())); + + applyBar(ui, "#StaminaBar", vitals.staminaCurrent(), vitals.staminaMax()); + ui.set("#StaminaValue.Text", escape( + vitals.staminaCurrent() + " / " + vitals.staminaMax())); + } + + long xpCurrent = profile.getExperience(); + long xpMax = Math.max(1L, progression.xpForLevel(profile.getLevel())); + applyBar(ui, "#XpBar", xpCurrent, xpMax); + ui.set("#XpValue.Text", escape(xpCurrent + " / " + xpMax)); + + for (AbilityDefinition ability : playerClass.abilities()) { + int slot = ability.slot(); + long remaining = abilities.cooldownRemaining(playerId, ability.id()); + String label = remaining > 0 + ? ability.displayName() + " (" + (remaining / 1000 + 1) + "s)" + : ability.displayName(); + ui.set("#Slot" + slot + "Name.Text", escape(label)); + } + + update(false, ui); + } + + private static void applyBar( + @Nonnull UICommandBuilder ui, + @Nonnull String barSelector, + long current, + long max) { + float value = max <= 0 ? 0f : Math.min(1f, (float) current / max); + ui.set(barSelector + ".Value", value); + } + + @Nonnull + private static String escape(@Nonnull String text) { + return text.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java b/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java new file mode 100644 index 0000000..f76f094 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/ui/AbilityBarService.java @@ -0,0 +1,237 @@ +package com.disklexar.mmorpg.ui; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.combat.AbilityService; +import com.disklexar.mmorpg.combat.MmorpgScheduler; +import com.disklexar.mmorpg.core.service.Service; +import com.disklexar.mmorpg.player.OnlinePlayer; +import com.disklexar.mmorpg.player.OnlinePlayerRegistry; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.progression.ProgressionService; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +import java.util.logging.Level; + +/** + * Shows and refreshes the native ability-bar HUD for players with an MMORPG class. + */ +public final class AbilityBarService implements Service { + + private final HytaleLogger logger; + private final ClassService classService; + private final AbilityService abilityService; + private final OnlinePlayerRegistry onlinePlayers; + private final PlayerSessionService sessions; + private final ProgressionService progression; + private final MmorpgScheduler scheduler; + + private final Map activeHuds = new ConcurrentHashMap<>(); + private ScheduledFuture refreshTask; + + public AbilityBarService( + @Nonnull MmorpgPlugin plugin, + @Nonnull ClassService classService, + @Nonnull AbilityService abilityService, + @Nonnull OnlinePlayerRegistry onlinePlayers, + @Nonnull PlayerSessionService sessions, + @Nonnull ProgressionService progression, + @Nonnull MmorpgScheduler scheduler) { + this.logger = plugin.getLogger(); + this.classService = classService; + this.abilityService = abilityService; + this.onlinePlayers = onlinePlayers; + this.sessions = sessions; + this.progression = progression; + this.scheduler = scheduler; + } + + @Override + public void start() { + refreshTask = scheduler.runRepeating(this::refreshAll, 5_000L, 1_000L); + } + + @Override + public void shutdown() { + if (refreshTask != null) { + refreshTask.cancel(false); + refreshTask = null; + } + activeHuds.clear(); + } + + public void sync(@Nonnull UUID playerUuid, @Nonnull PlayerProfile profile) { + OnlinePlayer online = onlinePlayers.get(playerUuid); + if (online == null) { + return; + } + sync(online, profile); + } + + public void sync(@Nonnull OnlinePlayer online, @Nonnull PlayerProfile profile) { + PlayerClass playerClass = classService.getClass(profile); + if (playerClass == null) { + dismiss(online.uuid()); + return; + } + + online.world().execute(() -> applyHud(online, profile, playerClass)); + } + + public void dismiss(@Nonnull UUID playerUuid) { + OnlinePlayer online = onlinePlayers.get(playerUuid); + if (online == null) { + activeHuds.remove(playerUuid); + return; + } + dismiss(online); + } + + public void dismiss(@Nonnull OnlinePlayer online) { + AbilityBarHud hud = activeHuds.remove(online.uuid()); + if (hud == null) { + return; + } + online.world().execute(() -> { + Player player = resolvePlayer(online.world(), online.uuid()); + if (player != null) { + player.getHudManager().removeCustomHud(online.playerRef(), AbilityBarHud.KEY); + } + }); + } + + private void refreshAll() { + for (OnlinePlayer online : onlinePlayers.all()) { + PlayerProfile profile = sessions.getSession(online.uuid()); + if (profile == null || !classService.hasClass(profile)) { + if (activeHuds.containsKey(online.uuid())) { + dismiss(online.uuid()); + } + continue; + } + AbilityBarHud hud = activeHuds.get(online.uuid()); + if (hud == null) { + sync(online, profile); + continue; + } + PlayerClass playerClass = classService.getClass(profile); + if (playerClass == null) { + continue; + } + online.world().execute(() -> { + if (onlinePlayers.get(online.uuid()) != online) { + return; + } + if (resolvePlayer(online.world(), online.uuid()) == null) { + activeHuds.remove(online.uuid()); + return; + } + pushUpdate(online, profile, playerClass, hud); + }); + } + } + + private void applyHud( + @Nonnull OnlinePlayer online, + @Nonnull PlayerProfile profile, + @Nonnull PlayerClass playerClass) { + try { + if (onlinePlayers.get(online.uuid()) == null) { + return; + } + + Player player = resolvePlayer(online.world(), online.uuid()); + if (player == null) { + logger.at(Level.FINE).log( + "Ability bar HUD deferred for %s: player not in world store yet", + online.playerRef().getUsername()); + return; + } + + AbilityBarHud hud = activeHuds.get(online.uuid()); + if (hud == null) { + hud = new AbilityBarHud(online.playerRef()); + player.getHudManager().addCustomHud(online.playerRef(), hud); + activeHuds.put(online.uuid(), hud); + logger.at(Level.INFO).log( + "MMORPG HUD shown for %s", + online.playerRef().getUsername()); + } + pushUpdate(online, profile, playerClass, hud); + } catch (Throwable t) { + activeHuds.remove(online.uuid()); + logger.at(Level.WARNING).log( + "Ability bar HUD failed for %s: %s", + online.playerRef().getUsername(), + t.getMessage()); + } + } + + private void pushUpdate( + @Nonnull OnlinePlayer online, + @Nonnull PlayerProfile profile, + @Nonnull PlayerClass playerClass, + @Nonnull AbilityBarHud hud) { + if (!activeHuds.containsKey(online.uuid()) || onlinePlayers.get(online.uuid()) == null) { + return; + } + World world = online.world(); + Store store = world.getEntityStore().getStore(); + Ref entityRef = resolveEntityRef(world, online.uuid()); + PlayerVitalsReader.Vitals vitals = entityRef == null + ? null + : PlayerVitalsReader.read(store, entityRef); + hud.sync(profile, playerClass, abilityService, progression, vitals, profile.getUuid()); + } + + @Nullable + private static Ref resolveEntityRef(@Nonnull World world, @Nonnull UUID playerUuid) { + for (PlayerRef playerRef : world.getPlayerRefs()) { + if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) { + continue; + } + Ref entityRef = playerRef.getReference(); + if (entityRef != null && entityRef.isValid()) { + return entityRef; + } + } + return null; + } + + /** + * Looks up the live {@link Player} from the world's player list instead of a cached + * {@link Ref} that may be unset or invalidated after {@code ClientReady}. + */ + @Nullable + private static Player resolvePlayer(@Nonnull World world, @Nonnull UUID playerUuid) { + Store store = world.getEntityStore().getStore(); + for (PlayerRef playerRef : world.getPlayerRefs()) { + if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) { + continue; + } + Ref entityRef = playerRef.getReference(); + if (entityRef == null || !entityRef.isValid()) { + continue; + } + Player player = store.getComponent(entityRef, Player.getComponentType()); + if (player != null) { + return player; + } + } + return null; + } +} diff --git a/src/main/java/com/disklexar/mmorpg/ui/PlayerInfoPage.java b/src/main/java/com/disklexar/mmorpg/ui/PlayerInfoPage.java new file mode 100644 index 0000000..5885d9c --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/ui/PlayerInfoPage.java @@ -0,0 +1,94 @@ +package com.disklexar.mmorpg.ui; + +import com.disklexar.mmorpg.player.PlayerInfoView; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; +import com.hypixel.hytale.server.core.entity.entities.player.pages.CustomUIPage; +import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; +import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.util.List; + +/** + * Custom UI page listing every MMORPG attribute of the player. + *

+ * Custom UI documents must already exist on the client (they ship in the game's {@code Assets.zip}), + * so we reuse the always-present {@code Pages/WarpListPage.ui} layout — a titled window with a + * scrolling list container ({@code #WarpList}) and a back button — and fill that container with one + * label per attribute via {@link UICommandBuilder#appendInline(String, String)}. This is the same + * mechanism the builtin pages use to inject rows, and it avoids the previous crash that came from + * passing inline markup to {@link UICommandBuilder#append(String)} (which expects a document path). + */ +public final class PlayerInfoPage extends CustomUIPage { + + /** Reliable layout: reuses the always-present base-game document. */ + private static final String RELIABLE_DOCUMENT = "Pages/WarpListPage.ui"; + private static final String RELIABLE_LIST = "#WarpList"; + + /** Designed layout shipped in the plugin asset pack. */ + private static final String CUSTOM_DOCUMENT = "Pages/MmorpgPlayerInfo.ui"; + private static final String CUSTOM_LIST = "#ProfileList"; + + private final PlayerProfile profile; + private final boolean custom; + + public PlayerInfoPage(@Nonnull PlayerRef playerRef, @Nonnull PlayerProfile profile) { + this(playerRef, profile, false); + } + + public PlayerInfoPage(@Nonnull PlayerRef playerRef, @Nonnull PlayerProfile profile, boolean custom) { + super(playerRef, CustomPageLifetime.CanDismiss); + this.profile = profile; + this.custom = custom; + } + + @Override + public void build( + @Nonnull Ref ref, + @Nonnull UICommandBuilder ui, + @Nonnull UIEventBuilder events, + @Nonnull Store store) { + String document = custom ? CUSTOM_DOCUMENT : RELIABLE_DOCUMENT; + String list = custom ? CUSTOM_LIST : RELIABLE_LIST; + + ui.append(document); + ui.clear(list); + + ui.appendInline(list, title("=== Profil MMORPG ===")); + List entries = PlayerInfoView.entries(profile); + for (PlayerInfoView.Entry entry : entries) { + ui.appendInline(list, row(entry.label(), entry.value())); + } + ui.appendInline(list, hint("(Échap pour fermer)")); + } + + @Nonnull + private static String title(@Nonnull String text) { + return "Label { Text: \"" + escape(text) + + "\"; Style: (HorizontalAlignment: Center, RenderBold: true, FontSize: 18);" + + " Anchor: (Bottom: 8); }"; + } + + @Nonnull + private static String row(@Nonnull String label, @Nonnull String value) { + return "Label { Text: \"" + escape(label + " : " + value) + + "\"; Style: (FontSize: 14, Wrap: true); Anchor: (Bottom: 3); }"; + } + + @Nonnull + private static String hint(@Nonnull String text) { + return "Label { Text: \"" + escape(text) + + "\"; Style: (HorizontalAlignment: Center, FontSize: 12, TextColor: #878e9c);" + + " Anchor: (Top: 8); }"; + } + + @Nonnull + private static String escape(@Nonnull String text) { + return text.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/main/java/com/disklexar/mmorpg/ui/PlayerVitalsReader.java b/src/main/java/com/disklexar/mmorpg/ui/PlayerVitalsReader.java new file mode 100644 index 0000000..e522f86 --- /dev/null +++ b/src/main/java/com/disklexar/mmorpg/ui/PlayerVitalsReader.java @@ -0,0 +1,45 @@ +package com.disklexar.mmorpg.ui; + +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.modules.entitystats.EntityStatMap; +import com.hypixel.hytale.server.core.modules.entitystats.EntityStatValue; +import com.hypixel.hytale.server.core.modules.entitystats.asset.DefaultEntityStatTypes; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Reads live health and stamina from the vanilla entity stat map. + */ +public final class PlayerVitalsReader { + + public record Vitals(int current, int max, int staminaCurrent, int staminaMax) { + } + + private PlayerVitalsReader() { + } + + @Nullable + public static Vitals read( + @Nonnull Store store, + @Nonnull Ref entityRef) { + EntityStatMap stats = store.getComponent(entityRef, EntityStatMap.getComponentType()); + if (stats == null) { + return null; + } + + EntityStatValue health = stats.get(DefaultEntityStatTypes.getHealth()); + EntityStatValue stamina = stats.get(DefaultEntityStatTypes.getStamina()); + if (health == null || stamina == null) { + return null; + } + + return new Vitals( + Math.round(health.get()), + Math.round(health.getMax()), + Math.round(stamina.get()), + Math.round(stamina.getMax())); + } +} diff --git a/src/main/resources/Common/UI/Custom/MmorpgAbilityBar.ui b/src/main/resources/Common/UI/Custom/MmorpgAbilityBar.ui new file mode 100644 index 0000000..523ba3a --- /dev/null +++ b/src/main/resources/Common/UI/Custom/MmorpgAbilityBar.ui @@ -0,0 +1,170 @@ +@HealthBarFill = PatchStyle(Color: #e74c3c); +@HealthBarBg = PatchStyle(Color: #2a1515); +@StaminaBarFill = PatchStyle(Color: #2ecc71); +@StaminaBarBg = PatchStyle(Color: #152a1c); +@XpBarFill = PatchStyle(Color: #4a9eff); +@XpBarBg = PatchStyle(Color: #152030); +@VitalsPanelWidth = 260; +@VitalsBarWidth = 244; +@AbilitySlotSize = 68; +@AbilityPanelPad = 3; +@AbilityPanelWidth = @AbilitySlotSize + (@AbilityPanelPad * 2); + +Group { + LayoutMode: Top; + Anchor: (Left: 16, Top: 16, Width: @VitalsPanelWidth); + + Label #HeaderLabel { + Style: (FontSize: 14, RenderBold: true, TextColor: #f5c842); + Text: "Nv.1 — Classe"; + Anchor: (Left: 0, Bottom: 8); + } + + Group #VitalsPanel { + LayoutMode: Top; + Padding: (Full: 8); + Background: #0d0f14(0.92); + Anchor: (Left: 0, Width: @VitalsPanelWidth, Bottom: 8); + + Group #HealthRow { + LayoutMode: Top; + Anchor: (Bottom: 6); + + Label #HealthTitle { + Style: (FontSize: 10, RenderBold: true, TextColor: #ff7b7b); + Text: "Vie"; + Anchor: (Bottom: 2); + } + + ProgressBar #HealthBar { + Anchor: (Width: @VitalsBarWidth, Height: 12); + Bar: @HealthBarFill; + Background: @HealthBarBg; + Value: 1.0; + Direction: End; + } + + Label #HealthValue { + Style: (FontSize: 9, TextColor: #f0f0f0); + Text: "0 / 0"; + Anchor: (Top: 2); + } + } + + Group #StaminaRow { + LayoutMode: Top; + Anchor: (Bottom: 6); + + Label #StaminaTitle { + Style: (FontSize: 10, RenderBold: true, TextColor: #7bdc9a); + Text: "Endurance"; + Anchor: (Bottom: 2); + } + + ProgressBar #StaminaBar { + Anchor: (Width: @VitalsBarWidth, Height: 12); + Bar: @StaminaBarFill; + Background: @StaminaBarBg; + Value: 1.0; + Direction: End; + } + + Label #StaminaValue { + Style: (FontSize: 9, TextColor: #f0f0f0); + Text: "0 / 0"; + Anchor: (Top: 2); + } + } + + Group #XpRow { + LayoutMode: Top; + + Label #XpTitle { + Style: (FontSize: 10, RenderBold: true, TextColor: #8eb6ff); + Text: "Expérience"; + Anchor: (Bottom: 2); + } + + ProgressBar #XpBar { + Anchor: (Width: @VitalsBarWidth, Height: 12); + Bar: @XpBarFill; + Background: @XpBarBg; + Value: 0.0; + Direction: End; + } + + Label #XpValue { + Style: (FontSize: 9, TextColor: #f0f0f0); + Text: "0 / 0"; + Anchor: (Top: 2); + } + } + } + + Label #BarTitle { + Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842); + Text: "CAPACITÉS"; + Anchor: (Left: 0, Bottom: 4); + } + + Group #AbilityBarPanel { + LayoutMode: Top; + Padding: (Full: @AbilityPanelPad); + Background: #0d0f14(0.92); + Anchor: (Left: 0, Width: @AbilityPanelWidth); + + Group #AbilitySlot1 { + Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize, Bottom: 5); + Background: #2a3140; + LayoutMode: Top; + Padding: (Full: 4); + + Label #Slot1Key { + Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842); + Text: "[1]"; + Anchor: (Bottom: 2); + } + + Label #Slot1Name { + Style: (FontSize: 10, TextColor: #ffffff, Wrap: true); + Text: "Capacité 1"; + } + } + + Group #AbilitySlot2 { + Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize, Bottom: 5); + Background: #2a3140; + LayoutMode: Top; + Padding: (Full: 4); + + Label #Slot2Key { + Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842); + Text: "[2]"; + Anchor: (Bottom: 2); + } + + Label #Slot2Name { + Style: (FontSize: 10, TextColor: #ffffff, Wrap: true); + Text: "Capacité 2"; + } + } + + Group #AbilitySlot3 { + Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize); + Background: #2a3140; + LayoutMode: Top; + Padding: (Full: 4); + + Label #Slot3Key { + Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842); + Text: "[3]"; + Anchor: (Bottom: 2); + } + + Label #Slot3Name { + Style: (FontSize: 10, TextColor: #ffffff, Wrap: true); + Text: "Capacité 3"; + } + } + } +} diff --git a/src/main/resources/Common/UI/Custom/Pages/MmorpgPlayerInfo.ui b/src/main/resources/Common/UI/Custom/Pages/MmorpgPlayerInfo.ui new file mode 100644 index 0000000..8e60447 --- /dev/null +++ b/src/main/resources/Common/UI/Custom/Pages/MmorpgPlayerInfo.ui @@ -0,0 +1,29 @@ +$C = "../Common.ui"; + +$C.@PageOverlay {} + +$C.@Container { + Anchor: (Width: 600, Height: 700); + + #Title { + Group { + $C.@Title { + @Text = %server.customUI.warpListPage.warps; + } + + $C.@HeaderSearch {} + } + } + + #Content { + LayoutMode: Left; + + Group #ProfileList { + FlexWeight: 1; + LayoutMode: TopScrolling; + ScrollbarStyle: $C.@DefaultScrollbarStyle; + } + } +} + +$C.@BackButton {} diff --git a/src/main/resources/Server/Item/Items/Weapon_Battleaxe_Mithril.json b/src/main/resources/Server/Item/Items/Weapon_Battleaxe_Mithril.json new file mode 100644 index 0000000..9cb88e5 --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Battleaxe_Mithril.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Battleaxe_Mithril", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Crossbow_Ancient_Steel.json b/src/main/resources/Server/Item/Items/Weapon_Crossbow_Ancient_Steel.json new file mode 100644 index 0000000..846cad5 --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Crossbow_Ancient_Steel.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Crossbow_Ancient_Steel", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Daggers_Mithril.json b/src/main/resources/Server/Item/Items/Weapon_Daggers_Mithril.json new file mode 100644 index 0000000..a053fed --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Daggers_Mithril.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Daggers_Mithril", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Longsword_Mithril.json b/src/main/resources/Server/Item/Items/Weapon_Longsword_Mithril.json new file mode 100644 index 0000000..2fe9e6a --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Longsword_Mithril.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Longsword_Mithril", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Shortbow_Cobalt.json b/src/main/resources/Server/Item/Items/Weapon_Shortbow_Cobalt.json new file mode 100644 index 0000000..1021f6c --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Shortbow_Cobalt.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Shortbow_Cobalt", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/Server/Item/Items/Weapon_Staff_Mithril.json b/src/main/resources/Server/Item/Items/Weapon_Staff_Mithril.json new file mode 100644 index 0000000..42f0b42 --- /dev/null +++ b/src/main/resources/Server/Item/Items/Weapon_Staff_Mithril.json @@ -0,0 +1,20 @@ +{ + "Id": "Weapon_Staff_Mithril", + "Interactions": { + "Ability1": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 1 } + ] + }, + "Ability2": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 2 } + ] + }, + "Ability3": { + "Interactions": [ + { "Type": "mmorpg_cast_ability", "Slot": 3 } + ] + } + } +} diff --git a/src/main/resources/config.json b/src/main/resources/config.json new file mode 100644 index 0000000..784fb84 --- /dev/null +++ b/src/main/resources/config.json @@ -0,0 +1,15 @@ +{ + "Debug": false, + "DefaultLevel": 1, + "MaxPlayers": 500, + "BaseXpPerLevel": 100, + "KillExperience": 10, + "Database": { + "FileName": "mmorpg.db" + }, + "Features": { + "Economy": true, + "Quests": false, + "Guilds": false + } +} diff --git a/src/main/resources/db/migrations/001_init.sql b/src/main/resources/db/migrations/001_init.sql new file mode 100644 index 0000000..39b6d8d --- /dev/null +++ b/src/main/resources/db/migrations/001_init.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS player_profiles ( + uuid TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + level INTEGER NOT NULL DEFAULT 1, + experience INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/src/main/resources/db/migrations/002_rpg.sql b/src/main/resources/db/migrations/002_rpg.sql new file mode 100644 index 0000000..fe00274 --- /dev/null +++ b/src/main/resources/db/migrations/002_rpg.sql @@ -0,0 +1,63 @@ +-- MMORPG progression schema: classes, powers, jobs, races, groups. +-- New columns on player_profiles. SQLite ignores duplicate ADD COLUMN guarded by +-- the migration runner (each migration is applied at most once via schema_migrations). + +ALTER TABLE player_profiles ADD COLUMN class_id TEXT; +ALTER TABLE player_profiles ADD COLUMN powers TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE player_profiles ADD COLUMN jobs TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE player_profiles ADD COLUMN guild_id TEXT; +ALTER TABLE player_profiles ADD COLUMN group_id TEXT; +ALTER TABLE player_profiles ADD COLUMN is_connected INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN total_time_play INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN last_date_connected INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN date_creation INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN money INTEGER NOT NULL DEFAULT 0; +ALTER TABLE player_profiles ADD COLUMN race_id TEXT NOT NULL DEFAULT 'human'; + +-- Backfill creation date for profiles that predate this migration. +UPDATE player_profiles SET date_creation = created_at WHERE date_creation = 0; + +-- Reference tables (definitions persisted for inspection / future editing). +CREATE TABLE IF NOT EXISTS classes ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + weapons TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS powers ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + passive INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS races ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + xp_multiplier REAL NOT NULL DEFAULT 1.0 +); + +-- Groups (parties): shared XP between members. +CREATE TABLE IF NOT EXISTS groups ( + id TEXT PRIMARY KEY, + owner_uuid TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS group_members ( + group_id TEXT NOT NULL, + player_uuid TEXT NOT NULL, + joined_at INTEGER NOT NULL, + PRIMARY KEY (group_id, player_uuid), + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_group_members_player ON group_members(player_uuid); diff --git a/src/main/resources/manifest.json b/src/main/resources/manifest.json new file mode 100644 index 0000000..cbe919d --- /dev/null +++ b/src/main/resources/manifest.json @@ -0,0 +1,18 @@ +{ + "Group": "com.disklexar", + "Name": "MMORPG", + "Version": "0.2.6", + "Description": "Serveur MMORPG Hytale — progression, persistance et systèmes sociaux", + "Authors": [ + { + "Name": "Disklexar" + } + ], + "Website": "https://github.com/disklexar/hytale-mmorpg", + "DisabledByDefault": false, + "IncludesAssetPack": true, + "Dependencies": {}, + "OptionalDependencies": {}, + "ServerVersion": "*", + "Main": "com.disklexar.mmorpg.MmorpgPlugin" +} \ No newline at end of file diff --git a/start-server.sh b/start-server.sh new file mode 100755 index 0000000..35d2e77 --- /dev/null +++ b/start-server.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Compile le plugin, configure le serveur de dev, puis lance Hytale. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +JAVA_HOME="/usr/lib/jvm/java-25-openjdk" +export JAVA_HOME +export PATH="$JAVA_HOME/bin:$PATH" + +GRADLE_PROPS="$ROOT/gradle.properties" +HYTALE_PROPS="$ROOT/hytale.properties" +GRADLE_ARGS=() + +read_property() { + local file="$1" + local key="$2" + if [[ -f "$file" ]]; then + grep -E "^${key}=" "$file" 2>/dev/null | tail -1 | cut -d= -f2- | tr -d '\r' || true + fi +} + +find_assets_zip() { + local home="$1" + [[ -z "$home" || ! -d "$home" ]] && return 1 + + local candidates=( + "$home/install/release/package/game/latest/Assets.zip" + "$home/Assets.zip" + ) + local path + for path in "${candidates[@]}"; do + if [[ -f "$path" && -s "$path" ]]; then + echo "$path" + return 0 + fi + done + + find "$home" -name "Assets.zip" -size +1M 2>/dev/null | head -1 +} + +detect_hytale_home() { + local candidates=( + "${HYTALE_HOME:-}" + "$(read_property "$HYTALE_PROPS" "hytale.home_path")" + "$(read_property "$GRADLE_PROPS" "hytale.home_path")" + "$HOME/.var/app/com.hypixel.HytaleLauncher/data/Hytale" + "$HOME/.local/share/Hytale" + "$HOME/AppData/Roaming/Hytale" + ) + local candidate assets + for candidate in "${candidates[@]}"; do + [[ -z "$candidate" ]] && continue + assets="$(find_assets_zip "$candidate" || true)" + if [[ -n "$assets" ]]; then + echo "$candidate" + return 0 + fi + done + return 1 +} + +ensure_hytale_home_path() { + local home + home="$(detect_hytale_home || true)" + + if [[ -z "$home" ]]; then + echo "Erreur: Assets.zip introuvable (fichier vide ou installation absente)." >&2 + echo "" >&2 + echo "Le serveur a besoin des assets du jeu Hytale. Configurez le chemin :" >&2 + echo "" >&2 + echo " cp hytale.properties.example hytale.properties" >&2 + echo " # puis éditez hytale.home_path=/chemin/vers/Hytale" >&2 + echo "" >&2 + echo " Ou : export HYTALE_HOME=/chemin/vers/Hytale" >&2 + echo " Ou : ajoutez hytale.home_path=... dans gradle.properties" >&2 + echo "" >&2 + echo "Le dossier doit contenir install/.../Assets.zip (via le launcher Hytale)." >&2 + exit 1 + fi + + local assets + assets="$(find_assets_zip "$home")" + echo "Hytale: $home" + echo "Assets: $assets" + + GRADLE_ARGS+=("-Phytale.home_path=$home") + + if [[ ! -f "$HYTALE_PROPS" ]] || [[ "$(read_property "$HYTALE_PROPS" "hytale.home_path")" != "$home" ]]; then + echo "hytale.home_path=$home" > "$HYTALE_PROPS" + echo "Enregistré dans hytale.properties" + fi +} + +reset_devserver_if_needed() { + if [[ "${HYTALE_RESET_DEVSERVER:-}" == "1" && -d "$ROOT/devserver" ]]; then + echo "HYTALE_RESET_DEVSERVER=1 : suppression de devserver/" + rm -rf "$ROOT/devserver" + fi +} + +run_gradle() { + "$ROOT/gradlew" --stop 2>/dev/null || true + "$ROOT/gradlew" "${GRADLE_ARGS[@]}" "$@" +} + +if [[ ! -d "$JAVA_HOME" ]]; then + echo "Erreur: JDK introuvable: $JAVA_HOME" >&2 + exit 1 +fi + +echo "JAVA_HOME=$JAVA_HOME ($("$JAVA_HOME/bin/java" -version 2>&1 | head -1))" +ensure_hytale_home_path +reset_devserver_if_needed + +echo "Configuration du serveur de dev..." +run_gradle setupServer + +echo "Compilation..." +run_gradle build + +echo "Démarrage du serveur (logs: devserver/logs/)..." +exec "$ROOT/gradlew" "${GRADLE_ARGS[@]}" runServer