19 Commits
Author SHA1 Message Date
SarTron-NorthBlueandClaude Sonnet 5 6b20a6e342 Ajoute voteparty.broadcast.every pour limiter le spam de progression
CI / build (push) Successful in 12m14s
Le message de progression VoteParty s'affichait a chaque vote. Ajoute
une option every (defaut 10) pour n'annoncer que tous les N votes, plus
systematiquement au dernier vote avant le declenchement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 17:26:13 +04:00
SarTron-NorthBlueandClaude Sonnet 5 cdc692d6d2 Fix : %player% jamais remplace dans les commandes VoteParty
CI / build (push) Successful in 12m41s
Les commandes voteparty.commands s'executaient une seule fois cote
console sans substitution de %player%, ce qui envoyait le placeholder
brut aux plugins de recompense (ex. "excellentcrates key give %player%
epic 5" -> "Joueur introuvable"). Les commandes contenant %player% sont
maintenant executees une fois par joueur en ligne ; les commandes sans
placeholder (deja globales, ex. "goldencrates broadcast_give ...")
gardent leur comportement precedent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:23:46 +04:00
SarTron-NorthBlueandClaude Sonnet 5 0a470339f4 chore: push de test pour verifier le webhook Discord
CI / build (push) Successful in 12m5s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 08:23:52 +04:00
SarTron-NorthBlueandClaude Sonnet 5 4aa1a25c54 Ajoute le menu principal /votemenu (profil, reclamation, administration) - v1.0.2
CI / build (push) Successful in 11m50s
Trois nouveaux ecrans GUI navigables depuis /votemenu :
- Menu principal : tete du joueur + progression VoteParty, boutons vers
  le menu de vote, la reclamation et (ops) l'administration.
- Menu de reclamation : equivalent visuel de /claim, affiche le nombre
  de votes en attente (resolu en async) et permet de les reclamer d'un clic.
- Menu d'administration (permission votenetwork.admin) : bascule le mode
  maintenance sans passer par la commande texte.

Extraction de la logique de reclamation dans ClaimAction, partagee entre
/claim et le menu visuel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 13:50:43 +04:00
SarTron-NorthBlueandClaude Sonnet 5 964f375841 Ajoute le GUI VoteParty avec liens de vote cliquables (v1.0.1)
CI / build (push) Successful in 12m13s
Nouvelle commande /votegui : affiche la progression du VoteParty et un
item par site de vote configure. Un clic ferme le GUI et envoie un
message chat cliquable vers l'URL du site (Bukkit ne permet pas
d'ouvrir un navigateur directement depuis un clic d'inventaire).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 13:26:53 +04:00
SarTron-NorthBlueandClaude Sonnet 5 35a8425254 Fix CI : downgrade upload-artifact v4 -> v3 (incompatible avec Gitea)
CI / build (push) Successful in 12m9s
Le build et les tests passaient deja (Setup JDK, checkout, mvn verify
tous verts). Seul l'upload d'artefact echouait : upload-artifact@v4
utilise la nouvelle API @actions/artifact v2, pas encore supportee par
le backend Gitea Actions (GHESNotSupportedError). v3 utilise l'ancienne
API, compatible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 09:24:23 +04:00
SarTron-NorthBlueandClaude Sonnet 5 5a7aec5344 Fix CI : revient sur ubuntu-latest, installe Maven via apt-get
CI / build (push) Failing after 12m37s
Le container maven:3.9-eclipse-temurin-17 n'a pas Node.js, requis par
les actions JS (checkout, cache, upload-artifact) -> echec au premier
step. Retour au runner par defaut + installation explicite de Maven
(actions/setup-java installe le JDK mais jamais Maven).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 09:10:43 +04:00
SarTron-NorthBlueandClaude Sonnet 5 686cb4de26 Fix CI : mvn introuvable sur le runner (image sans Maven preinstalle)
CI / build (push) Failing after 1m8s
actions/setup-java installe le JDK mais pas Maven. Ca marche sur les
runners GitHub-hostes (Maven deja present dans l'image), mais pas sur
ce runner self-hoste (act) qui utilise une image minimale. Bascule
sur l'image Docker officielle maven:3.9-eclipse-temurin-17, qui a
Maven + JDK 17 preinstalles, peu importe le runner sous-jacent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 09:08:16 +04:00
SarTron-NorthBlueandClaude Sonnet 5 70d41af369 ci: declenche manuellement le pipeline pour verification
CI / build (push) Failing after 4m49s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 09:02:11 +04:00
SarTron-NorthBlueandClaude Sonnet 5 3a84f16f23 Ajoute le pipeline CI Gitea Actions (CP11 - demarche DevOps)
CI / build (push) Failing after 5m24s
mvn clean verify (compile + tests) a chaque push/PR sur main, upload
des jars en artefact si succes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 09:18:32 +04:00
SarTron-NorthBlueandClaude Sonnet 5 5abe49fdd0 Ajoute des tests unitaires JUnit 5 (CP9 - plans de tests)
14 tests couvrant VoteConfig (Spigot + Velocity) et YamlPendingVoteStore
(Spigot + Velocity) : valeurs par defaut, portee per-server/global,
merge de config sans ecraser l'existant, incrementation/reset des
votes en attente, colorisation des messages.

Mockito pour VoteConfig/YamlPendingVoteStore cote Spigot (mock de
VoteNetworkSpigot, pas besoin de serveur Bukkit reel). Cote Velocity,
tests directs sans framework (classes deja decouplees de Velocity-API).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 09:14:12 +04:00
SarTron-NorthBlueandClaude Sonnet 5 d5f6e0cfb3 Ajoute un script de release Gitea (scripts/release.sh)
Sur demande uniquement (pas de trigger auto par commit) : build les 2
jars, zip, cree le tag/release via l'API Gitea et y attache le zip.
Token passe en variable d'env GITEA_TOKEN, jamais stocke dans le repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:24:13 +04:00
SarTron-NorthBlueandClaude Sonnet 5 0ca99c7e2c Ignore dist/ (zip de distribution genere localement)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:06:20 +04:00
SarTron-NorthBlueandClaude Sonnet 5 ff1afd58fe Ajoute la description SpigotMC (BBCode) pour la publication de la ressource
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:56:47 +04:00
SarTron-NorthBlueandClaude Sonnet 5 62d4d30530 Fix : merge auto de config.properties ne copiait pas les commentaires
L'ancien merge lisait les defauts via Properties.load(), qui jette
tous les commentaires : les nouvelles cles ajoutees automatiquement
(ex: pending-votes.scope) arrivaient nues, sans explication, sous un
header generique confus.

Le merge copie desormais le bloc texte entier (commentaires inclus)
du config.properties par defaut, verbatim, pour chaque cle manquante.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 14:37:57 +04:00
SarTron-NorthBlueandClaude Sonnet 5 261e220d1a Ajoute la portee per-server pour les votes en attente + credits/licence
pending-votes.scope (config.properties Velocity) = global (defaut,
comportement historique) ou per-server. En per-server, un vote hors
ligne incremente le compteur de chaque serveur de direct-vote.servers
independamment (nouvelle table nb_votes_attente_serveur) ; /claim sur
un serveur ne remet a 0 que ce serveur-la. Necessite server-name
(config.yml) et storage.pending-votes-scope assortis cote Spigot.

Ajoute LICENSE (MIT) et credit Sar_Tron (plugin.yml, @Plugin Velocity,
pom parent, README) en vue de la publication publique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 14:33:22 +04:00
SarTron-NorthBlueandClaude Sonnet 5 74b2914529 Ajoute un expansion PlaceholderAPI (%votenetwork_...%)
Le scoreboard du joueur affichait %current%/%required% en clair car
seule une API Java existait, pas de placeholders texte. Enregistre
automatiquement si PlaceholderAPI est present (softdepend) :
voteparty_current, voteparty_required, voteparty_remaining,
maintenance, pending (rafraichi en arriere-plan, async).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 14:26:44 +04:00
SarTron-NorthBlueandClaude Sonnet 5 a76c641b7d Merge auto des nouvelles options de config au demarrage
Une mise a jour du plugin qui ajoute des cles de config n'obligeait
pas a editer config.yml/config.properties a la main : les valeurs
par defaut restaient invisibles pour l'admin. Desormais, au demarrage,
les cles manquantes sont ajoutees dans le fichier existant sans
toucher aux valeurs deja personnalisees.

Spigot : merge via YamlConfiguration (perd les commentaires au
prochain rewrite, limitation Bukkit). Velocity : simple ajout de
lignes en fin de fichier, commentaires existants preserves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 14:23:58 +04:00
SarTron-NorthBlueandClaude Sonnet 5 ffbeb2de66 doc: precise que la meme BDD MySQL doit etre utilisee partout
Clarifie que storage.type (mysql/yaml) doit etre identique sur
Velocity et tous les serveurs Spigot avec les memes identifiants,
et que le VoteParty reste toujours local par serveur peu importe
ce choix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 14:21:30 +04:00
39 changed files with 1930 additions and 67 deletions
+38
View File
@@ -0,0 +1,38 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
cache: maven
- name: Install Maven
run: |
which mvn || (sudo apt-get update && sudo apt-get install -y maven)
- name: Build and test
run: mvn -B clean verify
- name: Upload jars
if: success()
uses: actions/upload-artifact@v3
with:
name: votenetwork-jars
path: |
velocity/target/VoteNetwork-Velocity.jar
spigot/target/VoteNetwork-Spigot.jar
retention-days: 14
+2
View File
@@ -1,6 +1,8 @@
target/ target/
dependency-reduced-pom.xml dependency-reduced-pom.xml
dist/
.idea/ .idea/
*.iml *.iml
.vscode/ .vscode/
.DS_Store .DS_Store
MSP3/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sar_Tron
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+57 -5
View File
@@ -1,5 +1,7 @@
# VoteNetwork # VoteNetwork
Par **Sar_Tron**, sous licence [MIT](LICENSE).
Systeme de vote reseau en deux modules, pour une architecture **proxy Velocity + serveurs Spigot/Paper** (modes A, B, C, ...). 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. - `velocity/` — module proxy. Recoit `votenetwork vote <pseudo>` (console uniquement) et route le vote en direct ou le met en attente.
@@ -32,6 +34,8 @@ Compatible **Java 17**, compile contre l'API Spigot **1.18.2** (additive, donc c
- Serveur : `plugins/votenetwork/config.yml` - Serveur : `plugins/votenetwork/config.yml`
4. Redémarrer. 4. Redémarrer.
> **⚠️ Même base de données partout.** `mysql.host` / `mysql.port` / `mysql.database` / `mysql.user` / `mysql.password` doivent être **identiques** dans `config.properties` (Velocity) et dans **chaque** `config.yml` (tous les serveurs Spigot : A, B, C...). C'est cette base commune qui permet à un joueur de voter hors ligne, puis de récupérer ses votes avec `/claim` sur n'importe lequel de vos serveurs. Des bases différentes = `/claim` ne retrouvera jamais les votes stockés par Velocity. Voir aussi [Stockage : MySQL ou YAML](#stockage--mysql-ou-yaml).
## Build depuis les sources ## Build depuis les sources
``` ```
@@ -40,6 +44,14 @@ mvn clean package
Jars produits dans `velocity/target/` et `spigot/target/`. Jars produits dans `velocity/target/` et `spigot/target/`.
### Publier une release (Gitea)
```
GITEA_TOKEN=xxxx ./scripts/release.sh 1.0.1 "Description de la release"
```
Build les 2 jars, les zip dans `dist/VoteNetwork.zip`, crée le tag/release sur Gitea et y attache le zip. Nécessite un token Gitea (Paramètres → Applications → Générer un nouveau jeton, scope `repo`), passé uniquement en variable d'environnement — jamais commité.
## Configuration ## Configuration
Chaque composant a son propre fichier, tous deux dans un dossier **`votenetwork`** : Chaque composant a son propre fichier, tous deux dans un dossier **`votenetwork`** :
@@ -97,10 +109,10 @@ messages:
## Stockage : MySQL ou YAML ## Stockage : MySQL ou YAML
Chaque composant choisit indépendamment son stockage via `storage.type` : Chaque composant choisit son stockage via `storage.type`, **mais ce choix doit être le même partout** (Velocity + tous les serveurs Spigot), avec les mêmes identifiants de connexion :
- **`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. - **`mysql`** (recommandé, seul mode fiable pour un vrai réseau) : Velocity et tous les serveurs Spigot pointent sur **la même base** (même `host`/`database`/`user`/`password`). La table `nb_votes_attente` est créée automatiquement au démarrage si absente. Un vote stocké par Velocity (joueur hors ligne) est donc visible par `/claim` sur n'importe quel serveur.
- **`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é. - **`yaml`** : fichier local `pending-votes.yml` dans le dossier de **chaque** plugin (un par proxy, un par serveur). **Pas synchronisé automatiquement** entre eux. Si Velocity est en `yaml` et qu'un serveur Spigot vote pour lui-même en `mysql` (ou l'inverse), `/claim` ne trouvera jamais les votes stockés côté proxy. N'utilisez ce mode qu'en solo/test, ou si tous les dossiers de plugins partagent le même disque (montage réseau, symlink) — sinon utilisez `mysql` partout.
Testez la connexion/l'accès au stockage configuré côté Spigot avec : Testez la connexion/l'accès au stockage configuré côté Spigot avec :
@@ -108,6 +120,21 @@ Testez la connexion/l'accès au stockage configuré côté Spigot avec :
/vote testdb /vote testdb
``` ```
### Portée des votes en attente : partagée (`global`) ou par serveur (`per-server`)
En mode `mysql` uniquement, `pending-votes.scope` (dans `config.properties` côté Velocity) contrôle si le compteur de votes en attente est **partagé** entre tous les serveurs ou **indépendant par serveur** :
- **`global`** (par défaut) : un seul compteur par joueur. Un `/claim` sur n'importe quel serveur le remet à 0 **partout** à la fois.
- **`per-server`** : chaque serveur listé dans `direct-vote.servers` a son propre compteur. Un vote hors ligne incrémente le compteur de **chaque** serveur de la liste. Un `/claim` sur le serveur 1 ne remet à 0 **que** le serveur 1 — le joueur garde ses votes en attente sur le serveur 2 et peut les réclamer séparément là-bas.
> Exemple : le joueur vote 2 fois hors ligne → 2 votes en attente sur *chaque* serveur. `/claim` sur le serveur 1 → 2 récompenses, serveur 1 repasse à 0, serveur 2 reste à 2. S'il revote une fois → serveur 1 = 1, serveur 2 = 3.
Pour activer `per-server`, il faut **les deux** :
1. `pending-votes.scope=per-server` + `direct-vote.servers` rempli dans `config.properties` (Velocity).
2. `storage.pending-votes-scope: per-server` + `server-name: "gen1"` (nom exact tiré de `direct-vote.servers`) dans le `config.yml` de **chaque** serveur Spigot concerné.
Si ces réglages ne correspondent pas entre le proxy et un serveur (scope différent, ou `server-name` absent/mal orthographié), ce serveur ne retrouvera jamais les votes stockés par Velocity.
## Fonctionnement du vote ## Fonctionnement du vote
1. Un site de vote appelle la console du proxy : `votenetwork vote <pseudo>`. 1. Un site de vote appelle la console du proxy : `votenetwork vote <pseudo>`.
@@ -129,7 +156,7 @@ Testez la connexion/l'accès au stockage configuré côté Spigot avec :
## VoteParty ## VoteParty
- Compteur indépendant **par serveur**, persistant localement (`plugins/votenetwork/voteparty.yml`), survit aux redémarrages. - Compteur indépendant **par serveur**, toujours persistant localement dans `plugins/votenetwork/voteparty.yml` (indépendant de `storage.type`, qui ne concerne que les votes en attente), survit aux redémarrages.
- `voteparty.votes-requis` définit le seuil de déclenchement. - `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). - `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`). - Messages de progression et de déclenchement personnalisables (`voteparty.broadcast`).
@@ -155,16 +182,40 @@ api.getPendingVotes(player.getUniqueId(), pending -> {
- `getVotePartyCurrentVotes()` / `getVotePartyRequiredVotes()` : lecture directe en mémoire, thread principal uniquement. - `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`. - `getPendingVotes(...)` : lecture asynchrone du stockage configuré (MySQL ou YAML), ne consomme pas les votes contrairement à `/claim`.
### Placeholders PlaceholderAPI (scoreboard/GUI sans coder de plugin)
Si [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) est installé, VoteNetwork enregistre automatiquement ses propres placeholders (aucune config nécessaire) :
| Placeholder | Valeur |
|---|---|
| `%votenetwork_voteparty_current%` | Votes cumulés sur ce serveur pour le VoteParty |
| `%votenetwork_voteparty_required%` | Objectif VoteParty (`voteparty.votes-requis`) |
| `%votenetwork_voteparty_remaining%` | Votes restants avant déclenchement |
| `%votenetwork_maintenance%` | `true`/`false` |
| `%votenetwork_pending%` | Votes en attente du joueur (rafraîchi en arrière-plan, pas de lag) |
Utilisez ces placeholders directement dans la config de votre plugin de scoreboard/tab/GUI habituel (FeatherBoard, TAB, DeluxeMenus, ...) — **pas** `%current%`/`%required%` bruts, qui n'existent pas côté PlaceholderAPI et doivent être définis dans la config du plugin de scoreboard lui-même.
## Structure de la base de données ## Structure de la base de données
Créée automatiquement si absente (mode `mysql`) : Créées automatiquement si absentes (mode `mysql`), les deux tables coexistent toujours — seule celle correspondant à `pending-votes.scope` est utilisée :
```sql ```sql
-- scope = global (par defaut)
CREATE TABLE IF NOT EXISTS nb_votes_attente ( CREATE TABLE IF NOT EXISTS nb_votes_attente (
player_uuid VARCHAR(36) NOT NULL PRIMARY KEY, player_uuid VARCHAR(36) NOT NULL PRIMARY KEY,
player_name VARCHAR(16) NOT NULL, player_name VARCHAR(16) NOT NULL,
nombre_votes INT DEFAULT 0 nombre_votes INT DEFAULT 0
); );
-- scope = per-server
CREATE TABLE IF NOT EXISTS nb_votes_attente_serveur (
player_uuid VARCHAR(36) NOT NULL,
server_name VARCHAR(64) NOT NULL,
player_name VARCHAR(16) NOT NULL,
nombre_votes INT DEFAULT 0,
PRIMARY KEY (player_uuid, server_name)
);
``` ```
--- ---
@@ -174,3 +225,4 @@ CREATE TABLE IF NOT EXISTS nb_votes_attente (
- 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. - 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. - 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. - 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.
- **Mise à jour de config** : à chaque démarrage, les nouvelles options apportées par une mise à jour du plugin (absentes de votre `config.yml`/`config.properties` existant) sont ajoutées automatiquement, **sans jamais toucher aux valeurs que vous avez déjà personnalisées**. Côté Spigot, la réécriture du `config.yml` fait perdre les commentaires (limitation de l'API Bukkit) ; côté Velocity, les nouvelles clés sont simplement ajoutées en bas du `config.properties`, les commentaires existants sont préservés.
+78
View File
@@ -0,0 +1,78 @@
[CENTER][SIZE=5][B]VoteNetwork[/B][/SIZE]
Systeme de vote reseau complet pour Velocity + Spigot/Paper
Par [B]Sar_Tron[/B] — Licence MIT — Code source : https://gitea.louconnect.fr/Sar_Tron/VoteNetwork[/CENTER]
[CENTER][B]⚠ Ce plugin necessite les DEUX modules du zip (proxy Velocity + serveurs Spigot) et une base MySQL. Il ne fonctionne PAS en le mettant seul sur un serveur solo sans proxy.[/B][/CENTER]
[HR][/HR]
[SIZE=4][B]Pourquoi VoteNetwork ?[/B][/SIZE]
La plupart des plugins de vote forcent un choix : soit le joueur doit etre connecte au bon moment, soit tout passe par une base de donnees meme quand ce n'est pas necessaire. VoteNetwork fait les deux intelligemment :
[LIST]
[*]Joueur connecte -> recompense [B]instantanee[/B] sur son serveur actuel, sans toucher a la base de donnees.
[*]Joueur deconnecte -> vote stocke automatiquement, recuperable plus tard via [B]/claim[/B].
[*]VoteParty [B]independant par serveur[/B] (chaque mode de jeu a son propre objectif et ses propres recompenses).
[*]Mode maintenance pour bloquer /claim pendant un reset sans desinstaller le plugin.
[/LIST]
[HR][/HR]
[SIZE=4][B]Fonctionnalites[/B][/SIZE]
[LIST]
[*][B]Vote direct en jeu[/B] via Plugin Messaging (canal custom, aucune latence de base de donnees si le joueur est en ligne).
[*][B]Votes en attente[/B] stockes en MySQL (partage reseau) ou en YAML local, au choix.
[*][B]Portee configurable[/B] des votes en attente : un compteur partage entre tous les serveurs, OU un compteur independant par serveur (le joueur peut claim ses votes du serveur A sans toucher a ceux du serveur B).
[*][B]VoteParty local[/B] par serveur : seuil, commandes et messages 100% personnalisables, persistant au redemarrage.
[*][B]Liste de commandes[/B] entierement configurable pour chaque vote et pour le declenchement du VoteParty.
[*][B]Message broadcast[/B] optionnel a chaque vote/claim, personnalisable.
[*][B]Mode maintenance[/B] (/vote stop|start) pour bloquer /claim proprement pendant un reset.
[*][B]Commande de diagnostic[/B] (/vote testdb) pour verifier la connexion MySQL ou l'acces au fichier YAML sans redemarrer.
[*][B]API Java publique[/B] (+ enregistrement ServicesManager Bukkit) pour lire l'etat du vote depuis un autre plugin.
[*][B]Support PlaceholderAPI[/B] natif : %votenetwork_voteparty_current%, %votenetwork_voteparty_required%, %votenetwork_voteparty_remaining%, %votenetwork_maintenance%, %votenetwork_pending% — pour scoreboard, tab, GUI, sans coder une ligne.
[*][B]Merge automatique de config[/B] : les nouvelles options ajoutees par une mise a jour n'ecrasent jamais tes reglages existants.
[*]Compatible [B]Java 17[/B], du [B]1.18[/B] aux versions les plus recentes (API additive, testee en production).
[/LIST]
[HR][/HR]
[SIZE=4][B]Installation[/B][/SIZE]
[LIST=1]
[*][B]VoteNetwork-Velocity.jar[/B] -> dossier plugins/ du proxy Velocity.
[*][B]VoteNetwork-Spigot.jar[/B] -> dossier plugins/ de CHAQUE serveur de jeu (aucune limite de nombre).
[*]Demarrer une fois pour generer les configs, puis editer :
[LIST]
[*]Proxy : plugins/votenetwork/config.properties
[*]Serveur : plugins/votenetwork/config.yml
[/LIST]
[*]Renseigner [B]les memes identifiants MySQL[/B] partout (proxy + tous les serveurs) — c'est cette base commune qui permet a un joueur de voter hors ligne puis de recuperer ses votes n'importe ou.
[*]Redemarrer. La table est creee automatiquement, aucun script SQL a lancer a la main.
[/LIST]
[HR][/HR]
[SIZE=4][B]Commandes & permissions[/B][/SIZE]
[TABLE]
[TR][TD][B]Commande[/B][/TD][TD][B]Ou[/B][/TD][TD][B]Qui[/B][/TD][TD][B]Description[/B][/TD][/TR]
[TR][TD]votenetwork vote <pseudo>[/TD][TD]Velocity[/TD][TD]Console uniquement[/TD][TD]Enregistre un vote pour le joueur[/TD][/TR]
[TR][TD]/claim[/TD][TD]Spigot[/TD][TD]Joueurs[/TD][TD]Recupere les votes en attente[/TD][/TR]
[TR][TD]/vote stop|start[/TD][TD]Spigot[/TD][TD]votenetwork.admin (op)[/TD][TD]Bascule le mode maintenance[/TD][/TR]
[TR][TD]/vote testdb[/TD][TD]Spigot[/TD][TD]votenetwork.admin (op)[/TD][TD]Teste le stockage configure[/TD][/TR]
[/TABLE]
[HR][/HR]
[SIZE=4][B]Pre-requis[/B][/SIZE]
[LIST]
[*]Un proxy [B]Velocity[/B] + au moins un serveur [B]Spigot/Paper 1.18+[/B].
[*]Une base [B]MySQL[/B] accessible depuis le proxy ET tous les serveurs (ou stockage YAML local pour un usage solo/test uniquement).
[*][B]Java 17[/B] minimum.
[*][I]Optionnel[/I] : PlaceholderAPI pour les placeholders de scoreboard.
[/LIST]
[HR][/HR]
[CENTER]Code source complet et documentation detaillee (config avancee, portee per-server, API) disponibles sur le depot Git : [B]https://gitea.louconnect.fr/Sar_Tron/VoteNetwork[/B]
Bugs / suggestions -> ouvrez une issue sur le depot.
Licence MIT — libre d'utilisation, modification et redistribution avec attribution.[/CENTER]
+28 -1
View File
@@ -6,7 +6,7 @@
<groupId>fr.votenetwork</groupId> <groupId>fr.votenetwork</groupId>
<artifactId>votenetwork-parent</artifactId> <artifactId>votenetwork-parent</artifactId>
<version>1.0.0</version> <version>1.0.2</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<modules> <modules>
@@ -14,12 +14,31 @@
<module>spigot</module> <module>spigot</module>
</modules> </modules>
<name>VoteNetwork</name>
<description>Systeme de vote reseau Velocity + Spigot/Paper</description>
<url>https://gitea.louconnect.fr/Sar_Tron/VoteNetwork</url>
<licenses>
<license>
<name>MIT License</name>
<url>https://opensource.org/licenses/MIT</url>
</license>
</licenses>
<developers>
<developer>
<name>Sar_Tron</name>
</developer>
</developers>
<properties> <properties>
<maven.compiler.source>17</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<hikaricp.version>5.1.0</hikaricp.version> <hikaricp.version>5.1.0</hikaricp.version>
<mysql.version>8.0.33</mysql.version> <mysql.version>8.0.33</mysql.version>
<junit.version>5.10.2</junit.version>
<mockito.version>5.11.0</mockito.version>
</properties> </properties>
<repositories> <repositories>
@@ -46,6 +65,14 @@
<artifactId>maven-shade-plugin</artifactId> <artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version> <version>3.5.1</version>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-Dnet.bytebuddy.experimental=true</argLine>
</configuration>
</plugin>
</plugins> </plugins>
</pluginManagement> </pluginManagement>
</build> </build>
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Build les 2 jars, zip, puis cree une release Gitea avec le zip attache.
#
# Usage :
# GITEA_TOKEN=xxxx ./scripts/release.sh 1.0.1 "Description de la release"
#
# Le token doit avoir le scope "repo" (Parametres -> Applications -> Generer un nouveau jeton).
# Ne JAMAIS commit le token. Passe-le uniquement en variable d'environnement.
set -euo pipefail
VERSION="${1:?Usage: release.sh <version> [notes]}"
NOTES="${2:-Release ${VERSION}}"
GITEA_HOST="https://gitea.louconnect.fr"
OWNER="Sar_Tron"
REPO="VoteNetwork"
TAG="v${VERSION}"
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "Erreur : variable GITEA_TOKEN non definie." >&2
echo "Cree un token dans Gitea (Parametres > Applications, scope repo) puis :" >&2
echo " GITEA_TOKEN=xxxx ./scripts/release.sh ${VERSION}" >&2
exit 1
fi
cd "$(dirname "$0")/.."
echo "==> Build Maven"
mvn -q clean package
echo "==> Preparation du zip de distribution"
rm -rf dist
mkdir -p dist
cp velocity/target/VoteNetwork-Velocity.jar dist/
cp spigot/target/VoteNetwork-Spigot.jar dist/
if command -v zip >/dev/null 2>&1; then
(cd dist && zip -q VoteNetwork.zip VoteNetwork-Velocity.jar VoteNetwork-Spigot.jar)
else
powershell -Command "Compress-Archive -Path dist/VoteNetwork-Velocity.jar,dist/VoteNetwork-Spigot.jar -DestinationPath dist/VoteNetwork.zip -Force"
fi
echo "==> Creation de la release Gitea ${TAG}"
RELEASE_JSON=$(curl -sf -X POST "${GITEA_HOST}/api/v1/repos/${OWNER}/${REPO}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"VoteNetwork ${VERSION}\",\"body\":$(printf '%s' "$NOTES" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))' 2>/dev/null || printf '"%s"' "$NOTES"),\"draft\":false,\"prerelease\":false}")
RELEASE_ID=$(printf '%s' "$RELEASE_JSON" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
if [ -z "$RELEASE_ID" ]; then
echo "Erreur : creation de la release echouee." >&2
echo "$RELEASE_JSON" >&2
exit 1
fi
echo "==> Upload du zip (release id ${RELEASE_ID})"
curl -sf -X POST "${GITEA_HOST}/api/v1/repos/${OWNER}/${REPO}/releases/${RELEASE_ID}/assets?name=VoteNetwork.zip" \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@dist/VoteNetwork.zip" > /dev/null
echo "==> Fait : ${GITEA_HOST}/${OWNER}/${REPO}/releases/tag/${TAG}"
+23 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>fr.votenetwork</groupId> <groupId>fr.votenetwork</groupId>
<artifactId>votenetwork-parent</artifactId> <artifactId>votenetwork-parent</artifactId>
<version>1.0.0</version> <version>1.0.2</version>
</parent> </parent>
<artifactId>votenetwork-spigot</artifactId> <artifactId>votenetwork-spigot</artifactId>
@@ -36,6 +36,24 @@
<artifactId>mysql-connector-j</artifactId> <artifactId>mysql-connector-j</artifactId>
<version>${mysql.version}</version> <version>${mysql.version}</version>
</dependency> </dependency>
<dependency>
<groupId>me.clip</groupId>
<artifactId>placeholderapi</artifactId>
<version>2.11.6</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<repositories> <repositories>
@@ -47,6 +65,10 @@
<id>sonatype</id> <id>sonatype</id>
<url>https://oss.sonatype.org/content/groups/public/</url> <url>https://oss.sonatype.org/content/groups/public/</url>
</repository> </repository>
<repository>
<id>placeholderapi</id>
<url>https://repo.extendedclip.com/content/repositories/placeholderapi/</url>
</repository>
</repositories> </repositories>
<build> <build>
@@ -0,0 +1,54 @@
package fr.votenetwork.spigot;
import fr.votenetwork.spigot.storage.PendingVoteStore;
import fr.votenetwork.spigot.voteparty.VoteParty;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
/**
* Reclame les votes en attente d'un joueur et distribue les recompenses. Partagee entre la
* commande /claim (texte) et le menu de reclamation visuel (/votemenu).
*/
public final class ClaimAction {
private ClaimAction() {
}
public static void claim(VoteNetworkSpigot plugin, VoteConfig config, PendingVoteStore pendingVoteStore,
VoteParty voteParty, Player player) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
pendingVoteStore.fetchAndClear(
player.getUniqueId(),
pendingVotes -> Bukkit.getScheduler().runTask(plugin, () -> apply(plugin, config, voteParty, player, pendingVotes)),
exception -> Bukkit.getScheduler().runTask(plugin, () -> {
plugin.getLogger().severe("Erreur reclamation pour " + player.getName() + ": " + exception.getMessage());
player.sendMessage(config.getMessageMaintenance());
})
)
);
}
private static void apply(VoteNetworkSpigot plugin, VoteConfig config, VoteParty voteParty, 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)));
if (config.isVoteBroadcastEnabled()) {
Bukkit.broadcastMessage(config.getVoteBroadcastMessage()
.replace("%player%", player.getName())
.replace("%amount%", String.valueOf(pendingVotes)));
}
}
}
@@ -1,9 +1,14 @@
package fr.votenetwork.spigot; package fr.votenetwork.spigot;
import fr.votenetwork.spigot.gui.VoteSite;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.FileConfiguration;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class VoteConfig { public class VoteConfig {
@@ -30,6 +35,15 @@ public class VoteConfig {
return !"yaml".equalsIgnoreCase(getStorageType()); return !"yaml".equalsIgnoreCase(getStorageType());
} }
/**
* Doit correspondre exactement a pending-votes.scope cote Velocity :
* global : /claim consomme le compteur partage entre tous les serveurs.
* per-server : /claim ne consomme que le compteur de CE serveur (server-name).
*/
public boolean isPerServerScope() {
return "per-server".equalsIgnoreCase(cfg().getString("storage.pending-votes-scope", "global"));
}
public String getMysqlHost() { public String getMysqlHost() {
return cfg().getString("mysql.host", "127.0.0.1"); return cfg().getString("mysql.host", "127.0.0.1");
} }
@@ -54,6 +68,14 @@ public class VoteConfig {
return cfg().getInt("mysql.pool-size", 5); return cfg().getInt("mysql.pool-size", 5);
} }
/**
* Nom de CE serveur, tel que declare cote Velocity dans direct-vote.servers.
* Uniquement necessaire si le proxy utilise pending-votes.scope=per-server.
*/
public String getServerName() {
return cfg().getString("server-name", "");
}
public List<String> getVoteRewardCommands() { public List<String> getVoteRewardCommands() {
return cfg().getStringList("rewards.vote-commands"); return cfg().getStringList("rewards.vote-commands");
} }
@@ -70,6 +92,11 @@ public class VoteConfig {
return cfg().getBoolean("voteparty.broadcast.enabled", true); return cfg().getBoolean("voteparty.broadcast.enabled", true);
} }
/** N'annonce la progression que tous les N votes (1 = a chaque vote). */
public int getVotePartyBroadcastEvery() {
return cfg().getInt("voteparty.broadcast.every", 10);
}
public String getVotePartyProgressMessage() { public String getVotePartyProgressMessage() {
return colorize(cfg().getString("voteparty.broadcast.progress-message", return colorize(cfg().getString("voteparty.broadcast.progress-message",
"&b[Vote] &f%current%&7/&f%required% &fvotes avant le prochain VoteParty !")); "&b[Vote] &f%current%&7/&f%required% &fvotes avant le prochain VoteParty !"));
@@ -124,6 +151,159 @@ public class VoteConfig {
"&cVous n'avez pas la permission d'utiliser cette commande.")); "&cVous n'avez pas la permission d'utiliser cette commande."));
} }
public String getGuiTitle() {
return colorize(cfg().getString("gui.title", "&8VoteNetwork"));
}
public String getGuiProgressItemMaterial() {
return cfg().getString("gui.progress-item.material", "NETHER_STAR");
}
public String getGuiProgressItemName() {
return colorize(cfg().getString("gui.progress-item.name", "&b&lVoteParty"));
}
public List<String> getGuiProgressItemLore() {
return colorizeList(cfg().getStringList("gui.progress-item.lore"));
}
public String getGuiClickMessage() {
return colorize(cfg().getString("gui.click-message", "&a[Vote] Clique sur le lien pour voter :"));
}
/**
* Parses gui.vote-sites (a YAML list of maps: name/material/url/lore) into VoteSite
* objects, one item per configured vote site.
*/
public List<VoteSite> getVoteSites() {
List<VoteSite> sites = new ArrayList<>();
for (Map<?, ?> raw : cfg().getMapList("gui.vote-sites")) {
String name = raw.containsKey("name") ? String.valueOf(raw.get("name")) : "&aVoter";
String materialName = raw.containsKey("material") ? String.valueOf(raw.get("material")) : "PAPER";
String url = raw.containsKey("url") ? String.valueOf(raw.get("url")) : "";
List<String> lore = new ArrayList<>();
Object loreObj = raw.get("lore");
if (loreObj instanceof List<?> loreList) {
for (Object line : loreList) {
lore.add(String.valueOf(line));
}
}
Material material;
try {
material = Material.valueOf(materialName.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
material = Material.PAPER;
}
sites.add(new VoteSite(colorize(name), material, url, colorizeList(lore)));
}
return sites;
}
// --- Menu principal (/votemenu) ---
public String getMainMenuTitle() {
return colorize(cfg().getString("gui.main-menu.title", "&8VoteNetwork - Menu"));
}
public List<String> getMainMenuHeadLore() {
return colorizeList(cfg().getStringList("gui.main-menu.head-lore"));
}
public String getMainMenuVoteItemMaterial() {
return cfg().getString("gui.main-menu.vote-item.material", "PAPER");
}
public String getMainMenuVoteItemName() {
return colorize(cfg().getString("gui.main-menu.vote-item.name", "&aVoter"));
}
public List<String> getMainMenuVoteItemLore() {
return colorizeList(cfg().getStringList("gui.main-menu.vote-item.lore"));
}
public String getMainMenuClaimItemMaterial() {
return cfg().getString("gui.main-menu.claim-item.material", "CHEST");
}
public String getMainMenuClaimItemName() {
return colorize(cfg().getString("gui.main-menu.claim-item.name", "&eMes votes en attente"));
}
public List<String> getMainMenuClaimItemLore() {
return colorizeList(cfg().getStringList("gui.main-menu.claim-item.lore"));
}
public String getMainMenuAdminItemMaterial() {
return cfg().getString("gui.main-menu.admin-item.material", "REDSTONE");
}
public String getMainMenuAdminItemName() {
return colorize(cfg().getString("gui.main-menu.admin-item.name", "&cAdministration"));
}
public List<String> getMainMenuAdminItemLore() {
return colorizeList(cfg().getStringList("gui.main-menu.admin-item.lore"));
}
// --- Menu de reclamation (/claim visuel) ---
public String getClaimMenuTitle() {
return colorize(cfg().getString("gui.claim-menu.title", "&8VoteNetwork - Reclamer"));
}
public String getClaimMenuLoadingLore() {
return colorize(cfg().getString("gui.claim-menu.loading-lore", "&7Chargement..."));
}
public String getClaimMenuAvailableMaterial() {
return cfg().getString("gui.claim-menu.available.material", "CHEST");
}
public String getClaimMenuAvailableName() {
return colorize(cfg().getString("gui.claim-menu.available.name", "&e%amount% vote(s) en attente"));
}
public List<String> getClaimMenuAvailableLore() {
return colorizeList(cfg().getStringList("gui.claim-menu.available.lore"));
}
public String getClaimMenuEmptyMaterial() {
return cfg().getString("gui.claim-menu.empty.material", "BARRIER");
}
public String getClaimMenuEmptyName() {
return colorize(cfg().getString("gui.claim-menu.empty.name", "&7Aucun vote en attente"));
}
public List<String> getClaimMenuEmptyLore() {
return colorizeList(cfg().getStringList("gui.claim-menu.empty.lore"));
}
// --- Menu d'administration (/votemenu, reserve aux ops) ---
public String getAdminMenuTitle() {
return colorize(cfg().getString("gui.admin-menu.title", "&8VoteNetwork - Administration"));
}
public String getAdminMenuItemMaterial() {
return cfg().getString("gui.admin-menu.item.material", "LEVER");
}
public String getAdminMenuItemNameEnabled() {
return colorize(cfg().getString("gui.admin-menu.item.name-maintenance-on", "&c&lMaintenance : ACTIVEE"));
}
public String getAdminMenuItemNameDisabled() {
return colorize(cfg().getString("gui.admin-menu.item.name-maintenance-off", "&a&lMaintenance : DESACTIVEE"));
}
public List<String> getAdminMenuItemLore() {
return colorizeList(cfg().getStringList("gui.admin-menu.item.lore"));
}
private String colorize(String input) { private String colorize(String input) {
return input == null ? "" : ChatColor.translateAlternateColorCodes('&', input); return input == null ? "" : ChatColor.translateAlternateColorCodes('&', input);
} }
@@ -5,14 +5,25 @@ import fr.votenetwork.spigot.api.VoteNetworkAPIHolder;
import fr.votenetwork.spigot.api.VoteNetworkAPIImpl; import fr.votenetwork.spigot.api.VoteNetworkAPIImpl;
import fr.votenetwork.spigot.command.ClaimCommand; import fr.votenetwork.spigot.command.ClaimCommand;
import fr.votenetwork.spigot.command.VoteAdminCommand; import fr.votenetwork.spigot.command.VoteAdminCommand;
import fr.votenetwork.spigot.command.VoteGuiCommand;
import fr.votenetwork.spigot.command.VoteMenuCommand;
import fr.votenetwork.spigot.gui.VoteMenuListener;
import fr.votenetwork.spigot.gui.VotePartyGuiListener;
import fr.votenetwork.spigot.listener.VoteMessageListener; import fr.votenetwork.spigot.listener.VoteMessageListener;
import fr.votenetwork.spigot.placeholder.VoteNetworkPlaceholders;
import fr.votenetwork.spigot.storage.MySqlPendingVoteStore; import fr.votenetwork.spigot.storage.MySqlPendingVoteStore;
import fr.votenetwork.spigot.storage.PendingVoteStore; import fr.votenetwork.spigot.storage.PendingVoteStore;
import fr.votenetwork.spigot.storage.YamlPendingVoteStore; import fr.votenetwork.spigot.storage.YamlPendingVoteStore;
import fr.votenetwork.spigot.voteparty.VoteParty; import fr.votenetwork.spigot.voteparty.VoteParty;
import org.bukkit.Bukkit;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.ServicePriority; import org.bukkit.plugin.ServicePriority;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
public class VoteNetworkSpigot extends JavaPlugin { public class VoteNetworkSpigot extends JavaPlugin {
public static final String CHANNEL = "votenetwork:vote"; public static final String CHANNEL = "votenetwork:vote";
@@ -25,6 +36,7 @@ public class VoteNetworkSpigot extends JavaPlugin {
@Override @Override
public void onEnable() { public void onEnable() {
saveDefaultConfig(); saveDefaultConfig();
mergeMissingConfigKeys();
this.voteConfig = new VoteConfig(this); this.voteConfig = new VoteConfig(this);
this.maintenanceState = new MaintenanceState(); this.maintenanceState = new MaintenanceState();
@@ -47,14 +59,61 @@ public class VoteNetworkSpigot extends JavaPlugin {
getCommand("claim").setExecutor(new ClaimCommand(this, voteConfig, pendingVoteStore, voteParty, maintenanceState)); getCommand("claim").setExecutor(new ClaimCommand(this, voteConfig, pendingVoteStore, voteParty, maintenanceState));
getCommand("vote").setExecutor(new VoteAdminCommand(this, voteConfig, pendingVoteStore, maintenanceState)); getCommand("vote").setExecutor(new VoteAdminCommand(this, voteConfig, pendingVoteStore, maintenanceState));
getCommand("votegui").setExecutor(new VoteGuiCommand(voteConfig, voteParty));
getCommand("votemenu").setExecutor(new VoteMenuCommand(voteConfig, voteParty));
getServer().getPluginManager().registerEvents(new VotePartyGuiListener(voteConfig), this);
getServer().getPluginManager().registerEvents(
new VoteMenuListener(this, voteConfig, pendingVoteStore, voteParty, maintenanceState), this);
VoteNetworkAPI api = new VoteNetworkAPIImpl(this, voteConfig, pendingVoteStore, voteParty, maintenanceState); VoteNetworkAPI api = new VoteNetworkAPIImpl(this, voteConfig, pendingVoteStore, voteParty, maintenanceState);
VoteNetworkAPIHolder.set(api); VoteNetworkAPIHolder.set(api);
getServer().getServicesManager().register(VoteNetworkAPI.class, api, this, ServicePriority.Normal); getServer().getServicesManager().register(VoteNetworkAPI.class, api, this, ServicePriority.Normal);
if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
new VoteNetworkPlaceholders(this, voteConfig, pendingVoteStore, voteParty, maintenanceState).register();
getLogger().info("Placeholders PlaceholderAPI enregistres (%votenetwork_...%).");
}
getLogger().info("VoteNetwork (Spigot) demarre."); getLogger().info("VoteNetwork (Spigot) demarre.");
} }
/**
* Ajoute dans config.yml les cles presentes dans le config.yml embarque dans le jar mais
* absentes du fichier existant (nouvelles options apportees par une mise a jour du plugin),
* sans jamais modifier une valeur deja definie par l'admin.
*
* Limite connue : Bukkit ne preserve pas les commentaires lors de la reecriture du fichier.
*/
private void mergeMissingConfigKeys() {
try (InputStreamReader reader = new InputStreamReader(getResource("config.yml"), StandardCharsets.UTF_8)) {
YamlConfiguration defaults = YamlConfiguration.loadConfiguration(reader);
YamlConfiguration current = YamlConfiguration.loadConfiguration(getConfigFile());
boolean changed = false;
for (String key : defaults.getKeys(true)) {
if (defaults.isConfigurationSection(key)) {
continue;
}
if (!current.isSet(key)) {
current.set(key, defaults.get(key));
changed = true;
}
}
if (changed) {
current.save(getConfigFile());
reloadConfig();
getLogger().info("config.yml mis a jour avec les nouvelles options manquantes (valeurs existantes conservees).");
}
} catch (IOException | java.io.UncheckedIOException e) {
getLogger().warning("Impossible de fusionner les nouvelles options de config.yml : " + e.getMessage());
}
}
private java.io.File getConfigFile() {
return new java.io.File(getDataFolder(), "config.yml");
}
@Override @Override
public void onDisable() { public void onDisable() {
getServer().getServicesManager().unregisterAll(this); getServer().getServicesManager().unregisterAll(this);
@@ -1,11 +1,11 @@
package fr.votenetwork.spigot.command; package fr.votenetwork.spigot.command;
import fr.votenetwork.spigot.ClaimAction;
import fr.votenetwork.spigot.MaintenanceState; import fr.votenetwork.spigot.MaintenanceState;
import fr.votenetwork.spigot.VoteNetworkSpigot; import fr.votenetwork.spigot.VoteNetworkSpigot;
import fr.votenetwork.spigot.VoteConfig; import fr.votenetwork.spigot.VoteConfig;
import fr.votenetwork.spigot.storage.PendingVoteStore; import fr.votenetwork.spigot.storage.PendingVoteStore;
import fr.votenetwork.spigot.voteparty.VoteParty; import fr.votenetwork.spigot.voteparty.VoteParty;
import org.bukkit.Bukkit;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
@@ -42,41 +42,7 @@ public class ClaimCommand implements CommandExecutor {
return true; return true;
} }
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> ClaimAction.claim(plugin, config, pendingVoteStore, voteParty, player);
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; 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)));
if (config.isVoteBroadcastEnabled()) {
Bukkit.broadcastMessage(config.getVoteBroadcastMessage()
.replace("%player%", player.getName())
.replace("%amount%", String.valueOf(pendingVotes)));
}
}
} }
@@ -0,0 +1,31 @@
package fr.votenetwork.spigot.command;
import fr.votenetwork.spigot.VoteConfig;
import fr.votenetwork.spigot.gui.VotePartyGui;
import fr.votenetwork.spigot.voteparty.VoteParty;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public class VoteGuiCommand implements CommandExecutor {
private final VoteConfig config;
private final VoteParty voteParty;
public VoteGuiCommand(VoteConfig config, VoteParty voteParty) {
this.config = config;
this.voteParty = voteParty;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(ChatColor.RED + "Commande reservee aux joueurs.");
return true;
}
VotePartyGui.open(player, config, voteParty);
return true;
}
}
@@ -0,0 +1,31 @@
package fr.votenetwork.spigot.command;
import fr.votenetwork.spigot.VoteConfig;
import fr.votenetwork.spigot.gui.MainMenuGui;
import fr.votenetwork.spigot.voteparty.VoteParty;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
public class VoteMenuCommand implements CommandExecutor {
private final VoteConfig config;
private final VoteParty voteParty;
public VoteMenuCommand(VoteConfig config, VoteParty voteParty) {
this.config = config;
this.voteParty = voteParty;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player player)) {
sender.sendMessage(ChatColor.RED + "Commande reservee aux joueurs.");
return true;
}
MainMenuGui.open(player, config, voteParty);
return true;
}
}
@@ -0,0 +1,47 @@
package fr.votenetwork.spigot.gui;
import fr.votenetwork.spigot.MaintenanceState;
import fr.votenetwork.spigot.VoteConfig;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.Locale;
/** Ops-only screen (permission votenetwork.admin) to toggle maintenance mode with one click. */
public final class AdminMenuGui {
private AdminMenuGui() {
}
public static void open(Player player, VoteConfig config, MaintenanceState maintenanceState) {
AdminMenuHolder holder = new AdminMenuHolder();
Inventory inventory = Bukkit.createInventory(holder, 27, config.getAdminMenuTitle());
holder.setInventory(inventory);
inventory.setItem(AdminMenuHolder.ITEM_SLOT, buildItem(config, maintenanceState.isStopped()));
player.openInventory(inventory);
}
public static ItemStack buildItem(VoteConfig config, boolean stopped) {
Material material;
try {
material = Material.valueOf(config.getAdminMenuItemMaterial().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
material = Material.LEVER;
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(stopped ? config.getAdminMenuItemNameEnabled() : config.getAdminMenuItemNameDisabled());
meta.setLore(config.getAdminMenuItemLore());
item.setItemMeta(meta);
}
return item;
}
}
@@ -0,0 +1,21 @@
package fr.votenetwork.spigot.gui;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
/** Marker holder for the ops-only administration screen (toggle maintenance mode). */
public class AdminMenuHolder implements InventoryHolder {
public static final int ITEM_SLOT = 13;
private Inventory inventory;
@Override
public Inventory getInventory() {
return inventory;
}
public void setInventory(Inventory inventory) {
this.inventory = inventory;
}
}
@@ -0,0 +1,80 @@
package fr.votenetwork.spigot.gui;
import fr.votenetwork.spigot.VoteConfig;
import fr.votenetwork.spigot.VoteNetworkSpigot;
import fr.votenetwork.spigot.storage.PendingVoteStore;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.Locale;
/**
* Visual counterpart of /claim: shows how many votes are waiting for the player, resolved
* asynchronously via {@link PendingVoteStore#peek}, and lets them claim by clicking the item.
*/
public final class ClaimMenuGui {
private ClaimMenuGui() {
}
public static void open(Player player, VoteNetworkSpigot plugin, VoteConfig config, PendingVoteStore pendingVoteStore) {
ClaimMenuHolder holder = new ClaimMenuHolder();
Inventory inventory = Bukkit.createInventory(holder, 27, config.getClaimMenuTitle());
holder.setInventory(inventory);
ItemStack loading = new ItemStack(Material.CLOCK);
ItemMeta loadingMeta = loading.getItemMeta();
if (loadingMeta != null) {
loadingMeta.setDisplayName(config.getClaimMenuLoadingLore());
loading.setItemMeta(loadingMeta);
}
inventory.setItem(ClaimMenuHolder.ITEM_SLOT, loading);
player.openInventory(inventory);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
pendingVoteStore.peek(
player.getUniqueId(),
amount -> Bukkit.getScheduler().runTask(plugin, () -> {
holder.setPendingVotes(amount);
if (player.getOpenInventory().getTopInventory().getHolder() == holder) {
inventory.setItem(ClaimMenuHolder.ITEM_SLOT, buildItem(config, amount));
}
}),
exception -> Bukkit.getScheduler().runTask(plugin, () -> {
plugin.getLogger().severe("Erreur menu reclamation pour " + player.getName() + ": " + exception.getMessage());
player.closeInventory();
})
)
);
}
public static ItemStack buildItem(VoteConfig config, int amount) {
boolean available = amount > 0;
String materialName = available ? config.getClaimMenuAvailableMaterial() : config.getClaimMenuEmptyMaterial();
String name = available
? config.getClaimMenuAvailableName().replace("%amount%", String.valueOf(amount))
: config.getClaimMenuEmptyName();
java.util.List<String> lore = available ? config.getClaimMenuAvailableLore() : config.getClaimMenuEmptyLore();
Material material;
try {
material = Material.valueOf(materialName.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
material = Material.CHEST;
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(name);
meta.setLore(lore);
item.setItemMeta(meta);
}
return item;
}
}
@@ -0,0 +1,31 @@
package fr.votenetwork.spigot.gui;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
/** Marker holder for the visual /claim screen opened from the main menu. */
public class ClaimMenuHolder implements InventoryHolder {
public static final int ITEM_SLOT = 13;
private Inventory inventory;
private volatile int pendingVotes = -1;
@Override
public Inventory getInventory() {
return inventory;
}
public void setInventory(Inventory inventory) {
this.inventory = inventory;
}
/** -1 while the async lookup is still pending, >= 0 once resolved. */
public int getPendingVotes() {
return pendingVotes;
}
public void setPendingVotes(int pendingVotes) {
this.pendingVotes = pendingVotes;
}
}
@@ -0,0 +1,74 @@
package fr.votenetwork.spigot.gui;
import fr.votenetwork.spigot.VoteConfig;
import fr.votenetwork.spigot.voteparty.VoteParty;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.SkullMeta;
/**
* /votemenu entry point: player head with VoteParty progress, plus navigation to the vote-site
* links (/votegui), the visual claim menu, and (ops only) the administration menu.
*/
public final class MainMenuGui {
private MainMenuGui() {
}
public static void open(Player player, VoteConfig config, VoteParty voteParty) {
MainMenuHolder holder = new MainMenuHolder();
Inventory inventory = Bukkit.createInventory(holder, 27, config.getMainMenuTitle());
holder.setInventory(inventory);
int required = config.getVotePartyVotesRequired();
int current = Math.min(voteParty.getCurrentVotes(), required);
ItemStack head = new ItemStack(Material.PLAYER_HEAD);
SkullMeta skullMeta = (SkullMeta) head.getItemMeta();
if (skullMeta != null) {
skullMeta.setOwningPlayer(player);
skullMeta.setDisplayName(ChatColor.translateAlternateColorCodes('&', "&b&l" + player.getName()));
skullMeta.setLore(config.getMainMenuHeadLore().stream()
.map(line -> line.replace("%player%", player.getName())
.replace("%current%", String.valueOf(current))
.replace("%required%", String.valueOf(required)))
.toList());
head.setItemMeta(skullMeta);
}
inventory.setItem(4, head);
inventory.setItem(MainMenuHolder.VOTE_SLOT, buildItem(
config.getMainMenuVoteItemMaterial(), config.getMainMenuVoteItemName(), config.getMainMenuVoteItemLore()));
inventory.setItem(MainMenuHolder.CLAIM_SLOT, buildItem(
config.getMainMenuClaimItemMaterial(), config.getMainMenuClaimItemName(), config.getMainMenuClaimItemLore()));
if (player.hasPermission("votenetwork.admin")) {
inventory.setItem(MainMenuHolder.ADMIN_SLOT, buildItem(
config.getMainMenuAdminItemMaterial(), config.getMainMenuAdminItemName(), config.getMainMenuAdminItemLore()));
}
player.openInventory(inventory);
}
private static ItemStack buildItem(String materialName, String name, java.util.List<String> lore) {
Material material;
try {
material = Material.valueOf(materialName.toUpperCase(java.util.Locale.ROOT));
} catch (IllegalArgumentException e) {
material = Material.PAPER;
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(name);
meta.setLore(lore);
item.setItemMeta(meta);
}
return item;
}
}
@@ -0,0 +1,23 @@
package fr.votenetwork.spigot.gui;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
/** Marker holder for the /votemenu main screen. */
public class MainMenuHolder implements InventoryHolder {
public static final int VOTE_SLOT = 11;
public static final int CLAIM_SLOT = 13;
public static final int ADMIN_SLOT = 15;
private Inventory inventory;
@Override
public Inventory getInventory() {
return inventory;
}
public void setInventory(Inventory inventory) {
this.inventory = inventory;
}
}
@@ -0,0 +1,83 @@
package fr.votenetwork.spigot.gui;
import fr.votenetwork.spigot.ClaimAction;
import fr.votenetwork.spigot.MaintenanceState;
import fr.votenetwork.spigot.VoteConfig;
import fr.votenetwork.spigot.VoteNetworkSpigot;
import fr.votenetwork.spigot.storage.PendingVoteStore;
import fr.votenetwork.spigot.voteparty.VoteParty;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
/** Click handling for the main/claim/admin menus (/votemenu tree). */
public class VoteMenuListener implements Listener {
private final VoteNetworkSpigot plugin;
private final VoteConfig config;
private final PendingVoteStore pendingVoteStore;
private final VoteParty voteParty;
private final MaintenanceState maintenanceState;
public VoteMenuListener(VoteNetworkSpigot plugin, VoteConfig config, PendingVoteStore pendingVoteStore,
VoteParty voteParty, MaintenanceState maintenanceState) {
this.plugin = plugin;
this.config = config;
this.pendingVoteStore = pendingVoteStore;
this.voteParty = voteParty;
this.maintenanceState = maintenanceState;
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
Object rawHolder = event.getInventory().getHolder();
if (!(event.getWhoClicked() instanceof Player player)) {
return;
}
if (rawHolder instanceof MainMenuHolder) {
event.setCancelled(true);
handleMainMenuClick(player, event.getRawSlot());
} else if (rawHolder instanceof ClaimMenuHolder holder) {
event.setCancelled(true);
handleClaimMenuClick(player, holder, event.getRawSlot());
} else if (rawHolder instanceof AdminMenuHolder) {
event.setCancelled(true);
handleAdminMenuClick(player, event.getRawSlot());
}
}
private void handleMainMenuClick(Player player, int slot) {
if (slot == MainMenuHolder.VOTE_SLOT) {
VotePartyGui.open(player, config, voteParty);
} else if (slot == MainMenuHolder.CLAIM_SLOT) {
ClaimMenuGui.open(player, plugin, config, pendingVoteStore);
} else if (slot == MainMenuHolder.ADMIN_SLOT && player.hasPermission("votenetwork.admin")) {
AdminMenuGui.open(player, config, maintenanceState);
}
}
private void handleClaimMenuClick(Player player, ClaimMenuHolder holder, int slot) {
if (slot != ClaimMenuHolder.ITEM_SLOT || holder.getPendingVotes() < 0) {
return;
}
if (maintenanceState.isStopped()) {
player.closeInventory();
player.sendMessage(config.getMessageMaintenance());
return;
}
player.closeInventory();
ClaimAction.claim(plugin, config, pendingVoteStore, voteParty, player);
}
private void handleAdminMenuClick(Player player, int slot) {
if (slot != AdminMenuHolder.ITEM_SLOT || !player.hasPermission("votenetwork.admin")) {
return;
}
boolean newState = !maintenanceState.isStopped();
maintenanceState.setStopped(newState);
player.sendMessage(newState ? config.getMessageMaintenanceEnabled() : config.getMessageMaintenanceDisabled());
player.getOpenInventory().getTopInventory().setItem(AdminMenuHolder.ITEM_SLOT, AdminMenuGui.buildItem(config, newState));
}
}
@@ -0,0 +1,72 @@
package fr.votenetwork.spigot.gui;
import fr.votenetwork.spigot.VoteConfig;
import fr.votenetwork.spigot.voteparty.VoteParty;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.List;
/**
* Builds and opens the VoteParty progress GUI: a progress item (slot 4) plus one item per
* configured vote site (starting at slot 9), each opening its URL when clicked.
*/
public final class VotePartyGui {
public static final int PROGRESS_SLOT = 4;
public static final int SITES_START_SLOT = 9;
private VotePartyGui() {
}
public static void open(Player player, VoteConfig config, VoteParty voteParty) {
List<VoteSite> sites = config.getVoteSites();
int size = ((SITES_START_SLOT + sites.size() + 8) / 9) * 9;
size = Math.max(size, 18);
VotePartyGuiHolder holder = new VotePartyGuiHolder();
Inventory inventory = Bukkit.createInventory(holder, size, config.getGuiTitle());
holder.setInventory(inventory);
int required = config.getVotePartyVotesRequired();
int current = Math.min(voteParty.getCurrentVotes(), required);
ItemStack progressItem = new ItemStack(materialOrFallback(config.getGuiProgressItemMaterial()));
ItemMeta progressMeta = progressItem.getItemMeta();
if (progressMeta != null) {
progressMeta.setDisplayName(config.getGuiProgressItemName());
progressMeta.setLore(config.getGuiProgressItemLore().stream()
.map(line -> line.replace("%current%", String.valueOf(current))
.replace("%required%", String.valueOf(required)))
.toList());
progressItem.setItemMeta(progressMeta);
}
inventory.setItem(PROGRESS_SLOT, progressItem);
int slot = SITES_START_SLOT;
for (VoteSite site : sites) {
ItemStack item = new ItemStack(site.getMaterial());
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(site.getName());
meta.setLore(site.getLore());
item.setItemMeta(meta);
}
inventory.setItem(slot, item);
slot++;
}
player.openInventory(inventory);
}
private static org.bukkit.Material materialOrFallback(String name) {
try {
return org.bukkit.Material.valueOf(name.toUpperCase(java.util.Locale.ROOT));
} catch (IllegalArgumentException e) {
return org.bukkit.Material.NETHER_STAR;
}
}
}
@@ -0,0 +1,23 @@
package fr.votenetwork.spigot.gui;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
/**
* Marker holder used to recognize our own GUI inventory in InventoryClickEvent, so the
* listener never touches clicks happening in unrelated inventories (chests, other plugins'
* GUIs, etc.).
*/
public class VotePartyGuiHolder implements InventoryHolder {
private Inventory inventory;
@Override
public Inventory getInventory() {
return inventory;
}
public void setInventory(Inventory inventory) {
this.inventory = inventory;
}
}
@@ -0,0 +1,53 @@
package fr.votenetwork.spigot.gui;
import fr.votenetwork.spigot.VoteConfig;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.TextComponent;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import java.util.List;
/**
* Bukkit cannot open a browser directly from an inventory click, so a click on a vote-site
* item closes the GUI and sends a clickable chat message (ClickEvent OPEN_URL) instead.
*/
public class VotePartyGuiListener implements Listener {
private final VoteConfig config;
public VotePartyGuiListener(VoteConfig config) {
this.config = config;
}
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (!(event.getInventory().getHolder() instanceof VotePartyGuiHolder)) {
return;
}
event.setCancelled(true);
int slot = event.getRawSlot();
if (slot < VotePartyGui.SITES_START_SLOT || !(event.getWhoClicked() instanceof Player player)) {
return;
}
List<VoteSite> sites = config.getVoteSites();
int index = slot - VotePartyGui.SITES_START_SLOT;
if (index < 0 || index >= sites.size()) {
return;
}
VoteSite site = sites.get(index);
if (site.getUrl() == null || site.getUrl().isEmpty()) {
return;
}
player.closeInventory();
TextComponent message = new TextComponent(config.getGuiClickMessage() + " " + site.getUrl());
message.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, site.getUrl()));
player.spigot().sendMessage(message);
}
}
@@ -0,0 +1,40 @@
package fr.votenetwork.spigot.gui;
import org.bukkit.Material;
import java.util.List;
/**
* A single vote-site entry configured in config.yml (gui.vote-sites): the display item and
* the URL opened (via a clickable chat message) when the player clicks it in the GUI.
*/
public class VoteSite {
private final String name;
private final Material material;
private final String url;
private final List<String> lore;
public VoteSite(String name, Material material, String url, List<String> lore) {
this.name = name;
this.material = material;
this.url = url;
this.lore = lore;
}
public String getName() {
return name;
}
public Material getMaterial() {
return material;
}
public String getUrl() {
return url;
}
public List<String> getLore() {
return lore;
}
}
@@ -0,0 +1,102 @@
package fr.votenetwork.spigot.placeholder;
import fr.votenetwork.spigot.MaintenanceState;
import fr.votenetwork.spigot.VoteConfig;
import fr.votenetwork.spigot.VoteNetworkSpigot;
import fr.votenetwork.spigot.storage.PendingVoteStore;
import fr.votenetwork.spigot.voteparty.VoteParty;
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Placeholders PlaceholderAPI :
* %votenetwork_voteparty_current%
* %votenetwork_voteparty_required%
* %votenetwork_voteparty_remaining%
* %votenetwork_maintenance% -> "true"/"false"
* %votenetwork_pending% -> votes en attente du joueur (rafraichi en arriere-plan)
*
* Utilisables dans n'importe quel plugin de scoreboard/GUI qui supporte PlaceholderAPI
* (%votenetwork_...%, pas %current%/%required% bruts : ces derniers doivent etre configures
* comme placeholders dans le plugin de scoreboard lui-meme).
*/
public class VoteNetworkPlaceholders extends PlaceholderExpansion {
private final VoteNetworkSpigot plugin;
private final VoteConfig config;
private final PendingVoteStore pendingVoteStore;
private final VoteParty voteParty;
private final MaintenanceState maintenanceState;
private final Map<String, Integer> pendingVotesCache = new ConcurrentHashMap<>();
public VoteNetworkPlaceholders(VoteNetworkSpigot 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 @NotNull String getIdentifier() {
return "votenetwork";
}
@Override
public @NotNull String getAuthor() {
return "VoteNetwork";
}
@Override
public @NotNull String getVersion() {
return plugin.getDescription().getVersion();
}
@Override
public boolean persist() {
return true;
}
@Override
public String onRequest(OfflinePlayer player, @NotNull String params) {
switch (params.toLowerCase()) {
case "voteparty_current":
return String.valueOf(voteParty.getCurrentVotes());
case "voteparty_required":
return String.valueOf(config.getVotePartyVotesRequired());
case "voteparty_remaining":
return String.valueOf(Math.max(0, config.getVotePartyVotesRequired() - voteParty.getCurrentVotes()));
case "maintenance":
return String.valueOf(maintenanceState.isStopped());
case "pending":
if (player == null) {
return "0";
}
return String.valueOf(getPendingVotesCached(player));
default:
return null;
}
}
private int getPendingVotesCached(OfflinePlayer player) {
String key = player.getUniqueId().toString();
Integer cached = pendingVotesCache.get(key);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () ->
pendingVoteStore.peek(
player.getUniqueId(),
votes -> pendingVotesCache.put(key, votes),
exception -> { /* on garde la derniere valeur en cache en cas d'erreur */ }
)
);
return cached != null ? cached : 0;
}
}
@@ -73,16 +73,24 @@ public class MySqlPendingVoteStore implements PendingVoteStore {
} }
private void createTableIfNotExists() { private void createTableIfNotExists() {
String sql = "CREATE TABLE IF NOT EXISTS nb_votes_attente (" + String globalSql = "CREATE TABLE IF NOT EXISTS nb_votes_attente (" +
"player_uuid VARCHAR(36) NOT NULL PRIMARY KEY," + "player_uuid VARCHAR(36) NOT NULL PRIMARY KEY," +
"player_name VARCHAR(16) NOT NULL," + "player_name VARCHAR(16) NOT NULL," +
"nombre_votes INT DEFAULT 0" + "nombre_votes INT DEFAULT 0" +
")"; ")";
String perServerSql = "CREATE TABLE IF NOT EXISTS nb_votes_attente_serveur (" +
"player_uuid VARCHAR(36) NOT NULL," +
"server_name VARCHAR(64) NOT NULL," +
"player_name VARCHAR(16) NOT NULL," +
"nombre_votes INT DEFAULT 0," +
"PRIMARY KEY (player_uuid, server_name)" +
")";
try (Connection connection = dataSource.getConnection(); try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) { Statement statement = connection.createStatement()) {
statement.execute(sql); statement.execute(globalSql);
statement.execute(perServerSql);
} catch (SQLException e) { } catch (SQLException e) {
plugin.getLogger().severe("Impossible de creer la table nb_votes_attente: " + e.getMessage()); plugin.getLogger().severe("Impossible de creer les tables de votes en attente: " + e.getMessage());
} }
} }
@@ -92,6 +100,14 @@ public class MySqlPendingVoteStore implements PendingVoteStore {
onError.accept(new SQLException("Pool MySQL indisponible (echec de connexion au demarrage)")); onError.accept(new SQLException("Pool MySQL indisponible (echec de connexion au demarrage)"));
return; return;
} }
if (config.isPerServerScope()) {
fetchAndClearPerServer(uuid, onResult, onError);
} else {
fetchAndClearGlobal(uuid, onResult, onError);
}
}
private void fetchAndClearGlobal(UUID uuid, IntConsumer onResult, Consumer<Exception> onError) {
String selectSql = "SELECT nombre_votes FROM nb_votes_attente WHERE player_uuid = ?"; 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 = ?"; String resetSql = "UPDATE nb_votes_attente SET nombre_votes = 0 WHERE player_uuid = ?";
@@ -120,16 +136,59 @@ public class MySqlPendingVoteStore implements PendingVoteStore {
} }
} }
private void fetchAndClearPerServer(UUID uuid, IntConsumer onResult, Consumer<Exception> onError) {
if (config.getServerName().isEmpty()) {
onError.accept(new SQLException("storage.pending-votes-scope=per-server mais server-name est vide dans config.yml"));
return;
}
String selectSql = "SELECT nombre_votes FROM nb_votes_attente_serveur WHERE player_uuid = ? AND server_name = ?";
String resetSql = "UPDATE nb_votes_attente_serveur SET nombre_votes = 0 WHERE player_uuid = ? AND server_name = ?";
try (Connection connection = dataSource.getConnection()) {
int pendingVotes = 0;
try (PreparedStatement select = connection.prepareStatement(selectSql)) {
select.setString(1, uuid.toString());
select.setString(2, config.getServerName());
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.setString(2, config.getServerName());
reset.executeUpdate();
}
}
onResult.accept(pendingVotes);
} catch (SQLException e) {
onError.accept(e);
}
}
@Override @Override
public void peek(UUID uuid, IntConsumer onResult, Consumer<Exception> onError) { public void peek(UUID uuid, IntConsumer onResult, Consumer<Exception> onError) {
if (!isConnected()) { if (!isConnected()) {
onError.accept(new SQLException("Pool MySQL indisponible (echec de connexion au demarrage)")); onError.accept(new SQLException("Pool MySQL indisponible (echec de connexion au demarrage)"));
return; return;
} }
String sql = "SELECT nombre_votes FROM nb_votes_attente WHERE player_uuid = ?";
boolean perServer = config.isPerServerScope();
String sql = perServer
? "SELECT nombre_votes FROM nb_votes_attente_serveur WHERE player_uuid = ? AND server_name = ?"
: "SELECT nombre_votes FROM nb_votes_attente WHERE player_uuid = ?";
try (Connection connection = dataSource.getConnection(); try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) { PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, uuid.toString()); statement.setString(1, uuid.toString());
if (perServer) {
statement.setString(2, config.getServerName());
}
try (ResultSet resultSet = statement.executeQuery()) { try (ResultSet resultSet = statement.executeQuery()) {
onResult.accept(resultSet.next() ? resultSet.getInt("nombre_votes") : 0); onResult.accept(resultSet.next() ? resultSet.getInt("nombre_votes") : 0);
} }
@@ -47,10 +47,14 @@ public class VoteParty {
public void addVotes(int amount) { public void addVotes(int amount) {
int required = config.getVotePartyVotesRequired(); int required = config.getVotePartyVotesRequired();
int updated = currentVotes.addAndGet(amount); int updated = currentVotes.addAndGet(amount);
int shown = Math.min(updated, required);
if (config.isVotePartyBroadcastEnabled()) { // N'annonce la progression que tous les N votes (config), plus le dernier avant le
// declenchement, pour eviter de spam le chat a chaque vote individuel.
int every = Math.max(1, config.getVotePartyBroadcastEvery());
if (config.isVotePartyBroadcastEnabled() && (shown % every == 0 || shown >= required)) {
Bukkit.broadcastMessage(config.getVotePartyProgressMessage() Bukkit.broadcastMessage(config.getVotePartyProgressMessage()
.replace("%current%", String.valueOf(Math.min(updated, required))) .replace("%current%", String.valueOf(shown))
.replace("%required%", String.valueOf(required))); .replace("%required%", String.valueOf(required)));
} }
@@ -62,14 +66,25 @@ public class VoteParty {
save(); save();
} }
/**
* Commands without %player% run once (e.g. a crate plugin's own "give to everyone"
* command). Commands containing %player% are dispatched once per online player, since
* VoteParty rewards the whole server rather than a single voter.
*/
private void trigger() { private void trigger() {
if (config.isVotePartyBroadcastEnabled()) { if (config.isVotePartyBroadcastEnabled()) {
Bukkit.broadcastMessage(config.getVotePartyTriggeredMessage()); Bukkit.broadcastMessage(config.getVotePartyTriggeredMessage());
} }
for (String command : config.getVotePartyCommands()) { for (String command : config.getVotePartyCommands()) {
if (command.contains("%player%")) {
for (org.bukkit.entity.Player player : Bukkit.getOnlinePlayers()) {
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command.replace("%player%", player.getName()));
}
} else {
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command);
} }
} }
}
public int getCurrentVotes() { public int getCurrentVotes() {
return currentVotes.get(); return currentVotes.get();
+85
View File
@@ -8,6 +8,14 @@
# partage automatiquement avec le fichier YAML du proxy Velocity si vous etes en reseau. # partage automatiquement avec le fichier YAML du proxy Velocity si vous etes en reseau.
storage: storage:
type: mysql type: mysql
# Doit correspondre EXACTEMENT a pending-votes.scope cote Velocity (config.properties) :
# global (par defaut) : /claim consomme le compteur partage entre tous les serveurs.
# per-server : /claim ne consomme que le compteur de CE serveur (voir server-name ci-dessous).
pending-votes-scope: global
# Nom de CE serveur, IDENTIQUE a celui utilise dans direct-vote.servers cote Velocity.
# Uniquement necessaire si storage.pending-votes-scope=per-server.
server-name: ""
mysql: mysql:
host: 127.0.0.1 host: 127.0.0.1
@@ -36,9 +44,86 @@ voteparty:
- "goldencrates broadcast_give vote_key 1" - "goldencrates broadcast_give vote_key 1"
broadcast: broadcast:
enabled: true enabled: true
# N'annonce la progression que tous les N votes (10 = 1 message toutes les 10 votes),
# plus systematiquement au dernier vote avant le declenchement. Mettre a 1 pour annoncer
# a chaque vote.
every: 10
progress-message: "&b[Vote] &f%current%&7/&f%required% &fvotes avant le prochain VoteParty !" progress-message: "&b[Vote] &f%current%&7/&f%required% &fvotes avant le prochain VoteParty !"
triggered-message: "&a[Vote] &fVoteParty declenche ! Profitez des recompenses !" triggered-message: "&a[Vote] &fVoteParty declenche ! Profitez des recompenses !"
# GUI /votegui : affiche la progression du VoteParty et des items cliquables vers les sites
# de vote (ouvre le lien via un message chat cliquable, Bukkit ne peut pas ouvrir un navigateur
# directement depuis un clic d'inventaire).
gui:
title: "&8VoteNetwork"
progress-item:
material: NETHER_STAR
name: "&b&lVoteParty"
lore:
- "&7Progression actuelle :"
- "&f%current%&7/&f%required% &7votes"
click-message: "&a[Vote] Clique sur le lien pour voter :"
vote-sites:
- name: "&aVoter sur le site 1"
material: PAPER
url: "https://example.com/vote/site1"
lore:
- "&7Clique pour voter !"
- name: "&aVoter sur le site 2"
material: BOOK
url: "https://example.com/vote/site2"
lore:
- "&7Clique pour voter !"
# Menu principal /votemenu : profil du joueur, progression VoteParty, et boutons de
# navigation vers le menu de vote (/votegui), de reclamation et (pour les ops) d'administration.
main-menu:
title: "&8VoteNetwork - Menu"
head-lore:
- "&7Joueur : &f%player%"
- "&7Progression VoteParty :"
- "&f%current%&7/&f%required% &7votes"
vote-item:
material: PAPER
name: "&aVoter"
lore:
- "&7Clique pour voir les liens de vote."
claim-item:
material: CHEST
name: "&eMes votes en attente"
lore:
- "&7Clique pour reclamer tes votes stockes."
admin-item:
material: REDSTONE
name: "&cAdministration"
lore:
- "&7Clique pour gerer le mode maintenance."
# Menu de reclamation visuel (equivalent GUI de /claim).
claim-menu:
title: "&8VoteNetwork - Reclamer"
loading-lore: "&7Chargement..."
available:
material: CHEST
name: "&e%amount% vote(s) en attente"
lore:
- "&7Clique pour reclamer !"
empty:
material: BARRIER
name: "&7Aucun vote en attente"
lore:
- "&7Reviens plus tard."
# Menu d'administration (reserve aux ops, permission votenetwork.admin).
admin-menu:
title: "&8VoteNetwork - Administration"
item:
material: LEVER
name-maintenance-on: "&c&lMaintenance : ACTIVEE"
name-maintenance-off: "&a&lMaintenance : DESACTIVEE"
lore:
- "&7Clique pour basculer le mode maintenance."
messages: messages:
direct-vote: "&a[Vote] &fMerci pour ton vote !" direct-vote: "&a[Vote] &fMerci pour ton vote !"
claim-success: "&a[Vote] &fVous avez recupere &e%amount% &fvote(s) en attente !" claim-success: "&a[Vote] &fVous avez recupere &e%amount% &fvote(s) en attente !"
+8 -1
View File
@@ -2,8 +2,9 @@ name: votenetwork
main: fr.votenetwork.spigot.VoteNetworkSpigot main: fr.votenetwork.spigot.VoteNetworkSpigot
version: ${project.version} version: ${project.version}
api-version: 1.18 api-version: 1.18
author: VoteNetwork author: Sar_Tron
description: Reception des votes reseau (direct ou en attente) avec VoteParty local. description: Reception des votes reseau (direct ou en attente) avec VoteParty local.
softdepend: [PlaceholderAPI]
commands: commands:
claim: claim:
@@ -13,6 +14,12 @@ commands:
description: Administration du systeme de vote (maintenance, test du stockage). description: Administration du systeme de vote (maintenance, test du stockage).
usage: /vote <stop|start|testdb> usage: /vote <stop|start|testdb>
permission: votenetwork.admin permission: votenetwork.admin
votegui:
description: Ouvre le GUI de progression du VoteParty avec les liens de vote.
usage: /votegui
votemenu:
description: Ouvre le menu principal (profil, votes en attente, administration).
usage: /votemenu
permissions: permissions:
votenetwork.admin: votenetwork.admin:
@@ -0,0 +1,70 @@
package fr.votenetwork.spigot;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class VoteConfigTest {
@Test
void defaultsAreUsedWhenKeysAbsent() {
VoteNetworkSpigot plugin = mock(VoteNetworkSpigot.class);
when(plugin.getConfig()).thenReturn(new YamlConfiguration());
VoteConfig config = new VoteConfig(plugin);
assertTrue(config.isMysqlStorage());
assertEquals("127.0.0.1", config.getMysqlHost());
assertEquals(3306, config.getMysqlPort());
assertEquals("votenetwork", config.getMysqlDatabase());
assertEquals(100, config.getVotePartyVotesRequired());
assertFalse(config.isPerServerScope());
assertFalse(config.isVoteBroadcastEnabled());
}
@Test
void mysqlStorageIsFalseOnlyForYaml() {
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("storage.type", "yaml");
VoteNetworkSpigot plugin = mock(VoteNetworkSpigot.class);
when(plugin.getConfig()).thenReturn(yaml);
VoteConfig config = new VoteConfig(plugin);
assertFalse(config.isMysqlStorage());
assertEquals("yaml", config.getStorageType());
}
@Test
void perServerScopeReadFromConfig() {
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("storage.pending-votes-scope", "per-server");
yaml.set("server-name", "gen1");
VoteNetworkSpigot plugin = mock(VoteNetworkSpigot.class);
when(plugin.getConfig()).thenReturn(yaml);
VoteConfig config = new VoteConfig(plugin);
assertTrue(config.isPerServerScope());
assertEquals("gen1", config.getServerName());
}
@Test
void messagesAreColorizedAndPlaceholdersPreserved() {
YamlConfiguration yaml = new YamlConfiguration();
yaml.set("messages.claim-success", "&aTu as recupere &e%amount% &avotes !");
VoteNetworkSpigot plugin = mock(VoteNetworkSpigot.class);
when(plugin.getConfig()).thenReturn(yaml);
VoteConfig config = new VoteConfig(plugin);
String message = config.getMessageClaimSuccess();
assertFalse(message.contains("&a"), "les codes couleur '&' doivent etre traduits");
assertTrue(message.contains("%amount%"), "le placeholder %amount% doit rester intact pour etre remplace plus tard");
}
}
@@ -0,0 +1,67 @@
package fr.votenetwork.spigot.storage;
import fr.votenetwork.spigot.VoteNetworkSpigot;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class YamlPendingVoteStoreTest {
@Test
void peekReturnsZeroWhenNothingStored(@TempDir File dataFolder) {
YamlPendingVoteStore store = newStore(dataFolder);
UUID uuid = UUID.randomUUID();
AtomicInteger result = new AtomicInteger(-1);
store.peek(uuid, result::set, e -> { throw new AssertionError(e); });
assertEquals(0, result.get());
}
@Test
void fetchAndClearResetsCounterToZeroAfterReading(@TempDir File dataFolder) throws Exception {
YamlPendingVoteStore store = newStore(dataFolder);
UUID uuid = UUID.randomUUID();
writeInitialVotes(dataFolder, uuid, 3);
AtomicInteger firstRead = new AtomicInteger(-1);
store.fetchAndClear(uuid, firstRead::set, e -> { throw new AssertionError(e); });
assertEquals(3, firstRead.get());
AtomicInteger secondRead = new AtomicInteger(-1);
store.peek(uuid, secondRead::set, e -> { throw new AssertionError(e); });
assertEquals(0, secondRead.get(), "fetchAndClear doit remettre le compteur a 0");
}
@Test
void testConnectionReportsWritableDirectory(@TempDir File dataFolder) {
YamlPendingVoteStore store = newStore(dataFolder);
AtomicReference<Boolean> success = new AtomicReference<>();
store.testConnection((ok, message) -> success.set(ok));
assertEquals(Boolean.TRUE, success.get(), "un dossier temporaire accessible en ecriture doit etre rapporte comme fonctionnel");
}
private YamlPendingVoteStore newStore(File dataFolder) {
VoteNetworkSpigot plugin = mock(VoteNetworkSpigot.class);
when(plugin.getDataFolder()).thenReturn(dataFolder);
return new YamlPendingVoteStore(plugin);
}
private void writeInitialVotes(File dataFolder, UUID uuid, int votes) throws Exception {
org.bukkit.configuration.file.YamlConfiguration yaml = new org.bukkit.configuration.file.YamlConfiguration();
yaml.set("players." + uuid + ".votes", votes);
File file = new File(dataFolder, "pending-votes.yml");
yaml.save(file);
}
}
+7 -1
View File
@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>fr.votenetwork</groupId> <groupId>fr.votenetwork</groupId>
<artifactId>votenetwork-parent</artifactId> <artifactId>votenetwork-parent</artifactId>
<version>1.0.0</version> <version>1.0.2</version>
</parent> </parent>
<artifactId>votenetwork-velocity</artifactId> <artifactId>votenetwork-velocity</artifactId>
@@ -36,6 +36,12 @@
<version>2.10.1</version> <version>2.10.1</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<repositories> <repositories>
@@ -57,16 +57,24 @@ public class MySqlPendingVoteStore implements PendingVoteStore {
} }
private void createTableIfNotExists() { private void createTableIfNotExists() {
String sql = "CREATE TABLE IF NOT EXISTS nb_votes_attente (" + String globalSql = "CREATE TABLE IF NOT EXISTS nb_votes_attente (" +
"player_uuid VARCHAR(36) NOT NULL PRIMARY KEY," + "player_uuid VARCHAR(36) NOT NULL PRIMARY KEY," +
"player_name VARCHAR(16) NOT NULL," + "player_name VARCHAR(16) NOT NULL," +
"nombre_votes INT DEFAULT 0" + "nombre_votes INT DEFAULT 0" +
")"; ")";
String perServerSql = "CREATE TABLE IF NOT EXISTS nb_votes_attente_serveur (" +
"player_uuid VARCHAR(36) NOT NULL," +
"server_name VARCHAR(64) NOT NULL," +
"player_name VARCHAR(16) NOT NULL," +
"nombre_votes INT DEFAULT 0," +
"PRIMARY KEY (player_uuid, server_name)" +
")";
try (Connection connection = dataSource.getConnection(); try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) { Statement statement = connection.createStatement()) {
statement.execute(sql); statement.execute(globalSql);
statement.execute(perServerSql);
} catch (SQLException e) { } catch (SQLException e) {
logger.error("Impossible de creer la table nb_votes_attente", e); logger.error("Impossible de creer les tables de votes en attente", e);
} }
} }
@@ -77,6 +85,15 @@ public class MySqlPendingVoteStore implements PendingVoteStore {
logger.error("Impossible d'enregistrer le vote en attente pour {} : pool MySQL indisponible", playerName); logger.error("Impossible d'enregistrer le vote en attente pour {} : pool MySQL indisponible", playerName);
return; return;
} }
if (config.isPerServerScope()) {
addPendingVotePerServer(uuid, playerName);
} else {
addPendingVoteGlobal(uuid, playerName);
}
}, asyncExecutor);
}
private void addPendingVoteGlobal(UUID uuid, String playerName) {
String sql = "INSERT INTO nb_votes_attente (player_uuid, player_name, nombre_votes) " + String sql = "INSERT INTO nb_votes_attente (player_uuid, player_name, nombre_votes) " +
"VALUES (?, ?, 1) " + "VALUES (?, ?, 1) " +
"ON DUPLICATE KEY UPDATE nombre_votes = nombre_votes + 1, player_name = VALUES(player_name)"; "ON DUPLICATE KEY UPDATE nombre_votes = nombre_votes + 1, player_name = VALUES(player_name)";
@@ -88,7 +105,24 @@ public class MySqlPendingVoteStore implements PendingVoteStore {
} catch (SQLException e) { } catch (SQLException e) {
logger.error("Erreur lors de l'insertion du vote en attente pour {}", playerName, e); logger.error("Erreur lors de l'insertion du vote en attente pour {}", playerName, e);
} }
}, asyncExecutor); }
private void addPendingVotePerServer(UUID uuid, String playerName) {
String sql = "INSERT INTO nb_votes_attente_serveur (player_uuid, server_name, 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)) {
for (String server : config.getDirectVoteServers()) {
statement.setString(1, uuid.toString());
statement.setString(2, server);
statement.setString(3, playerName);
statement.addBatch();
}
statement.executeBatch();
} catch (SQLException e) {
logger.error("Erreur lors de l'insertion du vote en attente (per-server) pour {}", playerName, e);
}
} }
@Override @Override
@@ -26,6 +26,7 @@ public class VoteConfig {
private String password; private String password;
private int poolSize; private int poolSize;
private Set<String> directVoteServers; private Set<String> directVoteServers;
private String pendingVotesScope;
public VoteConfig(Path dataDirectory, Logger logger) { public VoteConfig(Path dataDirectory, Logger logger) {
this.dataDirectory = dataDirectory; this.dataDirectory = dataDirectory;
@@ -45,6 +46,8 @@ public class VoteConfig {
} }
} }
mergeMissingConfigKeys(configFile);
try (InputStream in = Files.newInputStream(configFile)) { try (InputStream in = Files.newInputStream(configFile)) {
properties.load(in); properties.load(in);
} }
@@ -63,11 +66,69 @@ public class VoteConfig {
.filter(s -> !s.isEmpty()) .filter(s -> !s.isEmpty())
.map(s -> s.toLowerCase(Locale.ROOT)) .map(s -> s.toLowerCase(Locale.ROOT))
.collect(Collectors.toSet()); .collect(Collectors.toSet());
this.pendingVotesScope = properties.getProperty("pending-votes.scope", "global");
if (isPerServerScope() && directVoteServers.isEmpty()) {
logger.warn("pending-votes.scope=per-server mais direct-vote.servers est vide : bascule sur 'global'. "
+ "Renseignez direct-vote.servers pour utiliser le mode per-server.");
this.pendingVotesScope = "global";
}
} catch (IOException e) { } catch (IOException e) {
logger.error("Impossible de charger la configuration Velocity", e); logger.error("Impossible de charger la configuration Velocity", e);
} }
} }
/**
* Ajoute a la fin de config.properties les blocs (commentaires + cle=valeur) du fichier par
* defaut du jar dont la cle est absente du fichier existant (nouvelles options apportees par
* une mise a jour du plugin), sans jamais toucher aux lignes/valeurs deja presentes. Copie le
* bloc entier tel quel (donc avec son explication en commentaire), pas juste "cle=valeur".
*/
private void mergeMissingConfigKeys(Path configFile) throws IOException {
String defaultsText;
try (InputStream in = getClass().getClassLoader().getResourceAsStream("config.properties")) {
if (in == null) {
return;
}
defaultsText = new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
}
Properties existing = new Properties();
try (InputStream in = Files.newInputStream(configFile)) {
existing.load(in);
}
String[] blocks = defaultsText.split("\\r?\\n\\s*\\r?\\n");
StringBuilder toAppend = new StringBuilder();
for (String block : blocks) {
java.util.List<String> keysInBlock = new java.util.ArrayList<>();
for (String line : block.split("\\r?\\n")) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
continue;
}
int eq = trimmed.indexOf('=');
if (eq > 0) {
keysInBlock.add(trimmed.substring(0, eq).trim());
}
}
if (!keysInBlock.isEmpty() && keysInBlock.stream().noneMatch(existing::containsKey)) {
toAppend.append(block.stripTrailing()).append(System.lineSeparator()).append(System.lineSeparator());
}
}
if (toAppend.length() > 0) {
String addition = System.lineSeparator()
+ "# --- nouvelles options ajoutees par une mise a jour du plugin (voir description ci-dessous) ---"
+ System.lineSeparator() + System.lineSeparator()
+ toAppend;
Files.writeString(configFile, addition, java.nio.file.StandardOpenOption.APPEND);
logger.info("config.properties mis a jour avec les nouvelles options manquantes (valeurs existantes conservees).");
}
}
public boolean isMysqlStorage() { public boolean isMysqlStorage() {
return !"yaml".equalsIgnoreCase(storageType); return !"yaml".equalsIgnoreCase(storageType);
} }
@@ -81,6 +142,19 @@ public class VoteConfig {
return directVoteServers.isEmpty() || directVoteServers.contains(serverName.toLowerCase(Locale.ROOT)); return directVoteServers.isEmpty() || directVoteServers.contains(serverName.toLowerCase(Locale.ROOT));
} }
/**
* true si chaque serveur doit avoir son propre compteur de votes en attente independant
* (un /claim sur un serveur ne remet a 0 que ce serveur-la). Sinon (par defaut), un seul
* compteur est partage entre tous les serveurs : /claim le remet a 0 partout a la fois.
*/
public boolean isPerServerScope() {
return "per-server".equalsIgnoreCase(pendingVotesScope);
}
public Set<String> getDirectVoteServers() {
return directVoteServers;
}
public String getJdbcUrl() { public String getJdbcUrl() {
return "jdbc:mysql://" + host + ":" + port + "/" + database return "jdbc:mysql://" + host + ":" + port + "/" + database
+ "?useSSL=false&autoReconnect=true&characterEncoding=utf8"; + "?useSSL=false&autoReconnect=true&characterEncoding=utf8";
@@ -14,9 +14,9 @@ import java.nio.file.Path;
@Plugin( @Plugin(
id = "votenetwork", id = "votenetwork",
name = "VoteNetwork", name = "VoteNetwork",
version = "1.0.0", version = "1.0.2",
description = "Systeme de vote reseau Velocity <-> Spigot", description = "Systeme de vote reseau Velocity <-> Spigot",
authors = {"VoteNetwork"} authors = {"Sar_Tron"}
) )
public class VoteNetworkVelocity { public class VoteNetworkVelocity {
@@ -20,3 +20,17 @@ mysql.pool-size=5
# Laisser vide = tous les serveurs sont consideres comme ayant le plugin (deconseille des # Laisser vide = tous les serveurs sont consideres comme ayant le plugin (deconseille des
# qu'un lobby/hub sans VoteNetwork-Spigot existe sur le reseau). # qu'un lobby/hub sans VoteNetwork-Spigot existe sur le reseau).
direct-vote.servers=gen1,gen2 direct-vote.servers=gen1,gen2
# Portee des votes en attente (joueur hors ligne au moment du vote), uniquement en storage.type=mysql :
# global (par defaut) : un seul compteur partage entre tous les serveurs. Un /claim sur
# N'IMPORTE QUEL serveur remet le compteur a 0 pour TOUS les serveurs a la fois.
# per-server : chaque serveur de direct-vote.servers a son propre compteur independant. Un
# vote hors ligne incremente le compteur de CHAQUE serveur de la liste. Un /claim
# sur le serveur 1 ne remet a 0 QUE le compteur du serveur 1 : le joueur garde ses
# votes en attente sur le serveur 2 et peut les reclamer separement la-bas.
# Exemple concret en per-server : le joueur vote 2 fois hors ligne -> 2 votes en attente sur
# CHAQUE serveur. Il fait /claim sur le serveur 1 -> recoit 2 recompenses, serveur 1 repasse a 0,
# serveur 2 reste a 2. S'il revote une fois -> serveur 1 = 1, serveur 2 = 3.
# Necessite direct-vote.servers rempli ET que chaque serveur Spigot declare son server-name
# (config.yml) correspondant exactement a un nom de cette liste.
pending-votes.scope=global
@@ -0,0 +1,104 @@
package fr.votenetwork.velocity;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.helpers.NOPLogger;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class VoteConfigTest {
@Test
void loadCopiesDefaultsWhenFileMissing(@TempDir Path dataDir) {
VoteConfig config = new VoteConfig(dataDir, NOPLogger.NOP_LOGGER);
config.load();
assertTrue(config.isMysqlStorage());
assertEquals("jdbc:mysql://127.0.0.1:3306/votenetwork?useSSL=false&autoReconnect=true&characterEncoding=utf8",
config.getJdbcUrl());
assertTrue(Files.exists(dataDir.resolve("config.properties")));
}
@Test
void perServerScopeFallsBackToGlobalWhenServerListEmpty(@TempDir Path dataDir) throws IOException {
writeConfig(dataDir, """
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
direct-vote.servers=
pending-votes.scope=per-server
""");
VoteConfig config = new VoteConfig(dataDir, NOPLogger.NOP_LOGGER);
config.load();
assertFalse(config.isPerServerScope(), "sans direct-vote.servers, per-server doit retomber sur global");
}
@Test
void perServerScopeAppliesWhenServersListed(@TempDir Path dataDir) throws IOException {
writeConfig(dataDir, """
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
direct-vote.servers=gen1, GEN2
pending-votes.scope=per-server
""");
VoteConfig config = new VoteConfig(dataDir, NOPLogger.NOP_LOGGER);
config.load();
assertTrue(config.isPerServerScope());
assertTrue(config.isDirectVoteServer("gen1"));
assertTrue(config.isDirectVoteServer("Gen2"), "la comparaison doit ignorer la casse");
assertFalse(config.isDirectVoteServer("lobby"));
}
@Test
void isDirectVoteServerAllowsAllWhenListEmpty(@TempDir Path dataDir) throws IOException {
writeConfig(dataDir, """
storage.type=mysql
direct-vote.servers=
""");
VoteConfig config = new VoteConfig(dataDir, NOPLogger.NOP_LOGGER);
config.load();
assertTrue(config.isDirectVoteServer("n_importe_quoi"));
}
@Test
void mergeAddsMissingKeysWithoutTouchingExistingOnes(@TempDir Path dataDir) throws IOException {
writeConfig(dataDir, """
mysql.password=ne-pas-toucher
""");
VoteConfig config = new VoteConfig(dataDir, NOPLogger.NOP_LOGGER);
config.load();
String fileContent = Files.readString(dataDir.resolve("config.properties"), StandardCharsets.UTF_8);
assertTrue(fileContent.contains("mysql.password=ne-pas-toucher"), "la valeur existante ne doit jamais etre ecrasee");
assertTrue(fileContent.contains("pending-votes.scope="), "les cles manquantes doivent etre ajoutees");
assertEquals("ne-pas-toucher", config.getPassword());
}
private void writeConfig(Path dataDir, String content) throws IOException {
Files.createDirectories(dataDir);
Files.writeString(dataDir.resolve("config.properties"), content, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,55 @@
package fr.votenetwork.velocity;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.slf4j.helpers.NOPLogger;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.junit.jupiter.api.Assertions.assertTrue;
class YamlPendingVoteStoreTest {
@Test
void addPendingVoteCreatesEntryAndIncrements(@TempDir Path dataDir)
throws ExecutionException, InterruptedException, TimeoutException, IOException {
YamlPendingVoteStore store = new YamlPendingVoteStore(dataDir, NOPLogger.NOP_LOGGER);
UUID uuid = UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5");
store.addPendingVote(uuid, "Notch").get(5, TimeUnit.SECONDS);
String content = Files.readString(dataDir.resolve("pending-votes.yml"), StandardCharsets.UTF_8);
assertTrue(content.contains(uuid.toString()));
assertTrue(content.contains("name: Notch"));
assertTrue(content.contains("votes: 1"));
store.addPendingVote(uuid, "Notch").get(5, TimeUnit.SECONDS);
content = Files.readString(dataDir.resolve("pending-votes.yml"), StandardCharsets.UTF_8);
assertTrue(content.contains("votes: 2"), "un deuxieme vote hors ligne doit incrementer le compteur existant");
}
@Test
void addPendingVoteHandlesTwoDifferentPlayersIndependently(@TempDir Path dataDir)
throws ExecutionException, InterruptedException, TimeoutException, IOException {
YamlPendingVoteStore store = new YamlPendingVoteStore(dataDir, NOPLogger.NOP_LOGGER);
UUID uuidA = UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5");
UUID uuidB = UUID.fromString("853c80ef-3c37-49fd-aa49-938b674adae6");
store.addPendingVote(uuidA, "Sar_Tron").get(5, TimeUnit.SECONDS);
store.addPendingVote(uuidB, "Notch").get(5, TimeUnit.SECONDS);
String content = Files.readString(dataDir.resolve("pending-votes.yml"), StandardCharsets.UTF_8);
assertTrue(content.contains(uuidA.toString()));
assertTrue(content.contains(uuidB.toString()));
assertTrue(content.contains("name: Sar_Tron"));
assertTrue(content.contains("name: Notch"));
}
}