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
+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