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 <noreply@anthropic.com>
This commit is contained in:
SarTron-NorthBlue
2026-07-12 13:12:32 +04:00
co-authored by Claude Sonnet 5
commit 8c69a0dc9a
28 changed files with 1893 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
target/
dependency-reduced-pom.xml
.idea/
*.iml
.vscode/
.DS_Store
+170
View File
@@ -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 <pseudo>` (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 <pseudo>`.
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 <pseudo>` | 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.
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>fr.northblue</groupId>
<artifactId>northblue-vote-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>velocity</module>
<module>spigot</module>
</modules>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<hikaricp.version>5.1.0</hikaricp.version>
<mysql.version>8.0.33</mysql.version>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
<repository>
<id>sonatype</id>
<url>https://oss.sonatype.org/content/groups/public/</url>
</repository>
</repositories>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
+90
View File
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fr.northblue</groupId>
<artifactId>northblue-vote-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>northblue-vote-spigot</artifactId>
<packaging>jar</packaging>
<!--
Compiled against Spigot 1.18.2 API on purpose: the Bukkit API is additive
across versions, so a plugin built against an older API version keeps
loading fine on newer server jars (1.19 -> 1.21.x and beyond) as long as
it only touches stable, non-removed API surface (which this plugin does).
-->
<dependencies>
<dependency>
<groupId>org.spigotmc</groupId>
<artifactId>spigot-api</artifactId>
<version>1.18.2-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>${hikaricp.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.version}</version>
</dependency>
</dependencies>
<repositories>
<repository>
<id>spigotmc-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
</repository>
<repository>
<id>sonatype</id>
<url>https://oss.sonatype.org/content/groups/public/</url>
</repository>
</repositories>
<build>
<finalName>VoteNetwork-Spigot</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<relocations>
<relocation>
<pattern>com.zaxxer.hikari</pattern>
<shadedPattern>fr.northblue.vote.libs.hikari</shadedPattern>
</relocation>
<relocation>
<pattern>com.mysql</pattern>
<shadedPattern>fr.northblue.vote.libs.mysql</shadedPattern>
</relocation>
</relocations>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
}
@@ -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;
}
}
@@ -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<String> getVoteRewardCommands() {
return cfg().getStringList("rewards.vote-commands");
}
public int getVotePartyVotesRequired() {
return cfg().getInt("voteparty.votes-requis", 100);
}
public List<String> 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<String> colorizeList(List<String> input) {
return input.stream().map(this::colorize).collect(Collectors.toList());
}
}
@@ -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<NorthBlueVoteAPI> 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<Integer> callback);
static NorthBlueVoteAPI get() {
return NorthBlueVoteAPIHolder.INSTANCE;
}
}
@@ -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;
}
}
@@ -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<Integer> 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));
}
)
);
}
}
@@ -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)));
}
}
@@ -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 <stop|start|testdb>");
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 <stop|start|testdb>");
}
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);
}
})
)
);
}
}
@@ -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);
}
}
@@ -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<Boolean, String> 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<Exception> 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<Exception> 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();
}
}
}
@@ -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<Exception> onError);
/** Lit le compteur de votes en attente sans le modifier (utilise par l'API). */
void peek(UUID uuid, IntConsumer onResult, Consumer<Exception> onError);
/** Teste que le stockage est joignable/fonctionnel, pour /vote testdb. */
void testConnection(BiConsumer<Boolean, String> callback);
void close();
}
@@ -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<Exception> 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<Exception> 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<Boolean, String> 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() {
}
}
@@ -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();
}
}
+44
View File
@@ -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."
+20
View File
@@ -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 <stop|start|testdb>
permission: votenetwork.admin
permissions:
votenetwork.admin:
description: Autorise l'utilisation de /vote stop|start|testdb.
default: op
+93
View File
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fr.northblue</groupId>
<artifactId>northblue-vote-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>northblue-vote-velocity</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>com.velocitypowered</groupId>
<artifactId>velocity-api</artifactId>
<version>3.3.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>${hikaricp.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
<scope>provided</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
</repositories>
<build>
<finalName>VoteNetwork-Velocity</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>com.velocitypowered</groupId>
<artifactId>velocity-api</artifactId>
<version>3.3.0-SNAPSHOT</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<relocations>
<relocation>
<pattern>com.zaxxer.hikari</pattern>
<shadedPattern>fr.northblue.vote.libs.hikari</shadedPattern>
</relocation>
<relocation>
<pattern>com.mysql</pattern>
<shadedPattern>fr.northblue.vote.libs.mysql</shadedPattern>
</relocation>
</relocations>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -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<UUID> 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);
}
}
@@ -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<Void> 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();
}
}
}
@@ -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();
}
}
}
@@ -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<Void> addPendingVote(UUID uuid, String playerName);
void close();
}
@@ -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 <pseudo>"));
return;
}
String playerName = args[1];
Optional<Player> onlinePlayer = proxy.getPlayer(playerName);
if (onlinePlayer.isPresent()) {
Player player = onlinePlayer.get();
Optional<ServerConnection> 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;
}
}
@@ -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;
}
}
@@ -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<Void> addPendingVote(UUID uuid, String playerName) {
return CompletableFuture.runAsync(() -> {
lock.lock();
try {
Map<UUID, Entry> 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<UUID, Entry> load() throws IOException {
Map<UUID, Entry> 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<UUID, Entry> 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<UUID, Entry> data) throws IOException {
StringBuilder sb = new StringBuilder("players:\n");
for (Map.Entry<UUID, 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;
}
}
}
@@ -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