commit 8c69a0dc9aa5558ad053d80dd536dafdbaf3d7f3 Author: SarTron-NorthBlue Date: Sun Jul 12 13:12:32 2026 +0400 VoteNetwork: plugin de vote reseau Velocity <-> Spigot Reception directe en jeu (plugin messaging) ou stockage des votes en attente (MySQL ou YAML configurable), /claim, VoteParty local par serveur, mode maintenance, API publique pour scoreboard/GUI. Co-Authored-By: Claude Sonnet 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..467028a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +dependency-reduced-pom.xml +.idea/ +*.iml +.vscode/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e31ff55 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# VoteNetwork + +Systeme de vote reseau en deux modules, pour une architecture **proxy Velocity + serveurs Spigot/Paper** (modes A, B, C, ...). + +- `velocity/` — module proxy. Recoit `votenetwork vote ` (console uniquement) et route le vote en direct ou le met en attente. +- `spigot/` — module serveur. Recoit le signal en direct via Plugin Messaging, gere `/claim`, le VoteParty local et le mode maintenance. + +Compatible **Java 17**, compile contre l'API Spigot **1.18.2** (additive, donc compatible en avant vers 1.19 → 1.21.x et versions futures). + +--- + +## Sommaire + +- [Installation](#installation) +- [Build depuis les sources](#build-depuis-les-sources) +- [Configuration](#configuration) +- [Stockage : MySQL ou YAML](#stockage--mysql-ou-yaml) +- [Fonctionnement du vote](#fonctionnement-du-vote) +- [Commandes & permissions](#commandes--permissions) +- [VoteParty](#voteparty) +- [API publique (scoreboard / GUI)](#api-publique-scoreboard--gui) +- [Structure de la base de données](#structure-de-la-base-de-données) + +--- + +## Installation + +1. `velocity/target/VoteNetwork-Velocity.jar` → `plugins/` du proxy Velocity. +2. `spigot/target/VoteNetwork-Spigot.jar` → `plugins/` de **chaque** serveur Spigot/Paper (A, B, C, ...). +3. Démarrer une fois pour générer les fichiers de config, puis les éditer : + - Proxy : `plugins/votenetwork/config.properties` + - Serveur : `plugins/votenetwork/config.yml` +4. Redémarrer. + +## Build depuis les sources + +``` +mvn clean package +``` + +Jars produits dans `velocity/target/` et `spigot/target/`. + +## Configuration + +Chaque composant a son propre fichier, tous deux dans un dossier **`votenetwork`** : + +| Composant | Fichier | Dossier | +|---|---|---| +| Velocity | `config.properties` | `plugins/votenetwork/` (racine du proxy) | +| Spigot | `config.yml` | `plugins/votenetwork/` (sur chaque serveur) | + +Le `config.yml` Spigot regroupe tout ce qui est personnalisable : + +```yaml +storage: + type: mysql # mysql ou yaml + +mysql: + host: 127.0.0.1 + port: 3306 + database: votenetwork + user: root + password: changeme + pool-size: 5 + +rewards: + vote-commands: # executees pour CHAQUE vote (direct ou via /claim, une fois par vote) + - "goldencrates give %player% vote_key 1" + +voteparty: + votes-requis: 100 # déclenche le VoteParty à ce cumul (par serveur) + commands: # executees UNE fois quand le VoteParty se déclenche + - "goldencrates broadcast_give vote_key 1" + broadcast: + enabled: true + progress-message: "&b[Vote] &f%current%&7/&f%required% &fvotes avant le prochain VoteParty !" + triggered-message: "&a[Vote] &fVoteParty declenche ! Profitez des recompenses !" + +messages: + direct-vote: "&a[Vote] &fMerci pour ton vote !" + claim-success: "&a[Vote] &fVous avez recupere &e%amount% &fvote(s) en attente !" + claim-empty: "&c[Vote] &fVous n'avez aucun vote en attente." + maintenance: "&c[Vote] &fLe systeme de vote est actuellement en maintenance." + maintenance-enabled: "&c[Vote] &fMode maintenance active." + maintenance-disabled: "&a[Vote] &fMode maintenance desactive." + no-permission: "&cVous n'avez pas la permission d'utiliser cette commande." +``` + +- `%player%` dans `rewards.vote-commands` est remplacé par le pseudo du joueur. +- `%current%` / `%required%` dans les messages de progression VoteParty. +- `%amount%` dans le message de succès `/claim`. +- Codes couleur `&` supportés partout. + +## Stockage : MySQL ou YAML + +Chaque composant choisit indépendamment son stockage via `storage.type` : + +- **`mysql`** (recommandé pour un vrai réseau) : partagé entre le proxy et tous les serveurs. La table `nb_votes_attente` est créée automatiquement au démarrage si absente. +- **`yaml`** : fichier local `pending-votes.yml` dans le dossier du plugin. Pratique en solo/test, **mais pas synchronisé automatiquement** entre le fichier du proxy et celui d'un serveur — chacun a sa propre copie locale. N'utilisez ce mode en réseau multi-machines que si les dossiers de plugins sont partagés sur le même disque (montage réseau, symlink). Sinon, un vote stocké côté proxy en YAML ne sera jamais vu par `/claim` sur un serveur Spigot séparé. + +Testez la connexion/l'accès au stockage configuré côté Spigot avec : + +``` +/vote testdb +``` + +## Fonctionnement du vote + +1. Un site de vote appelle la console du proxy : `votenetwork vote `. +2. **Joueur connecté** : le proxy détecte son serveur actuel et envoie un paquet sur le canal `votenetwork:vote`. Le serveur Spigot donne la récompense immédiatement et incrémente son VoteParty local de +1. **Rien n'est écrit en base.** +3. **Joueur déconnecté** : le proxy résout son UUID (API Mojang, avec repli si indisponible) et incrémente son compteur de votes en attente (MySQL ou YAML selon la config), de façon asynchrone. +4. Le joueur tape `/claim` sur un serveur Spigot : lecture asynchrone du compteur en attente, distribution des récompenses × N, VoteParty local +N, remise à 0. +5. `/vote stop` bloque `/claim` (mode maintenance, message personnalisable) ; `/vote start` le réactive. + +## Commandes & permissions + +| Commande | Où | Qui | Description | +|---|---|---|---| +| `votenetwork vote ` | Velocity | Console uniquement | Enregistre un vote pour le joueur | +| `/claim` | Spigot | Joueurs | Récupère les votes en attente | +| `/vote stop\|start` | Spigot | `votenetwork.admin` (op par défaut) | Bascule le mode maintenance | +| `/vote testdb` | Spigot | `votenetwork.admin` (op par défaut) | Teste le stockage configuré (MySQL ou YAML) | + +## VoteParty + +- Compteur indépendant **par serveur**, persistant localement (`plugins/votenetwork/voteparty.yml`), survit aux redémarrages. +- `voteparty.votes-requis` définit le seuil de déclenchement. +- `voteparty.commands` s'exécutent une fois le seuil atteint, puis le compteur repart à 0 (avec report de l'excédent si plusieurs votes arrivent d'un coup, ex. via `/claim` de N votes). +- Messages de progression et de déclenchement personnalisables (`voteparty.broadcast`). + +## API publique (scoreboard / GUI) + +Un autre plugin sur le même serveur Spigot peut lire l'état du vote via `fr.northblue.vote.spigot.api.NorthBlueVoteAPI` : + +```java +NorthBlueVoteAPI api = NorthBlueVoteAPI.get(); +// ou : Bukkit.getServicesManager().getRegistration(NorthBlueVoteAPI.class).getProvider(); + +int current = api.getVotePartyCurrentVotes(); +int required = api.getVotePartyRequiredVotes(); +boolean maintenance = api.isMaintenanceEnabled(); + +api.getPendingVotes(player.getUniqueId(), pending -> { + // callback rappelé sur le thread principal — safe pour un scoreboard/GUI + scoreboardLine.setText("Votes en attente: " + pending); +}); +``` + +- `getVotePartyCurrentVotes()` / `getVotePartyRequiredVotes()` : lecture directe en mémoire, thread principal uniquement. +- `getPendingVotes(...)` : lecture asynchrone du stockage configuré (MySQL ou YAML), ne consomme pas les votes contrairement à `/claim`. + +## Structure de la base de données + +Créée automatiquement si absente (mode `mysql`) : + +```sql +CREATE TABLE IF NOT EXISTS nb_votes_attente ( + player_uuid VARCHAR(36) NOT NULL PRIMARY KEY, + player_name VARCHAR(16) NOT NULL, + nombre_votes INT DEFAULT 0 +); +``` + +--- + +## Notes techniques + +- HikariCP + MySQL Connector/J sont shadés et relocalisés dans les deux jars (rien à installer manuellement sur le serveur). Le driver JDBC est chargé explicitement par son nom de classe relocalisé pour rester fiable après le shading. +- Toutes les requêtes SQL et I/O fichier sont asynchrones — aucun accès réseau ou disque ne bloque le thread principal du serveur ni celui du proxy. +- Si la connexion MySQL échoue au démarrage, le plugin reste actif (pas de crash) : corrigez `config.yml`/`config.properties` puis utilisez `/vote testdb`, ou redémarrez. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9283de4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + fr.northblue + northblue-vote-parent + 1.0.0 + pom + + + velocity + spigot + + + + 17 + 17 + UTF-8 + 5.1.0 + 8.0.33 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + sonatype + https://oss.sonatype.org/content/groups/public/ + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + + + diff --git a/spigot/pom.xml b/spigot/pom.xml new file mode 100644 index 0000000..0821353 --- /dev/null +++ b/spigot/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + + fr.northblue + northblue-vote-parent + 1.0.0 + + + northblue-vote-spigot + jar + + + + + org.spigotmc + spigot-api + 1.18.2-R0.1-SNAPSHOT + provided + + + com.zaxxer + HikariCP + ${hikaricp.version} + + + com.mysql + mysql-connector-j + ${mysql.version} + + + + + + spigotmc-repo + https://hub.spigotmc.org/nexus/content/repositories/snapshots/ + + + sonatype + https://oss.sonatype.org/content/groups/public/ + + + + + VoteNetwork-Spigot + + + src/main/resources + true + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + + + com.zaxxer.hikari + fr.northblue.vote.libs.hikari + + + com.mysql + fr.northblue.vote.libs.mysql + + + + + + + + + + + + diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/MaintenanceState.java b/spigot/src/main/java/fr/northblue/vote/spigot/MaintenanceState.java new file mode 100644 index 0000000..0ba61b9 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/MaintenanceState.java @@ -0,0 +1,16 @@ +package fr.northblue.vote.spigot; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class MaintenanceState { + + private final AtomicBoolean stopped = new AtomicBoolean(false); + + public boolean isStopped() { + return stopped.get(); + } + + public void setStopped(boolean value) { + stopped.set(value); + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/NorthBlueVoteSpigot.java b/spigot/src/main/java/fr/northblue/vote/spigot/NorthBlueVoteSpigot.java new file mode 100644 index 0000000..93c60e8 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/NorthBlueVoteSpigot.java @@ -0,0 +1,86 @@ +package fr.northblue.vote.spigot; + +import fr.northblue.vote.spigot.api.NorthBlueVoteAPI; +import fr.northblue.vote.spigot.api.NorthBlueVoteAPIHolder; +import fr.northblue.vote.spigot.api.NorthBlueVoteAPIImpl; +import fr.northblue.vote.spigot.command.ClaimCommand; +import fr.northblue.vote.spigot.command.VoteAdminCommand; +import fr.northblue.vote.spigot.listener.VoteMessageListener; +import fr.northblue.vote.spigot.storage.MySqlPendingVoteStore; +import fr.northblue.vote.spigot.storage.PendingVoteStore; +import fr.northblue.vote.spigot.storage.YamlPendingVoteStore; +import fr.northblue.vote.spigot.voteparty.VoteParty; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.plugin.java.JavaPlugin; + +public class NorthBlueVoteSpigot extends JavaPlugin { + + public static final String CHANNEL = "votenetwork:vote"; + + private VoteConfig voteConfig; + private PendingVoteStore pendingVoteStore; + private VoteParty voteParty; + private MaintenanceState maintenanceState; + + @Override + public void onEnable() { + saveDefaultConfig(); + + this.voteConfig = new VoteConfig(this); + this.maintenanceState = new MaintenanceState(); + + if (voteConfig.isMysqlStorage()) { + MySqlPendingVoteStore mysqlStore = new MySqlPendingVoteStore(this, voteConfig); + mysqlStore.connect(); + this.pendingVoteStore = mysqlStore; + getLogger().info("Stockage des votes en attente : MySQL."); + } else { + this.pendingVoteStore = new YamlPendingVoteStore(this); + getLogger().info("Stockage des votes en attente : fichier YAML local (pending-votes.yml)."); + } + + this.voteParty = new VoteParty(this, voteConfig); + this.voteParty.load(); + + getServer().getMessenger().registerIncomingPluginChannel(this, CHANNEL, new VoteMessageListener(voteConfig, voteParty)); + getServer().getMessenger().registerOutgoingPluginChannel(this, CHANNEL); + + getCommand("claim").setExecutor(new ClaimCommand(this, voteConfig, pendingVoteStore, voteParty, maintenanceState)); + getCommand("vote").setExecutor(new VoteAdminCommand(this, voteConfig, pendingVoteStore, maintenanceState)); + + NorthBlueVoteAPI api = new NorthBlueVoteAPIImpl(this, voteConfig, pendingVoteStore, voteParty, maintenanceState); + NorthBlueVoteAPIHolder.set(api); + getServer().getServicesManager().register(NorthBlueVoteAPI.class, api, this, ServicePriority.Normal); + + getLogger().info("VoteNetwork (Spigot) demarre."); + } + + @Override + public void onDisable() { + getServer().getServicesManager().unregisterAll(this); + NorthBlueVoteAPIHolder.clear(); + + if (voteParty != null) { + voteParty.save(); + } + if (pendingVoteStore != null) { + pendingVoteStore.close(); + } + } + + public VoteConfig getVoteConfig() { + return voteConfig; + } + + public PendingVoteStore getPendingVoteStore() { + return pendingVoteStore; + } + + public VoteParty getVoteParty() { + return voteParty; + } + + public MaintenanceState getMaintenanceState() { + return maintenanceState; + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/VoteConfig.java b/spigot/src/main/java/fr/northblue/vote/spigot/VoteConfig.java new file mode 100644 index 0000000..cb57041 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/VoteConfig.java @@ -0,0 +1,125 @@ +package fr.northblue.vote.spigot; + +import org.bukkit.ChatColor; +import org.bukkit.configuration.file.FileConfiguration; + +import java.util.List; +import java.util.stream.Collectors; + +public class VoteConfig { + + private final NorthBlueVoteSpigot plugin; + + public VoteConfig(NorthBlueVoteSpigot plugin) { + this.plugin = plugin; + } + + private FileConfiguration cfg() { + return plugin.getConfig(); + } + + public void reload() { + plugin.reloadConfig(); + } + + public String getStorageType() { + return cfg().getString("storage.type", "mysql"); + } + + public boolean isMysqlStorage() { + return !"yaml".equalsIgnoreCase(getStorageType()); + } + + public String getMysqlHost() { + return cfg().getString("mysql.host", "127.0.0.1"); + } + + public int getMysqlPort() { + return cfg().getInt("mysql.port", 3306); + } + + public String getMysqlDatabase() { + return cfg().getString("mysql.database", "northblue"); + } + + public String getMysqlUser() { + return cfg().getString("mysql.user", "root"); + } + + public String getMysqlPassword() { + return cfg().getString("mysql.password", ""); + } + + public int getMysqlPoolSize() { + return cfg().getInt("mysql.pool-size", 5); + } + + public List getVoteRewardCommands() { + return cfg().getStringList("rewards.vote-commands"); + } + + public int getVotePartyVotesRequired() { + return cfg().getInt("voteparty.votes-requis", 100); + } + + public List getVotePartyCommands() { + return cfg().getStringList("voteparty.commands"); + } + + public boolean isVotePartyBroadcastEnabled() { + return cfg().getBoolean("voteparty.broadcast.enabled", true); + } + + public String getVotePartyProgressMessage() { + return colorize(cfg().getString("voteparty.broadcast.progress-message", + "&b[Vote] &f%current%&7/&f%required% &fvotes avant le prochain VoteParty !")); + } + + public String getVotePartyTriggeredMessage() { + return colorize(cfg().getString("voteparty.broadcast.triggered-message", + "&a[Vote] &fVoteParty declenche ! Profitez des recompenses !")); + } + + public String getMessageClaimSuccess() { + return colorize(cfg().getString("messages.claim-success", + "&a[Vote] &fVous avez recupere &e%amount% &fvote(s) en attente !")); + } + + public String getMessageClaimEmpty() { + return colorize(cfg().getString("messages.claim-empty", + "&c[Vote] &fVous n'avez aucun vote en attente.")); + } + + public String getMessageMaintenance() { + return colorize(cfg().getString("messages.maintenance", + "&c[Vote] &fLe systeme de vote est actuellement en maintenance.")); + } + + public String getMessageMaintenanceEnabled() { + return colorize(cfg().getString("messages.maintenance-enabled", + "&c[Vote] &fMode maintenance active.")); + } + + public String getMessageMaintenanceDisabled() { + return colorize(cfg().getString("messages.maintenance-disabled", + "&a[Vote] &fMode maintenance desactive.")); + } + + public String getMessageDirectVote() { + return colorize(cfg().getString("messages.direct-vote", + "&a[Vote] &fMerci pour ton vote !")); + } + + public String getMessageNoPermission() { + return colorize(cfg().getString("messages.no-permission", + "&cVous n'avez pas la permission d'utiliser cette commande.")); + } + + private String colorize(String input) { + return input == null ? "" : ChatColor.translateAlternateColorCodes('&', input); + } + + public List colorizeList(List input) { + return input.stream().map(this::colorize).collect(Collectors.toList()); + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/api/NorthBlueVoteAPI.java b/spigot/src/main/java/fr/northblue/vote/spigot/api/NorthBlueVoteAPI.java new file mode 100644 index 0000000..287f363 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/api/NorthBlueVoteAPI.java @@ -0,0 +1,34 @@ +package fr.northblue.vote.spigot.api; + +import java.util.UUID; +import java.util.function.Consumer; + +/** + * API publique NorthBlueVote, pour affichage scoreboard / GUI par d'autres plugins. + * + * Recuperation : + * NorthBlueVoteAPI api = NorthBlueVoteAPI.get(); + * ou via le ServicesManager Bukkit : + * RegisteredServiceProvider rsp = Bukkit.getServicesManager().getRegistration(NorthBlueVoteAPI.class); + */ +public interface NorthBlueVoteAPI { + + /** Compteur VoteParty actuel sur CE serveur (thread principal uniquement). */ + int getVotePartyCurrentVotes(); + + /** Objectif VoteParty configure sur CE serveur (thread principal uniquement). */ + int getVotePartyRequiredVotes(); + + /** true si /claim est actuellement bloque (mode maintenance). */ + boolean isMaintenanceEnabled(); + + /** + * Lit (sans les consommer) les votes en attente en base pour ce joueur. + * Requete asynchrone : le callback est rappele sur le thread principal du serveur. + */ + void getPendingVotes(UUID playerUuid, Consumer callback); + + static NorthBlueVoteAPI get() { + return NorthBlueVoteAPIHolder.INSTANCE; + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/api/NorthBlueVoteAPIHolder.java b/spigot/src/main/java/fr/northblue/vote/spigot/api/NorthBlueVoteAPIHolder.java new file mode 100644 index 0000000..376947d --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/api/NorthBlueVoteAPIHolder.java @@ -0,0 +1,17 @@ +package fr.northblue.vote.spigot.api; + +public final class NorthBlueVoteAPIHolder { + + static volatile NorthBlueVoteAPI INSTANCE; + + private NorthBlueVoteAPIHolder() { + } + + public static void set(NorthBlueVoteAPI api) { + INSTANCE = api; + } + + public static void clear() { + INSTANCE = null; + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/api/NorthBlueVoteAPIImpl.java b/spigot/src/main/java/fr/northblue/vote/spigot/api/NorthBlueVoteAPIImpl.java new file mode 100644 index 0000000..5a28c7d --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/api/NorthBlueVoteAPIImpl.java @@ -0,0 +1,58 @@ +package fr.northblue.vote.spigot.api; + +import fr.northblue.vote.spigot.MaintenanceState; +import fr.northblue.vote.spigot.NorthBlueVoteSpigot; +import fr.northblue.vote.spigot.VoteConfig; +import fr.northblue.vote.spigot.storage.PendingVoteStore; +import fr.northblue.vote.spigot.voteparty.VoteParty; +import org.bukkit.Bukkit; + +import java.util.UUID; +import java.util.function.Consumer; + +public class NorthBlueVoteAPIImpl implements NorthBlueVoteAPI { + + private final NorthBlueVoteSpigot plugin; + private final VoteConfig config; + private final PendingVoteStore pendingVoteStore; + private final VoteParty voteParty; + private final MaintenanceState maintenanceState; + + public NorthBlueVoteAPIImpl(NorthBlueVoteSpigot plugin, VoteConfig config, PendingVoteStore pendingVoteStore, + VoteParty voteParty, MaintenanceState maintenanceState) { + this.plugin = plugin; + this.config = config; + this.pendingVoteStore = pendingVoteStore; + this.voteParty = voteParty; + this.maintenanceState = maintenanceState; + } + + @Override + public int getVotePartyCurrentVotes() { + return voteParty.getCurrentVotes(); + } + + @Override + public int getVotePartyRequiredVotes() { + return config.getVotePartyVotesRequired(); + } + + @Override + public boolean isMaintenanceEnabled() { + return maintenanceState.isStopped(); + } + + @Override + public void getPendingVotes(UUID playerUuid, Consumer callback) { + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> + pendingVoteStore.peek( + playerUuid, + pending -> Bukkit.getScheduler().runTask(plugin, () -> callback.accept(pending)), + exception -> { + plugin.getLogger().severe("Erreur API getPendingVotes: " + exception.getMessage()); + Bukkit.getScheduler().runTask(plugin, () -> callback.accept(0)); + } + ) + ); + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/command/ClaimCommand.java b/spigot/src/main/java/fr/northblue/vote/spigot/command/ClaimCommand.java new file mode 100644 index 0000000..2806ae7 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/command/ClaimCommand.java @@ -0,0 +1,76 @@ +package fr.northblue.vote.spigot.command; + +import fr.northblue.vote.spigot.MaintenanceState; +import fr.northblue.vote.spigot.NorthBlueVoteSpigot; +import fr.northblue.vote.spigot.VoteConfig; +import fr.northblue.vote.spigot.storage.PendingVoteStore; +import fr.northblue.vote.spigot.voteparty.VoteParty; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public class ClaimCommand implements CommandExecutor { + + private final NorthBlueVoteSpigot plugin; + private final VoteConfig config; + private final PendingVoteStore pendingVoteStore; + private final VoteParty voteParty; + private final MaintenanceState maintenanceState; + + public ClaimCommand(NorthBlueVoteSpigot plugin, VoteConfig config, PendingVoteStore pendingVoteStore, + VoteParty voteParty, MaintenanceState maintenanceState) { + this.plugin = plugin; + this.config = config; + this.pendingVoteStore = pendingVoteStore; + this.voteParty = voteParty; + this.maintenanceState = maintenanceState; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player)) { + sender.sendMessage("Cette commande est reservee aux joueurs."); + return true; + } + + Player player = (Player) sender; + + if (maintenanceState.isStopped()) { + player.sendMessage(config.getMessageMaintenance()); + return true; + } + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> + pendingVoteStore.fetchAndClear( + player.getUniqueId(), + pendingVotes -> Bukkit.getScheduler().runTask(plugin, () -> applyClaim(player, pendingVotes)), + exception -> Bukkit.getScheduler().runTask(plugin, () -> { + plugin.getLogger().severe("Erreur /claim pour " + player.getName() + ": " + exception.getMessage()); + player.sendMessage(config.getMessageMaintenance()); + }) + ) + ); + + return true; + } + + private void applyClaim(Player player, int pendingVotes) { + if (pendingVotes <= 0) { + player.sendMessage(config.getMessageClaimEmpty()); + return; + } + + for (int i = 0; i < pendingVotes; i++) { + for (String cmd : config.getVoteRewardCommands()) { + String parsed = cmd.replace("%player%", player.getName()); + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), parsed); + } + } + + voteParty.addVotes(pendingVotes); + + player.sendMessage(config.getMessageClaimSuccess().replace("%amount%", String.valueOf(pendingVotes))); + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/command/VoteAdminCommand.java b/spigot/src/main/java/fr/northblue/vote/spigot/command/VoteAdminCommand.java new file mode 100644 index 0000000..6933385 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/command/VoteAdminCommand.java @@ -0,0 +1,70 @@ +package fr.northblue.vote.spigot.command; + +import fr.northblue.vote.spigot.MaintenanceState; +import fr.northblue.vote.spigot.NorthBlueVoteSpigot; +import fr.northblue.vote.spigot.VoteConfig; +import fr.northblue.vote.spigot.storage.PendingVoteStore; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; + +public class VoteAdminCommand implements CommandExecutor { + + private final NorthBlueVoteSpigot plugin; + private final VoteConfig config; + private final PendingVoteStore pendingVoteStore; + private final MaintenanceState maintenanceState; + + public VoteAdminCommand(NorthBlueVoteSpigot plugin, VoteConfig config, PendingVoteStore pendingVoteStore, MaintenanceState maintenanceState) { + this.plugin = plugin; + this.config = config; + this.pendingVoteStore = pendingVoteStore; + this.maintenanceState = maintenanceState; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission("votenetwork.admin")) { + sender.sendMessage(config.getMessageNoPermission()); + return true; + } + + if (args.length != 1) { + sender.sendMessage("Usage: /vote "); + return true; + } + + switch (args[0].toLowerCase()) { + case "stop" -> { + maintenanceState.setStopped(true); + sender.sendMessage(config.getMessageMaintenanceEnabled()); + } + case "start" -> { + maintenanceState.setStopped(false); + sender.sendMessage(config.getMessageMaintenanceDisabled()); + } + case "testdb" -> testStorage(sender); + default -> sender.sendMessage("Usage: /vote "); + } + + return true; + } + + private void testStorage(CommandSender sender) { + sender.sendMessage(ChatColor.GRAY + "[Vote] Test du stockage en cours..."); + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> + pendingVoteStore.testConnection((success, message) -> + Bukkit.getScheduler().runTask(plugin, () -> { + if (success) { + sender.sendMessage(ChatColor.GREEN + "[Vote] " + message); + } else { + sender.sendMessage(ChatColor.RED + "[Vote] Echec : " + message); + } + }) + ) + ); + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/listener/VoteMessageListener.java b/spigot/src/main/java/fr/northblue/vote/spigot/listener/VoteMessageListener.java new file mode 100644 index 0000000..2295c72 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/listener/VoteMessageListener.java @@ -0,0 +1,50 @@ +package fr.northblue.vote.spigot.listener; + +import com.google.common.io.ByteArrayDataInput; +import com.google.common.io.ByteStreams; +import fr.northblue.vote.spigot.NorthBlueVoteSpigot; +import fr.northblue.vote.spigot.VoteConfig; +import fr.northblue.vote.spigot.voteparty.VoteParty; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.messaging.PluginMessageListener; + +import java.util.UUID; + +public class VoteMessageListener implements PluginMessageListener { + + private final VoteConfig config; + private final VoteParty voteParty; + + public VoteMessageListener(VoteConfig config, VoteParty voteParty) { + this.config = config; + this.voteParty = voteParty; + } + + @Override + public void onPluginMessageReceived(String channel, Player receivingPlayer, byte[] message) { + if (!channel.equals(NorthBlueVoteSpigot.CHANNEL)) { + return; + } + + ByteArrayDataInput in = ByteStreams.newDataInput(message); + UUID uuid = UUID.fromString(in.readUTF()); + in.readUTF(); // pseudo, non utilise ici (on retrouve le joueur via son UUID) + Player target = Bukkit.getPlayer(uuid); + + if (target == null || !target.isOnline()) { + // Le joueur a quitte ce serveur juste avant l'arrivee du message : le vote reste + // gere par le fallback base de donnees cote Velocity lors du prochain vote. + return; + } + + for (String command : config.getVoteRewardCommands()) { + String parsed = command.replace("%player%", target.getName()); + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), parsed); + } + + target.sendMessage(config.getMessageDirectVote()); + + voteParty.addVotes(1); + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/storage/MySqlPendingVoteStore.java b/spigot/src/main/java/fr/northblue/vote/spigot/storage/MySqlPendingVoteStore.java new file mode 100644 index 0000000..ef26946 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/storage/MySqlPendingVoteStore.java @@ -0,0 +1,147 @@ +package fr.northblue.vote.spigot.storage; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import fr.northblue.vote.spigot.NorthBlueVoteSpigot; +import fr.northblue.vote.spigot.VoteConfig; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.IntConsumer; + +public class MySqlPendingVoteStore implements PendingVoteStore { + + private static final String DRIVER_CLASS = "fr.northblue.vote.libs.mysql.cj.jdbc.Driver"; + + private final NorthBlueVoteSpigot plugin; + private final VoteConfig config; + private HikariDataSource dataSource; + + public MySqlPendingVoteStore(NorthBlueVoteSpigot plugin, VoteConfig config) { + this.plugin = plugin; + this.config = config; + } + + public void connect() { + try { + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl("jdbc:mysql://" + config.getMysqlHost() + ":" + config.getMysqlPort() + + "/" + config.getMysqlDatabase() + "?useSSL=false&autoReconnect=true&characterEncoding=utf8"); + hikariConfig.setUsername(config.getMysqlUser()); + hikariConfig.setPassword(config.getMysqlPassword()); + hikariConfig.setMaximumPoolSize(config.getMysqlPoolSize()); + hikariConfig.setPoolName("VoteNetwork-Spigot-Pool"); + hikariConfig.setDriverClassName(DRIVER_CLASS); + hikariConfig.setInitializationFailTimeout(-1); + hikariConfig.addDataSourceProperty("cachePrepStmts", "true"); + hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250"); + hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + + this.dataSource = new HikariDataSource(hikariConfig); + createTableIfNotExists(); + } catch (Exception e) { + plugin.getLogger().severe("Connexion MySQL impossible au demarrage: " + e.getMessage()); + plugin.getLogger().severe("Corrigez config.yml puis utilisez /vote testdb, ou redemarrez le plugin."); + } + } + + public boolean isConnected() { + return dataSource != null && !dataSource.isClosed(); + } + + @Override + public void testConnection(BiConsumer callback) { + String url = "jdbc:mysql://" + config.getMysqlHost() + ":" + config.getMysqlPort() + + "/" + config.getMysqlDatabase() + "?useSSL=false&connectTimeout=3000&socketTimeout=3000"; + long start = System.currentTimeMillis(); + try { + Class.forName(DRIVER_CLASS); + try (Connection connection = DriverManager.getConnection(url, config.getMysqlUser(), config.getMysqlPassword())) { + long latency = System.currentTimeMillis() - start; + callback.accept(true, "Connexion reussie en " + latency + " ms (" + config.getMysqlHost() + ":" + config.getMysqlPort() + "/" + config.getMysqlDatabase() + ")"); + } + } catch (Exception e) { + callback.accept(false, e.getMessage()); + } + } + + private void createTableIfNotExists() { + String sql = "CREATE TABLE IF NOT EXISTS nb_votes_attente (" + + "player_uuid VARCHAR(36) NOT NULL PRIMARY KEY," + + "player_name VARCHAR(16) NOT NULL," + + "nombre_votes INT DEFAULT 0" + + ")"; + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(sql); + } catch (SQLException e) { + plugin.getLogger().severe("Impossible de creer la table nb_votes_attente: " + e.getMessage()); + } + } + + @Override + public void fetchAndClear(UUID uuid, IntConsumer onResult, Consumer onError) { + if (!isConnected()) { + onError.accept(new SQLException("Pool MySQL indisponible (echec de connexion au demarrage)")); + return; + } + String selectSql = "SELECT nombre_votes FROM nb_votes_attente WHERE player_uuid = ?"; + String resetSql = "UPDATE nb_votes_attente SET nombre_votes = 0 WHERE player_uuid = ?"; + + try (Connection connection = dataSource.getConnection()) { + int pendingVotes = 0; + + try (PreparedStatement select = connection.prepareStatement(selectSql)) { + select.setString(1, uuid.toString()); + try (ResultSet resultSet = select.executeQuery()) { + if (resultSet.next()) { + pendingVotes = resultSet.getInt("nombre_votes"); + } + } + } + + if (pendingVotes > 0) { + try (PreparedStatement reset = connection.prepareStatement(resetSql)) { + reset.setString(1, uuid.toString()); + reset.executeUpdate(); + } + } + + onResult.accept(pendingVotes); + } catch (SQLException e) { + onError.accept(e); + } + } + + @Override + public void peek(UUID uuid, IntConsumer onResult, Consumer onError) { + if (!isConnected()) { + onError.accept(new SQLException("Pool MySQL indisponible (echec de connexion au demarrage)")); + return; + } + String sql = "SELECT nombre_votes FROM nb_votes_attente WHERE player_uuid = ?"; + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + onResult.accept(resultSet.next() ? resultSet.getInt("nombre_votes") : 0); + } + } catch (SQLException e) { + onError.accept(e); + } + } + + @Override + public void close() { + if (dataSource != null) { + dataSource.close(); + } + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/storage/PendingVoteStore.java b/spigot/src/main/java/fr/northblue/vote/spigot/storage/PendingVoteStore.java new file mode 100644 index 0000000..c6b4153 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/storage/PendingVoteStore.java @@ -0,0 +1,27 @@ +package fr.northblue.vote.spigot.storage; + +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.IntConsumer; + +/** + * Stockage des votes en attente (joueurs hors ligne au moment du vote). + * Deux implementations : MySQL (partage reseau) ou fichier YAML local. + * + * Toutes les methodes doivent etre appelees depuis un thread asynchrone + * (Bukkit.getScheduler().runTaskAsynchronously) : les deux implementations font de l'I/O bloquant. + */ +public interface PendingVoteStore { + + /** Lit le compteur de votes en attente puis le remet a 0 (utilise par /claim). */ + void fetchAndClear(UUID uuid, IntConsumer onResult, Consumer onError); + + /** Lit le compteur de votes en attente sans le modifier (utilise par l'API). */ + void peek(UUID uuid, IntConsumer onResult, Consumer onError); + + /** Teste que le stockage est joignable/fonctionnel, pour /vote testdb. */ + void testConnection(BiConsumer callback); + + void close(); +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/storage/YamlPendingVoteStore.java b/spigot/src/main/java/fr/northblue/vote/spigot/storage/YamlPendingVoteStore.java new file mode 100644 index 0000000..60768e1 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/storage/YamlPendingVoteStore.java @@ -0,0 +1,80 @@ +package fr.northblue.vote.spigot.storage; + +import fr.northblue.vote.spigot.NorthBlueVoteSpigot; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.IntConsumer; + +/** + * Stockage local des votes en attente dans plugins/votenetwork/pending-votes.yml. + * + * ATTENTION : ce fichier est local a CE serveur. Si le proxy Velocity utilise aussi + * le stockage "yaml", son fichier pending-votes.yml (dossier du plugin Velocity) est + * distinct de celui-ci et n'est PAS synchronise automatiquement entre machines. + * Le mode "yaml" n'est donc fiable que pour un reseau mono-machine (proxy + serveurs + * sur le meme disque, via un lien/point de montage partage) ou pour des tests locaux. + * Pour un vrai reseau multi-serveurs, utilisez "mysql". + */ +public class YamlPendingVoteStore implements PendingVoteStore { + + private final File file; + private final ReentrantLock lock = new ReentrantLock(); + + public YamlPendingVoteStore(NorthBlueVoteSpigot plugin) { + this.file = new File(plugin.getDataFolder(), "pending-votes.yml"); + } + + @Override + public void fetchAndClear(UUID uuid, IntConsumer onResult, Consumer onError) { + lock.lock(); + try { + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); + String path = "players." + uuid; + int votes = yaml.getInt(path + ".votes", 0); + if (votes > 0) { + yaml.set(path + ".votes", 0); + yaml.save(file); + } + onResult.accept(votes); + } catch (IOException e) { + onError.accept(e); + } finally { + lock.unlock(); + } + } + + @Override + public void peek(UUID uuid, IntConsumer onResult, Consumer onError) { + lock.lock(); + try { + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); + onResult.accept(yaml.getInt("players." + uuid + ".votes", 0)); + } finally { + lock.unlock(); + } + } + + @Override + public void testConnection(BiConsumer callback) { + lock.lock(); + try { + File parent = file.getParentFile(); + boolean writable = parent.exists() || parent.mkdirs(); + callback.accept(writable, writable + ? "Stockage local YAML actif : " + file.getPath() + : "Impossible d'ecrire dans " + file.getPath()); + } finally { + lock.unlock(); + } + } + + @Override + public void close() { + } +} diff --git a/spigot/src/main/java/fr/northblue/vote/spigot/voteparty/VoteParty.java b/spigot/src/main/java/fr/northblue/vote/spigot/voteparty/VoteParty.java new file mode 100644 index 0000000..1a32c77 --- /dev/null +++ b/spigot/src/main/java/fr/northblue/vote/spigot/voteparty/VoteParty.java @@ -0,0 +1,77 @@ +package fr.northblue.vote.spigot.voteparty; + +import fr.northblue.vote.spigot.NorthBlueVoteSpigot; +import fr.northblue.vote.spigot.VoteConfig; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +public class VoteParty { + + private final NorthBlueVoteSpigot plugin; + private final VoteConfig config; + private final File storageFile; + private final AtomicInteger currentVotes = new AtomicInteger(0); + + public VoteParty(NorthBlueVoteSpigot plugin, VoteConfig config) { + this.plugin = plugin; + this.config = config; + this.storageFile = new File(plugin.getDataFolder(), "voteparty.yml"); + } + + public void load() { + if (!storageFile.exists()) { + currentVotes.set(0); + return; + } + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(storageFile); + currentVotes.set(yaml.getInt("current-votes", 0)); + } + + public void save() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("current-votes", currentVotes.get()); + try { + yaml.save(storageFile); + } catch (IOException e) { + plugin.getLogger().severe("Impossible de sauvegarder le VoteParty local: " + e.getMessage()); + } + } + + /** + * A appeler uniquement depuis le thread principal du serveur. + */ + public void addVotes(int amount) { + int required = config.getVotePartyVotesRequired(); + int updated = currentVotes.addAndGet(amount); + + if (config.isVotePartyBroadcastEnabled()) { + Bukkit.broadcastMessage(config.getVotePartyProgressMessage() + .replace("%current%", String.valueOf(Math.min(updated, required))) + .replace("%required%", String.valueOf(required))); + } + + while (updated >= required && required > 0) { + trigger(); + updated = currentVotes.addAndGet(-required); + } + + save(); + } + + private void trigger() { + if (config.isVotePartyBroadcastEnabled()) { + Bukkit.broadcastMessage(config.getVotePartyTriggeredMessage()); + } + for (String command : config.getVotePartyCommands()) { + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); + } + } + + public int getCurrentVotes() { + return currentVotes.get(); + } +} diff --git a/spigot/src/main/resources/config.yml b/spigot/src/main/resources/config.yml new file mode 100644 index 0000000..6a08c08 --- /dev/null +++ b/spigot/src/main/resources/config.yml @@ -0,0 +1,44 @@ +# ========================================= +# VoteNetwork - Configuration (Spigot) +# ========================================= + +# Stockage des votes en attente (joueurs hors ligne au moment du vote) : +# - mysql : recommande pour un vrai reseau (proxy + plusieurs serveurs). Partage entre toutes les machines. +# - yaml : fichier local plugins/votenetwork/pending-votes.yml. Utile en solo/test, mais N'EST PAS +# partage automatiquement avec le fichier YAML du proxy Velocity si vous etes en reseau. +storage: + type: mysql + +mysql: + host: 127.0.0.1 + port: 3306 + database: votenetwork + user: root + password: changeme + pool-size: 5 + +# Commandes executees par la console pour recompenser UN vote (vote direct en jeu ou /claim, une fois par vote). +# %player% est remplace par le pseudo du joueur. +rewards: + vote-commands: + - "goldencrates give %player% vote_key 1" + +voteparty: + # Nombre de votes cumules (sur CE serveur) declenchant le VoteParty. + votes-requis: 100 + # Commandes executees par la console quand le VoteParty se declenche. + commands: + - "goldencrates broadcast_give vote_key 1" + broadcast: + enabled: true + progress-message: "&b[Vote] &f%current%&7/&f%required% &fvotes avant le prochain VoteParty !" + triggered-message: "&a[Vote] &fVoteParty declenche ! Profitez des recompenses !" + +messages: + direct-vote: "&a[Vote] &fMerci pour ton vote !" + claim-success: "&a[Vote] &fVous avez recupere &e%amount% &fvote(s) en attente !" + claim-empty: "&c[Vote] &fVous n'avez aucun vote en attente." + maintenance: "&c[Vote] &fLe systeme de vote est actuellement en maintenance." + maintenance-enabled: "&c[Vote] &fMode maintenance active." + maintenance-disabled: "&a[Vote] &fMode maintenance desactive." + no-permission: "&cVous n'avez pas la permission d'utiliser cette commande." diff --git a/spigot/src/main/resources/plugin.yml b/spigot/src/main/resources/plugin.yml new file mode 100644 index 0000000..8c2d873 --- /dev/null +++ b/spigot/src/main/resources/plugin.yml @@ -0,0 +1,20 @@ +name: votenetwork +main: fr.northblue.vote.spigot.NorthBlueVoteSpigot +version: ${project.version} +api-version: 1.18 +author: VoteNetwork +description: Reception des votes reseau (direct ou en attente) avec VoteParty local. + +commands: + claim: + description: Recupere vos votes en attente. + usage: /claim + vote: + description: Administration du systeme de vote (maintenance, test du stockage). + usage: /vote + permission: votenetwork.admin + +permissions: + votenetwork.admin: + description: Autorise l'utilisation de /vote stop|start|testdb. + default: op diff --git a/velocity/pom.xml b/velocity/pom.xml new file mode 100644 index 0000000..38b0c6b --- /dev/null +++ b/velocity/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + + fr.northblue + northblue-vote-parent + 1.0.0 + + + northblue-vote-velocity + jar + + + + com.velocitypowered + velocity-api + 3.3.0-SNAPSHOT + provided + + + com.zaxxer + HikariCP + ${hikaricp.version} + + + com.mysql + mysql-connector-j + ${mysql.version} + + + com.google.code.gson + gson + 2.10.1 + provided + + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + + + VoteNetwork-Velocity + + + org.apache.maven.plugins + maven-compiler-plugin + + + + com.velocitypowered + velocity-api + 3.3.0-SNAPSHOT + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + + + com.zaxxer.hikari + fr.northblue.vote.libs.hikari + + + com.mysql + fr.northblue.vote.libs.mysql + + + + + + + + + + + + diff --git a/velocity/src/main/java/fr/northblue/vote/velocity/MojangUuidResolver.java b/velocity/src/main/java/fr/northblue/vote/velocity/MojangUuidResolver.java new file mode 100644 index 0000000..52db5a3 --- /dev/null +++ b/velocity/src/main/java/fr/northblue/vote/velocity/MojangUuidResolver.java @@ -0,0 +1,61 @@ +package fr.northblue.vote.velocity; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class MojangUuidResolver { + + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); + + private final Logger logger; + + public MojangUuidResolver(Logger logger) { + this.logger = logger; + } + + public CompletableFuture resolve(String playerName) { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("https://api.mojang.com/users/profiles/minecraft/" + playerName)) + .GET() + .build(); + + return HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(response -> { + if (response.statusCode() != 200 || response.body() == null || response.body().isBlank()) { + return offlineFallback(playerName); + } + try { + JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); + String rawId = json.get("id").getAsString(); + return dashedUuid(rawId); + } catch (Exception e) { + logger.warn("Reponse Mojang illisible pour {}, utilisation d'un UUID de secours", playerName); + return offlineFallback(playerName); + } + }) + .exceptionally(throwable -> { + logger.warn("Impossible de contacter l'API Mojang pour {}", playerName, throwable); + return offlineFallback(playerName); + }); + } + + private UUID offlineFallback(String playerName) { + return UUID.nameUUIDFromBytes(("OfflinePlayer:" + playerName).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private UUID dashedUuid(String raw) { + String dashed = raw.replaceFirst( + "(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})", + "$1-$2-$3-$4-$5" + ); + return UUID.fromString(dashed); + } +} diff --git a/velocity/src/main/java/fr/northblue/vote/velocity/MySqlPendingVoteStore.java b/velocity/src/main/java/fr/northblue/vote/velocity/MySqlPendingVoteStore.java new file mode 100644 index 0000000..83f9d25 --- /dev/null +++ b/velocity/src/main/java/fr/northblue/vote/velocity/MySqlPendingVoteStore.java @@ -0,0 +1,100 @@ +package fr.northblue.vote.velocity; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.slf4j.Logger; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public class MySqlPendingVoteStore implements PendingVoteStore { + + private final VoteConfig config; + private final Logger logger; + private final Executor asyncExecutor = Executors.newFixedThreadPool(4, r -> { + Thread t = new Thread(r, "VoteNetwork-DB"); + t.setDaemon(true); + return t; + }); + + private HikariDataSource dataSource; + + public MySqlPendingVoteStore(VoteConfig config, Logger logger) { + this.config = config; + this.logger = logger; + } + + public void connect() { + try { + HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setJdbcUrl(config.getJdbcUrl()); + hikariConfig.setUsername(config.getUser()); + hikariConfig.setPassword(config.getPassword()); + hikariConfig.setMaximumPoolSize(config.getPoolSize()); + hikariConfig.setPoolName("VoteNetwork-Velocity-Pool"); + hikariConfig.setDriverClassName("fr.northblue.vote.libs.mysql.cj.jdbc.Driver"); + hikariConfig.setInitializationFailTimeout(-1); + hikariConfig.addDataSourceProperty("cachePrepStmts", "true"); + hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250"); + hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + + this.dataSource = new HikariDataSource(hikariConfig); + + createTableIfNotExists(); + } catch (Exception e) { + logger.error("Connexion MySQL impossible au demarrage (Velocity). Verifiez config.properties.", e); + } + } + + public boolean isConnected() { + return dataSource != null && !dataSource.isClosed(); + } + + private void createTableIfNotExists() { + String sql = "CREATE TABLE IF NOT EXISTS nb_votes_attente (" + + "player_uuid VARCHAR(36) NOT NULL PRIMARY KEY," + + "player_name VARCHAR(16) NOT NULL," + + "nombre_votes INT DEFAULT 0" + + ")"; + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(sql); + } catch (SQLException e) { + logger.error("Impossible de creer la table nb_votes_attente", e); + } + } + + @Override + public CompletableFuture addPendingVote(UUID uuid, String playerName) { + return CompletableFuture.runAsync(() -> { + if (!isConnected()) { + logger.error("Impossible d'enregistrer le vote en attente pour {} : pool MySQL indisponible", playerName); + return; + } + String sql = "INSERT INTO nb_votes_attente (player_uuid, player_name, nombre_votes) " + + "VALUES (?, ?, 1) " + + "ON DUPLICATE KEY UPDATE nombre_votes = nombre_votes + 1, player_name = VALUES(player_name)"; + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + statement.setString(2, playerName); + statement.executeUpdate(); + } catch (SQLException e) { + logger.error("Erreur lors de l'insertion du vote en attente pour {}", playerName, e); + } + }, asyncExecutor); + } + + @Override + public void close() { + if (dataSource != null) { + dataSource.close(); + } + } +} diff --git a/velocity/src/main/java/fr/northblue/vote/velocity/NorthBlueVoteVelocity.java b/velocity/src/main/java/fr/northblue/vote/velocity/NorthBlueVoteVelocity.java new file mode 100644 index 0000000..ae01bf5 --- /dev/null +++ b/velocity/src/main/java/fr/northblue/vote/velocity/NorthBlueVoteVelocity.java @@ -0,0 +1,71 @@ +package fr.northblue.vote.velocity; + +import com.google.inject.Inject; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; +import org.slf4j.Logger; + +import java.nio.file.Path; + +@Plugin( + id = "votenetwork", + name = "VoteNetwork", + version = "1.0.0", + description = "Systeme de vote reseau Velocity <-> Spigot", + authors = {"VoteNetwork"} +) +public class NorthBlueVoteVelocity { + + public static final MinecraftChannelIdentifier VOTE_CHANNEL = + MinecraftChannelIdentifier.create("votenetwork", "vote"); + + private final ProxyServer proxy; + private final Logger logger; + private final Path dataDirectory; + + private VoteConfig config; + private PendingVoteStore pendingVoteStore; + + @Inject + public NorthBlueVoteVelocity(ProxyServer proxy, Logger logger, @com.velocitypowered.api.plugin.annotation.DataDirectory Path dataDirectory) { + this.proxy = proxy; + this.logger = logger; + this.dataDirectory = dataDirectory; + } + + @Subscribe + public void onProxyInitialize(ProxyInitializeEvent event) { + this.config = new VoteConfig(dataDirectory, logger); + this.config.load(); + + if (config.isMysqlStorage()) { + MySqlPendingVoteStore mysqlStore = new MySqlPendingVoteStore(config, logger); + mysqlStore.connect(); + this.pendingVoteStore = mysqlStore; + logger.info("Stockage des votes en attente : MySQL."); + } else { + this.pendingVoteStore = new YamlPendingVoteStore(dataDirectory, logger); + logger.info("Stockage des votes en attente : fichier YAML local (pending-votes.yml)."); + } + + proxy.getChannelRegistrar().register(VOTE_CHANNEL); + + proxy.getCommandManager().register( + proxy.getCommandManager().metaBuilder("votenetwork").build(), + new VoteCommand(proxy, pendingVoteStore, logger) + ); + + logger.info("VoteNetwork (Velocity) demarre."); + } + + @Subscribe + public void onProxyShutdown(ProxyShutdownEvent event) { + if (pendingVoteStore != null) { + pendingVoteStore.close(); + } + } +} diff --git a/velocity/src/main/java/fr/northblue/vote/velocity/PendingVoteStore.java b/velocity/src/main/java/fr/northblue/vote/velocity/PendingVoteStore.java new file mode 100644 index 0000000..9632d6d --- /dev/null +++ b/velocity/src/main/java/fr/northblue/vote/velocity/PendingVoteStore.java @@ -0,0 +1,15 @@ +package fr.northblue.vote.velocity; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Stockage des votes en attente (joueurs hors ligne au moment du vote). + * Deux implementations : MySQL (partage reseau, recommande) ou fichier YAML local. + */ +public interface PendingVoteStore { + + CompletableFuture addPendingVote(UUID uuid, String playerName); + + void close(); +} diff --git a/velocity/src/main/java/fr/northblue/vote/velocity/VoteCommand.java b/velocity/src/main/java/fr/northblue/vote/velocity/VoteCommand.java new file mode 100644 index 0000000..f192e25 --- /dev/null +++ b/velocity/src/main/java/fr/northblue/vote/velocity/VoteCommand.java @@ -0,0 +1,82 @@ +package fr.northblue.vote.velocity; + +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.command.SimpleCommand; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.ChannelMessageSink; +import net.kyori.adventure.text.Component; +import org.slf4j.Logger; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Optional; + +public class VoteCommand implements SimpleCommand { + + private final ProxyServer proxy; + private final PendingVoteStore pendingVoteStore; + private final Logger logger; + private final MojangUuidResolver uuidResolver; + + public VoteCommand(ProxyServer proxy, PendingVoteStore pendingVoteStore, Logger logger) { + this.proxy = proxy; + this.pendingVoteStore = pendingVoteStore; + this.logger = logger; + this.uuidResolver = new MojangUuidResolver(logger); + } + + @Override + public void execute(Invocation invocation) { + CommandSource source = invocation.source(); + String[] args = invocation.arguments(); + + if (!(source instanceof com.velocitypowered.api.proxy.ConsoleCommandSource)) { + source.sendMessage(Component.text("Cette commande n'est executable que depuis la console.")); + return; + } + + if (args.length < 2 || !args[0].equalsIgnoreCase("vote")) { + source.sendMessage(Component.text("Usage: votenetwork vote ")); + return; + } + + String playerName = args[1]; + Optional onlinePlayer = proxy.getPlayer(playerName); + + if (onlinePlayer.isPresent()) { + Player player = onlinePlayer.get(); + Optional serverConnection = player.getCurrentServer(); + + if (serverConnection.isPresent()) { + sendVoteSignal(serverConnection.get(), player); + logger.info("Vote direct envoye a {} pour le joueur {}", serverConnection.get().getServerInfo().getName(), player.getUsername()); + return; + } + } + + uuidResolver.resolve(playerName) + .thenCompose(uuid -> pendingVoteStore.addPendingVote(uuid, playerName)) + .thenRun(() -> logger.info("Vote stocke en base de donnees pour {} (hors ligne)", playerName)); + } + + private void sendVoteSignal(ChannelMessageSink sink, Player player) { + ByteArrayOutputStream byteArray = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(byteArray); + try { + out.writeUTF(player.getUniqueId().toString()); + out.writeUTF(player.getUsername()); + } catch (IOException e) { + logger.error("Erreur lors de la construction du message de vote", e); + return; + } + sink.sendPluginMessage(NorthBlueVoteVelocity.VOTE_CHANNEL, byteArray.toByteArray()); + } + + @Override + public boolean hasPermission(Invocation invocation) { + return invocation.source() instanceof com.velocitypowered.api.proxy.ConsoleCommandSource; + } +} diff --git a/velocity/src/main/java/fr/northblue/vote/velocity/VoteConfig.java b/velocity/src/main/java/fr/northblue/vote/velocity/VoteConfig.java new file mode 100644 index 0000000..be8a10c --- /dev/null +++ b/velocity/src/main/java/fr/northblue/vote/velocity/VoteConfig.java @@ -0,0 +1,79 @@ +package fr.northblue.vote.velocity; + +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +public class VoteConfig { + + private final Path dataDirectory; + private final Logger logger; + private final Properties properties = new Properties(); + + private String storageType; + private String host; + private int port; + private String database; + private String user; + private String password; + private int poolSize; + + public VoteConfig(Path dataDirectory, Logger logger) { + this.dataDirectory = dataDirectory; + this.logger = logger; + } + + public void load() { + try { + Files.createDirectories(dataDirectory); + Path configFile = dataDirectory.resolve("config.properties"); + + if (!Files.exists(configFile)) { + try (InputStream in = getClass().getClassLoader().getResourceAsStream("config.properties")) { + if (in != null) { + Files.copy(in, configFile); + } + } + } + + try (InputStream in = Files.newInputStream(configFile)) { + properties.load(in); + } + + this.storageType = properties.getProperty("storage.type", "mysql"); + this.host = properties.getProperty("mysql.host", "127.0.0.1"); + this.port = Integer.parseInt(properties.getProperty("mysql.port", "3306")); + this.database = properties.getProperty("mysql.database", "northblue"); + this.user = properties.getProperty("mysql.user", "root"); + this.password = properties.getProperty("mysql.password", ""); + this.poolSize = Integer.parseInt(properties.getProperty("mysql.pool-size", "5")); + } catch (IOException e) { + logger.error("Impossible de charger la configuration Velocity", e); + } + } + + public boolean isMysqlStorage() { + return !"yaml".equalsIgnoreCase(storageType); + } + + public String getJdbcUrl() { + return "jdbc:mysql://" + host + ":" + port + "/" + database + + "?useSSL=false&autoReconnect=true&characterEncoding=utf8"; + } + + public String getUser() { + return user; + } + + public String getPassword() { + return password; + } + + public int getPoolSize() { + return poolSize; + } +} diff --git a/velocity/src/main/java/fr/northblue/vote/velocity/YamlPendingVoteStore.java b/velocity/src/main/java/fr/northblue/vote/velocity/YamlPendingVoteStore.java new file mode 100644 index 0000000..dd16f35 --- /dev/null +++ b/velocity/src/main/java/fr/northblue/vote/velocity/YamlPendingVoteStore.java @@ -0,0 +1,133 @@ +package fr.northblue.vote.velocity; + +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Stockage local des votes en attente dans pending-votes.yml (dossier du plugin Velocity). + * + * ATTENTION : ce fichier est local au proxy. Si un serveur Spigot utilise aussi le + * stockage "yaml", son propre pending-votes.yml (dossier du plugin Spigot) est distinct + * de celui-ci et n'est PAS synchronise automatiquement. Le mode "yaml" n'est fiable que + * pour un reseau mono-machine (partage de disque) ou pour des tests locaux. + * Pour un vrai reseau multi-serveurs, utilisez "mysql". + */ +public class YamlPendingVoteStore implements PendingVoteStore { + + private static final Pattern UUID_LINE = Pattern.compile("^ {2}([0-9a-fA-F-]{36}):$"); + private static final Pattern NAME_LINE = Pattern.compile("^ {4}name: (.+)$"); + private static final Pattern VOTES_LINE = Pattern.compile("^ {4}votes: (\\d+)$"); + + private final Path file; + private final Logger logger; + private final ReentrantLock lock = new ReentrantLock(); + private final Executor asyncExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "VoteNetwork-YamlStore"); + t.setDaemon(true); + return t; + }); + + public YamlPendingVoteStore(Path dataDirectory, Logger logger) { + this.file = dataDirectory.resolve("pending-votes.yml"); + this.logger = logger; + } + + @Override + public CompletableFuture addPendingVote(UUID uuid, String playerName) { + return CompletableFuture.runAsync(() -> { + lock.lock(); + try { + Map data = load(); + Entry entry = data.get(uuid); + if (entry == null) { + entry = new Entry(playerName, 0); + data.put(uuid, entry); + } + entry.name = playerName; + entry.votes++; + save(data); + } catch (IOException e) { + logger.error("Erreur stockage YAML du vote en attente pour {}", playerName, e); + } finally { + lock.unlock(); + } + }, asyncExecutor); + } + + private Map load() throws IOException { + Map result = new LinkedHashMap<>(); + if (!Files.exists(file)) { + return result; + } + + UUID currentUuid = null; + String currentName = null; + Integer currentVotes = null; + + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + Matcher uuidMatcher = UUID_LINE.matcher(line); + if (uuidMatcher.matches()) { + flush(result, currentUuid, currentName, currentVotes); + currentUuid = UUID.fromString(uuidMatcher.group(1)); + currentName = null; + currentVotes = null; + continue; + } + Matcher nameMatcher = NAME_LINE.matcher(line); + if (nameMatcher.matches()) { + currentName = nameMatcher.group(1); + continue; + } + Matcher votesMatcher = VOTES_LINE.matcher(line); + if (votesMatcher.matches()) { + currentVotes = Integer.parseInt(votesMatcher.group(1)); + } + } + flush(result, currentUuid, currentName, currentVotes); + return result; + } + + private void flush(Map result, UUID uuid, String name, Integer votes) { + if (uuid != null && name != null && votes != null) { + result.put(uuid, new Entry(name, votes)); + } + } + + private void save(Map data) throws IOException { + StringBuilder sb = new StringBuilder("players:\n"); + for (Map.Entry e : data.entrySet()) { + sb.append(" ").append(e.getKey()).append(":\n"); + sb.append(" name: ").append(e.getValue().name).append("\n"); + sb.append(" votes: ").append(e.getValue().votes).append("\n"); + } + Files.createDirectories(file.getParent()); + Files.writeString(file, sb.toString(), StandardCharsets.UTF_8); + } + + @Override + public void close() { + } + + private static class Entry { + String name; + int votes; + + Entry(String name, int votes) { + this.name = name; + this.votes = votes; + } + } +} diff --git a/velocity/src/main/resources/config.properties b/velocity/src/main/resources/config.properties new file mode 100644 index 0000000..8c6a691 --- /dev/null +++ b/velocity/src/main/resources/config.properties @@ -0,0 +1,14 @@ +# VoteNetwork - Configuration (Velocity) + +# Stockage des votes en attente (joueurs hors ligne au moment du vote) : +# mysql : recommande pour un vrai reseau (proxy + plusieurs serveurs). Partage entre toutes les machines. +# yaml : fichier local pending-votes.yml (dossier du plugin Velocity). Utile en solo/test, mais N'EST PAS +# partage automatiquement avec le pending-votes.yml des serveurs Spigot si vous etes en reseau. +storage.type=mysql + +mysql.host=127.0.0.1 +mysql.port=3306 +mysql.database=votenetwork +mysql.user=root +mysql.password=changeme +mysql.pool-size=5