first
Build / build (push) Has been cancelled

This commit is contained in:
gpatruno
2026-06-05 15:00:58 +02:00
commit 3710a28ae0
100 changed files with 7744 additions and 0 deletions
+25
View File
@@ -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
+29
View File
@@ -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
+41
View File
@@ -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/<nom>` — nouvelles fonctionnalités
- `fix/<nom>` — 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.
+21
View File
@@ -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.
+153
View File
@@ -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 <choisir\|quitter\|info> [classe]` | Gérer votre classe |
| `/mmorpg power <recevoir\|retirer\|info> [pouvoir]` | Gérer vos pouvoirs |
| `/mmorpg job <rejoindre\|quitter\|info> [metier]` | Gérer vos métiers |
| `/mmorpg group <inviter\|accepter\|quitter\|kick\|info> [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 <classe>` 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).
+54
View File
@@ -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/<ItemId>.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.
+29
View File
@@ -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);
}
}
+37
View File
@@ -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)
}
+72
View File
@@ -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.*`
+66
View File
@@ -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
+408
View File
@@ -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 | 10 (10) | Potions, nourriture, gadgets MMORPG |
| Capacités classe | Ability 13 | 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 13 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 1n) | 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** | 10241279 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é 13 | 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)
+53
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk
+3
View File
@@ -0,0 +1,3 @@
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.java.home=/usr/lib/jvm/java-25-openjdk
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
Vendored
+93
View File
@@ -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
+10
View File
@@ -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=
+16
View File
@@ -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"
}
}
@@ -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");
}
}
@@ -0,0 +1,4 @@
/**
* Public API interfaces for cross-module integration (future extraction to separate plugins).
*/
package com.disklexar.mmorpg.api;
@@ -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;
}
}
@@ -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<Class<?>, Object> services = new HashMap<>();
private final List<Service> lifecycleServices = new ArrayList<>();
public <T> void register(@Nonnull Class<T> type, @Nonnull T instance) {
services.put(type, instance);
if (instance instanceof Service service) {
lifecycleServices.add(service);
}
}
@Nullable
public <T> T get(@Nonnull Class<T> type) {
return type.cast(services.get(type));
}
@Nonnull
public <T> T require(@Nonnull Class<T> 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();
}
}
}
@@ -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<UUID, Map<String, Long>> 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<String, Long> 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<EntityStore> aimedTarget) {
UUID caster = playerRef.getUuid();
Store<EntityStore> store = world.getEntityStore().getStore();
Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nullable Ref<EntityStore> aimedTarget) {
world.execute(() -> {
Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> 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<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
return new Vector3d(position(store, ref)).add(0, 1.5, 0);
}
@Nonnull
private Vector3d position(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> target,
@Nonnull Vector3d fallback) {
TransformComponent transform = store.getComponent(target, TransformComponent.getComponentType());
return transform != null ? transform.getPosition() : fallback;
}
private UUID entityUuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
return component == null ? null : component.getUuid();
}
public enum CastResult {
OK, ON_COOLDOWN, WRONG_WEAPON, UNKNOWN
}
}
@@ -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<EntityStore> 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) {
}
}
@@ -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:
* <ul>
* <li>Archer "Tir rapide": a flat outgoing-damage bonus for a window (server-side proxy for
* attack speed, which is otherwise client-authoritative),</li>
* <li>Archer "Tir puissant": a one-shot +200% multiplier on the next basic attack,</li>
* <li>Assassin "Lame Empoisonnée": a window during which every hit applies a stacking poison,</li>
* <li>Assassin "Camouflage": bookkeeping of the current invisibility window.</li>
* </ul>
*/
public final class CombatBuffService {
private final Map<UUID, Long> rapidFireUntil = new ConcurrentHashMap<>();
private final Set<UUID> powerShotCharged = ConcurrentHashMap.newKeySet();
private final Map<UUID, Long> poisonBladeUntil = new ConcurrentHashMap<>();
private final Map<UUID, Long> 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);
}
}
@@ -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.
* <p>
* 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<EntityStore> 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<EntityStore> 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 <em>player</em> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull String soundEventId,
@Nonnull Vector3d position) {
sound(store, soundEventId, position, 1.0f, 1.0f);
}
public static void sound(
@Nonnull Store<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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.
}
}
}
@@ -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:
* <ul>
* <li>last time the player took damage (drives the "Résilient" power and out-of-combat detection),</li>
* <li>temporary resistance debuffs applied by abilities (e.g. the Knight war cry).</li>
* </ul>
*/
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<UUID, Long> lastDamageTakenAt = new ConcurrentHashMap<>();
private final Map<UUID, Long> lastCombatAt = new ConcurrentHashMap<>();
private final Map<UUID, Long> 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);
}
}
@@ -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).
* <p>
* 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());
}
};
}
}
@@ -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.
* <p>
* 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<EntityStore> store = player.world().getEntityStore().getStore();
Ref<EntityStore> 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());
}
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> caster,
@Nonnull Ref<EntityStore> 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);
}
}
}
@@ -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<UUID, Long> 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;
}
}
@@ -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<EntityStore, BreakBlockEvent> {
private static final Query<EntityStore> 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<EntityStore> getQuery() {
return QUERY;
}
@Override
public void handle(
int index,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull BreakBlockEvent event) {
Ref<EntityStore> 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);
}
}
}
@@ -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:
* <ul>
* <li>victims with an active resistance debuff (Knight war cry) take +20% damage,</li>
* <li>attackers with the "Résilient" power deal +10% damage for 5s after being hit.</li>
* </ul>
*/
public final class MmorpgDamageSystem extends DamageEventSystem {
private static final Query<EntityStore> 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<EntityStore> getQuery() {
return QUERY;
}
@Override
public SystemGroup<EntityStore> getGroup() {
return DamageModule.get().getInspectDamageGroup();
}
@Override
public void handle(
int index,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull Damage damage) {
Ref<EntityStore> 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<EntityStore> 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<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
return component == null ? null : component.getUuid();
}
}
@@ -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<EntityStore> 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<EntityStore> getQuery() {
return QUERY;
}
@Override
public void onComponentAdded(
@Nonnull Ref<EntityStore> deadRef,
@Nonnull DeathComponent death,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> 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<EntityStore> 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()));
}
}
@@ -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<Integer> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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 <classe>."));
return;
}
controller.tryCast(playerRef, world, slot, null);
}
}
@@ -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 <choisir|quitter|info>}
*/
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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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();
}
}
@@ -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> T service(@Nonnull MmorpgPlugin plugin, @Nonnull Class<T> type) {
Bootstrap bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
return null;
}
return bootstrap.getRegistry().get(type);
}
@Nullable
static UUID uuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
return component == null ? null : component.getUuid();
}
@Nullable
static PlayerProfile profile(
@Nonnull MmorpgPlugin plugin,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
UUID uuid = uuid(store, ref);
if (uuid == null) {
return null;
}
PlayerSessionService sessions = service(plugin, PlayerSessionService.class);
return sessions == null ? null : sessions.getSession(uuid);
}
}
@@ -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 <inviter|accepter|quitter|kick|info>}
*/
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<PlayerRef> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<PlayerRef> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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 + "%"));
}
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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 <id>"));
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)."));
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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)));
}
}
@@ -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 <rejoindre|quitter|info>}
*/
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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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();
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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)));
}
});
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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)));
}
});
}
}
@@ -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));
}
}
@@ -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<Void> 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 <choisir|quitter|info> [classe]
/mmorpg power <recevoir|retirer|info> [pouvoir]
/mmorpg job <rejoindre|quitter|info> [metier]
/mmorpg group <inviter|accepter|quitter|kick|info> [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);
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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())));
}
}
@@ -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 <recevoir|retirer|info>}
*/
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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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();
}
}
@@ -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;
}
}
}
@@ -0,0 +1,11 @@
package com.disklexar.mmorpg.core.service;
/**
* Lifecycle contract for MMORPG services.
*/
public interface Service {
void start();
void shutdown();
}
@@ -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;
}
}
@@ -0,0 +1,7 @@
/**
* Economy system (currency, shops, auctions).
* <p>
* Disabled by default via {@code Features.Economy} in config.json.
* Planned: wallets, transactions, NPC vendors.
*/
package com.disklexar.mmorpg.economy;
@@ -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<SyncInteractionChain> 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<EntityStore> entityRef = playerRef.getReference();
if (entityRef == null || !entityRef.isValid()) {
return;
}
Store<EntityStore> 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));
}
}
@@ -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<EntityStore> entityRef = event.getPlayerRef();
Bootstrap bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
return;
}
Store<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull Player player) {
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
if (playerRef != null) {
return playerRef.getUsername();
}
return "Joueur";
}
}
@@ -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<MmorpgCastAbilityInteraction> 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<EntityStore> entityRef = interactionContext.getEntity();
if (entityRef == null || !entityRef.isValid()) {
interactionContext.getState().state = InteractionState.Failed;
return;
}
Store<EntityStore> 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<EntityStore> target = interactionContext.getTargetEntity();
ClassAbilityController controller = plugin.getClassAbilityController();
if (!controller.tryCast(playerRef, world, abilitySlot, target)) {
interactionContext.getState().state = InteractionState.Failed;
}
}
}
@@ -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;
}
}
@@ -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<Group> loadAll() throws SQLException {
List<Group> 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();
}
}
@@ -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<List<String>>() {
}.getType();
private final DatabaseManager databaseManager;
public PlayerProfileRepository(@Nonnull DatabaseManager databaseManager) {
this.databaseManager = databaseManager;
}
@Nonnull
public Optional<PlayerProfile> 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<String> parseList(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
List<String> parsed = GSON.fromJson(json, STRING_LIST);
return parsed != null ? parsed : List.of();
}
@Nonnull
private Connection connection() {
return databaseManager.getConnection();
}
}
@@ -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.
* <p>
* 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<String> 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);
}
}
}
@@ -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<EntityStore> entityRef,
@Nonnull World world) {
}
@@ -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<UUID, OnlinePlayer> 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<OnlinePlayer> all() {
return online.values();
}
}
@@ -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<Entry> entries(@Nonnull PlayerProfile profile) {
List<Entry> 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));
}
}
@@ -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<UUID, PlayerProfile> sessions = new ConcurrentHashMap<>();
private final Map<UUID, Long> 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<PlayerProfile> 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());
}
}
}
@@ -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.
* <p>
* 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<String> powers = new LinkedHashSet<>();
private final Set<String> 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<String> getPowers() {
return powers;
}
@Nonnull
public Set<String> 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();
}
}
@@ -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());
}
}
}
@@ -0,0 +1,7 @@
/**
* Quest system (objectives, rewards, quest chains).
* <p>
* Disabled by default via {@code Features.Quests} in config.json.
* Planned: quest definitions, progress tracking, daily quests.
*/
package com.disklexar.mmorpg.quest;
@@ -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) {
}
@@ -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<String, PlayerClass> 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<PlayerClass> all() {
return List.copyOf(BY_ID.values());
}
}
@@ -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;
}
}
@@ -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<String> weapons,
@Nonnull List<AbilityDefinition> abilities) {
public boolean usesWeapon(@Nonnull String weaponId) {
for (String weapon : weapons) {
if (weapon.equalsIgnoreCase(weaponId)) {
return true;
}
}
return false;
}
}
@@ -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<UUID> members = ConcurrentHashMap.newKeySet();
private final Set<UUID> 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<UUID> getMembers() {
return members;
}
@Nonnull
public Set<UUID> getPendingInvites() {
return pendingInvites;
}
public boolean isOwner(@Nonnull UUID uuid) {
return owner.equals(uuid);
}
@Nonnull
public Set<UUID> membersExcept(@Nonnull UUID uuid) {
Set<UUID> others = new LinkedHashSet<>(members);
others.remove(uuid);
return others;
}
}
@@ -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.
* <p>
* 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<UUID, Group> groupsById = new ConcurrentHashMap<>();
private final Map<UUID, UUID> 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
}
}
@@ -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<String, JobDefinition> 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<JobDefinition> all() {
return List.copyOf(BY_ID.values());
}
}
@@ -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) {
}
@@ -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;
}
}
@@ -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<String, PowerDefinition> 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<PowerDefinition> all() {
return List.copyOf(BY_ID.values());
}
}
@@ -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) {
}
@@ -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;
}
}
@@ -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<String, RaceDefinition> 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<RaceDefinition> all() {
return List.copyOf(BY_ID.values());
}
}
@@ -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) {
}
@@ -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;
}
}
@@ -0,0 +1,7 @@
/**
* Social systems (parties, guilds, friends).
* <p>
* Disabled by default via {@code Features.Guilds} in config.json.
* Planned: guild creation, ranks, shared storage, party instances.
*/
package com.disklexar.mmorpg.social;
@@ -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("\"", "\\\"");
}
}
@@ -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<UUID, AbilityBarHud> 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<EntityStore> store = world.getEntityStore().getStore();
Ref<EntityStore> 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<EntityStore> resolveEntityRef(@Nonnull World world, @Nonnull UUID playerUuid) {
for (PlayerRef playerRef : world.getPlayerRefs()) {
if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) {
continue;
}
Ref<EntityStore> 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<EntityStore> store = world.getEntityStore().getStore();
for (PlayerRef playerRef : world.getPlayerRefs()) {
if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) {
continue;
}
Ref<EntityStore> entityRef = playerRef.getReference();
if (entityRef == null || !entityRef.isValid()) {
continue;
}
Player player = store.getComponent(entityRef, Player.getComponentType());
if (player != null) {
return player;
}
}
return null;
}
}
@@ -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.
* <p>
* 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<EntityStore> ref,
@Nonnull UICommandBuilder ui,
@Nonnull UIEventBuilder events,
@Nonnull Store<EntityStore> 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<PlayerInfoView.Entry> 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("\"", "\\\"");
}
}
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> 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()));
}
}
@@ -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";
}
}
}
}
@@ -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 {}
@@ -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 }
]
}
}
}
@@ -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 }
]
}
}
}
@@ -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 }
]
}
}
}
@@ -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 }
]
}
}
}
@@ -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 }
]
}
}
}
@@ -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 }
]
}
}
}
+15
View File
@@ -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
}
}
@@ -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
);
@@ -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);
+18
View File
@@ -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"
}
+124
View File
@@ -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