feat: SQLite persistence, default /core commands, Bukkit events, bootstrap

CRCore bootstrap class: one-line setup for game plugins (new CRCore(this).enable()).
Wires SQLite, services with event firing, and the /core command tree.

SQLite layer (fr.luc.crcore.database): Database wrapper exposing execute/update/
queryOne/query plus a fluent TableBuilder. ColumnType enum, RowMapper interface,
DatabaseException. Game plugins create their own tables in 2 lines via
db.table("foo").ifNotExists().column(...).create().

Repositories: SqliteTeamRepository and SqlitePlayerProfileRepository extend their
InMemory counterparts (write-through cache). 5 internal tables prefixed crcore_.

Command framework refactored for nested sub-commands: subcommand storage moved
from BaseCommand to AbstractCommand, recursive dispatch() and tabComplete(),
replaceSubCommand() for plugin overrides.

Default /core team commands (13 leaf sub-commands): create, delete, add, remove,
join, leave, info, list, transfer, visibility, score, top, setspawn. Each in its
own class under fr.luc.crcore.command.builtin.team, fully substitutable.

Bukkit events: 9 team events (Create/Dissolve/MemberAdd/MemberRemove/PlayerJoin/
LeadershipTransfer/VisibilityChange/ScoreChange/SpawnPointChange) + 3 player
events (ProfileCreate/Delete/ScoreChange). All post-only, non-cancellable.
BukkitEventFiringTeamServiceImpl and BukkitEventFiringPlayerProfileServiceImpl
override the on* hooks to call Bukkit.getPluginManager().callEvent.

JavaDoc on all new public classes and key existing ones. docs/, GEMINI.md and
PUML diagrams synced: new sections (built-in commands, events, database,
bootstrap), 4 new diagrams (builtin-commands, events, database, bootstrap-
sequence), and 7 new architecture decisions logged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Antone Barbaud
2026-06-09 10:54:00 +02:00
parent ffc77c4213
commit c1b414f400
63 changed files with 3632 additions and 400 deletions
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+38 -19
View File
@@ -8,41 +8,59 @@
être lue depuis `docs/` et écrite dans `docs/`. être lue depuis `docs/` et écrite dans `docs/`.
- Avant d'implémenter, vérifier ce qui est consigné dans `docs/`. - Avant d'implémenter, vérifier ce qui est consigné dans `docs/`.
- Toute nouvelle information donnée par l'utilisateur va dans le fichier adapté : - Toute nouvelle information donnée par l'utilisateur va dans le fichier adapté :
- `docs/features.md` — domaines fonctionnels (team, command, …) - `docs/features.md` — domaines fonctionnels (team, player, commandes built-in, events, database)
- `docs/decisions.md` — décisions techniques / architecturales - `docs/decisions.md` — décisions techniques / architecturales
- `docs/setup.md` — installation, build, intégration côté plugin de jeu - `docs/setup.md` — installation, build, intégration côté plugin de jeu
- `docs/README.md` — vue d'ensemble et index - `docs/README.md` — vue d'ensemble et index
- `docs/diagrams/*.puml` — diagrammes (classe / séquence / activité) - `docs/diagrams/*.puml` — diagrammes (classe / séquence / activité)
- En cas de conflit code ↔ `docs/`, `docs/` fait foi : aligner le code, ou - En cas de conflit code ↔ `docs/`, `docs/` fait foi.
mettre la doc à jour explicitement avec l'utilisateur.
## Nature du projet ## Nature du projet
**CR-Core** est une **librairie Java/Maven pure** — pas un plugin Bukkit. Elle **CR-Core** est une **librairie Java/Maven** consommée par des plugins Bukkit
fournit les briques réutilisables (domaine team, framework de commandes, ("plugins de jeu"). Pas de `plugin.yml` côté core — c'est le plugin de jeu
abstractions communes) que chaque plugin de jeu (futur `CitesPlugin`, etc.) qui en a un et qui instancie {@code CRCore} dans son {@code onEnable()}.
consomme en dépendance Maven.
- **Nom** : CR-Core (artifactId Maven : `CR-Core`) - **Nom** : CR-Core (artifactId Maven : `CR-Core`)
- **Type** : librairie (`jar`) — pas de `plugin.yml`, pas de `JavaPlugin` - **Type** : librairie (`jar`)
- **Cible runtime** : serveur Paper/Spigot 1.16.5 (le plugin de jeu downstream - **Cible runtime** : serveur Paper/Spigot 1.16.5
est responsable du `plugin.yml` et de l'enregistrement des commandes)
- **Build** : Maven, Java 16 - **Build** : Maven, Java 16
- **Package racine** : `fr.luc.crcore` - **Package racine** : `fr.luc.crcore`
- **SQLite** : `org.xerial:sqlite-jdbc` (compile scope)
## Domaines
- `fr.luc.crcore.common` — abstractions partagées (`Identifiable`, `Named`,
`ScoreHolder`, `AbstractEntity`, `Repository<T>`)
- `fr.luc.crcore.team` — équipes : visibilité (PUBLIC/PRIVATE), membres,
leader, scores nommés, classements, point de spawn. Sous-package `event/`
pour les évènements Bukkit.
- `fr.luc.crcore.player` — profils joueurs : scores nommés, classements
individuels. Sous-package `event/`.
- `fr.luc.crcore.command` — framework de commandes (nested sub-commands,
arguments typés, tab-complete). Sous-package `builtin/` pour les commandes
par défaut `/core team ...`.
- `fr.luc.crcore.database` — wrapper SQLite minimaliste (`Database`,
`TableBuilder`, `RowMapper`).
- `fr.luc.crcore.CRCore` — point d'entrée bootstrap (instancié en une ligne
par le plugin de jeu).
## Principe : simple par défaut, overridable partout ## Principe : simple par défaut, overridable partout
Toutes les classes du noyau sont conçues pour être étendues. Les services Chaque service expose des **hooks `protected`** que les plugins de jeu peuvent
fournissent une implémentation "tout faite" (ex. `TeamServiceImpl`), mais chaque overrider :
étape importante est exposée via une méthode `protected` (`newTeam`, - factories (`newTeam`, `newRanking`, `newProfile`)
`validateName`, `onAfterCreate`, …) qu'une sous-classe peut overrider. - validations (`validateName`, `validateJoinable`, …)
- post-hooks (`onAfterCreate`, `onMemberAdded`, `onScoreChanged`, …)
Chaque commande built-in (`TeamCreateSubCommand`, etc.) est elle aussi
substituable par nom via `replaceSubCommand(...)`.
**Règles** : **Règles** :
- Pas de `final` sur les classes du noyau, sauf raison forte. - Pas de `final` sur les classes du noyau.
- Méthodes-clés en `protected` (pas `private`) pour permettre l'override. - Méthodes-clés en `protected` (pas `private`).
- Hooks `onBefore...` / `onAfter...` partout où ça a du sens. - Hooks `onBefore...` / `onAfter...` partout où ça a du sens.
- Factories (`newTeam`, `newMember`, …) pour permettre de substituer des - Factories pour permettre les sous-classes.
sous-classes sans réécrire le service.
## Workflow attendu ## Workflow attendu
@@ -54,6 +72,7 @@ fournissent une implémentation "tout faite" (ex. `TeamServiceImpl`), mais chaqu
## Conventions de code ## Conventions de code
- Code (classes, méthodes, attributs, variables) en **anglais standard**. - Code (classes, méthodes, attributs, variables) en **anglais standard**.
- Messages joueur et documentation en français. - Messages joueur et documentation en **français**.
- JavaDoc en français sur les classes publiques et les méthodes non triviales.
- Séparation stricte : `interface`, `enum`, `abstract class`, `class` concrète, - Séparation stricte : `interface`, `enum`, `abstract class`, `class` concrète,
`exception` — chacun dans son fichier. `exception` — chacun dans son fichier.
+31 -20
View File
@@ -5,29 +5,35 @@ mécaniques, règles et spécifications du noyau sont consignées ici.
## Objectif du projet ## Objectif du projet
**CR-Core** est une **librairie Maven** réutilisable pour construire des plugins **CR-Core** est une **librairie Maven** réutilisable pour construire des
Minecraft. Elle fournit : plugins Minecraft Paper 1.16.5. Elle fournit, prêt à l'emploi en une ligne
d'initialisation côté plugin de jeu :
- des abstractions communes (`Identifiable`, `Named`, `ScoreHolder`, - **Abstractions communes** — `Identifiable`, `Named`, `ScoreHolder`,
`AbstractEntity`, `Repository<T>`) ; `AbstractEntity`, `Repository<T>`.
- un **domaine équipes** clé en main (`Team`, `TeamMember`, `TeamService`, - **Domaine Team** — équipes (nom, tag, couleur, chef, membres,
`TeamRepository`) — visibilité, scores, classements, spawn, overridable ; visibilité PUBLIC/PRIVATE, scores nommés, classements, point de spawn),
- un **domaine profils joueurs** (`PlayerProfile`, `PlayerProfileService`) — service overridable, exceptions dédiées.
scores nommés par joueur, classements individuels ; - **Domaine Player** — profils joueurs (scores nommés, classements
- un **framework de commandes** (`BaseCommand`, `SubCommand`, `ArgumentType`, individuels), service auto-créant les profils à la demande.
tab-completion intégrée). - **Framework de commandes** — `BaseCommand` / `SubCommand` imbriqués,
arguments typés, tab-complétion, permissions, player-only.
Les plugins de jeu (futur `CitesPlugin`, BedWars, etc.) déclarent CR-Core en - **Commandes par défaut** — `/core team [create|delete|add|remove|join|`
dépendance Maven `provided` (ou shadent la lib) et l'utilisent directement. `leave|info|list|transfer|visibility|score|top|setspawn]` fonctionnelles
out-of-the-box, chacune substituable par sous-classe.
- Cible runtime : **Minecraft 1.16.5** (API Paper). - **Évènements Bukkit** — 9 events team + 3 events player, à écouter avec
- Build : Maven, Java 16. `@EventHandler` côté plugin de jeu.
- **Persistance SQLite** — wrapper `Database` + `TableBuilder` fluide,
repositories SQLite write-through, table custom en 2 lignes pour les
plugins downstream.
- **Bootstrap unique** — `new CRCore(this).enable()` dans le `onEnable()`
du plugin de jeu, et tout est branché.
## Structure de la documentation ## Structure de la documentation
- `README.md` — Ce fichier. Vue d'ensemble et index. - `README.md` — Ce fichier. Vue d'ensemble et index.
- `setup.md` — Build, intégration dans un plugin de jeu, exemple d'usage. - `setup.md` — Build, intégration dans un plugin de jeu, exemple d'usage.
- `features.md` — Domaines fonctionnels (team, command). - `features.md` — Domaines fonctionnels détaillés.
- `decisions.md` — Journal des décisions importantes (ADR léger). - `decisions.md` — Journal des décisions importantes (ADR léger).
- `diagrams/` — Diagrammes PlantUML (`.puml`). - `diagrams/` — Diagrammes PlantUML (`.puml`).
@@ -36,17 +42,22 @@ dépendance Maven `provided` (ou shadent la lib) et l'utilisent directement.
| Fichier | Type | Sujet | | Fichier | Type | Sujet |
|---|---|---| |---|---|---|
| [team-class-diagram.puml](diagrams/team-class-diagram.puml) | Classe | Domaine Team + abstractions communes | | [team-class-diagram.puml](diagrams/team-class-diagram.puml) | Classe | Domaine Team + abstractions communes |
| [team-create-sequence.puml](diagrams/team-create-sequence.puml) | Séquence | Création d'une équipe | | [team-create-sequence.puml](diagrams/team-create-sequence.puml) | Séquence | Création d'une équipe via la commande |
| [team-join-sequence.puml](diagrams/team-join-sequence.puml) | Séquence | Auto-join sur une équipe publique | | [team-join-sequence.puml](diagrams/team-join-sequence.puml) | Séquence | Auto-join sur une équipe publique |
| [team-create-activity.puml](diagrams/team-create-activity.puml) | Activité | Flux de validation à la création | | [team-create-activity.puml](diagrams/team-create-activity.puml) | Activité | Flux de validation à la création |
| [player-class-diagram.puml](diagrams/player-class-diagram.puml) | Classe | Domaine Player (profils + scores + classements) | | [player-class-diagram.puml](diagrams/player-class-diagram.puml) | Classe | Domaine Player + scores joueur |
| [command-class-diagram.puml](diagrams/command-class-diagram.puml) | Classe | Framework de commandes | | [command-class-diagram.puml](diagrams/command-class-diagram.puml) | Classe | Framework de commandes (nested) |
| [builtin-commands-diagram.puml](diagrams/builtin-commands-diagram.puml) | Classe | Arbre des commandes `/core team ...` |
| [events-diagram.puml](diagrams/events-diagram.puml) | Classe | Évènements Bukkit team + player |
| [database-diagram.puml](diagrams/database-diagram.puml) | Classe | Wrapper SQLite + table builder |
| [bootstrap-sequence.puml](diagrams/bootstrap-sequence.puml) | Séquence | `CRCore.enable()` côté plugin de jeu |
## Conventions ## Conventions
- Code : **anglais standard**, séparation stricte interfaces / enums / classes - Code : **anglais standard**, séparation stricte interfaces / enums / classes
abstraites / classes concrètes / exceptions. abstraites / classes concrètes / exceptions.
- Doc & messages joueur : **français**. - Doc & messages joueur : **français**.
- JavaDoc en français sur les classes publiques et méthodes non triviales.
- Package racine : `fr.luc.crcore`. - Package racine : `fr.luc.crcore`.
- Classes du noyau **non-`final`**, méthodes-clés `protected`, hooks - Classes du noyau **non-`final`**, méthodes-clés `protected`, hooks
`onBefore…`/`onAfter…` et factories `new…` pour l'override. `onBefore…`/`onAfter…` et factories `new…` pour l'override.
+90
View File
@@ -232,3 +232,93 @@ Format léger : une décision = un titre + contexte + choix + raison.
pour ~8 méthodes simples. Choix actuel : duplication contrôlée des pour ~8 méthodes simples. Choix actuel : duplication contrôlée des
implémentations (Map + getters/setters), interface commune pour le implémentations (Map + getters/setters), interface commune pour le
contrat. contrat.
## 2026-06-09 — CRCore = bootstrap library, pas un plugin
- **Choix** : CR-Core reste une **librairie** (pas de `plugin.yml`, pas de
`JavaPlugin`). Le plugin de jeu downstream instancie `new CRCore(this)` dans
son `onEnable()` et appelle `.enable()` — c'est ce qui câble SQLite,
services, commandes et events.
- **Alternative écartée** : faire de CR-Core un plugin standalone (à
installer côté serveur). Refusé pour deux raisons : (1) chaque jeu a son
propre état (registre d'équipes, scores) — on ne veut pas partager entre
jeux par défaut ; (2) la friction de déploiement (2 jars sur le serveur)
est inutile pour des plugins shadés.
- **Conséquence** : chaque plugin de jeu shade CR-Core, a sa propre DB SQLite
dans son `dataFolder`, et déclare la commande Bukkit racine (`core` par
défaut) dans son `plugin.yml`.
## 2026-06-09 — Sous-commandes imbriquées récursives
- **Choix** : `AbstractCommand` porte la table des sous-commandes
(pas seulement `BaseCommand`). `SubCommand` peut donc avoir ses propres
sous-commandes (récursion). Routage via la méthode `dispatch(...)`
récursive.
- **Raison** : c'est ce qui permet `/core team create` (3 niveaux : root /
group / leaf). Sans ça, il faudrait flatter en `/core team-create` ou faire
du routage manuel dans chaque groupe.
- **Conséquence** : `BaseCommand` ne fait plus que pont Bukkit
(`CommandExecutor`/`TabCompleter``dispatch`) ; toute la logique de
routage vit dans `AbstractCommand`.
## 2026-06-09 — Override par sous-classe + `replaceSubCommand`
- **Choix** : `AbstractCommand.replaceSubCommand(name, newSub)` permet aux
plugins de jeu de remplacer une feuille (ex. `TeamCreateSubCommand`) par
leur propre implémentation, sans tout recâbler.
- **Raison** : le user a explicitement demandé "Les futures plugins ne
feront qu'override les fonctions si besoin". Cette méthode + le fait que
les classes ne soient pas `final` couvre les deux patterns :
- **Remplacement par instance** : `team.replaceSubCommand("create", new MyCreate(svc))`
- **Override par héritage** : `extends TeamCreateSubCommand` + `super.execute(ctx)`
## 2026-06-09 — Évènements Bukkit : post-only, non-cancellable
- **Choix** : tous les évènements CR-Core (team + player) sont **post-events**,
tirés via les hooks `on*` après commit. Aucun n'implémente `Cancellable`.
- **Raison** : la validation pré-action vit côté service dans les hooks
`validate*` (overridables). Mélanger pré-cancellable côté event et hooks
côté service dédoublerait les points de blocage. Pour bloquer un comportement,
le pattern est : override le hook `validate*` du service.
- **Boilerplate** : chaque event a sa propre `HandlerList` statique
(contrainte Bukkit, pas de moyen de partager via héritage). 12 events =
12 occurrences du même pattern, accepté pour rester idiomatique Bukkit.
## 2026-06-09 — SQLite write-through cache pour les repositories
- **Choix** : `SqliteTeamRepository` et `SqlitePlayerProfileRepository`
**étendent** leurs jumeaux `InMemory*` et overrident `save`/`delete` pour
persister synchronement vers SQLite. Au démarrage, `loadAll()` recharge
tout le state depuis la DB dans le cache mémoire.
- **Raison** : les lectures (findAll, findByName, classements en
`Collection<Team>.stream().sorted(...)`) restent rapides — pas de hit DB.
Les écritures vont en DB synchronement (acceptable au rythme des actions
joueur, qui sont rares à l'échelle d'un event entre amis).
- **Approche delete + reinsert pour les collections** : sur `save()`, on
remplace en bloc les `team_members` et `team_scores` d'une équipe (DELETE
puis INSERT). Plus simple et moins bug-prone qu'un diff fin, et négligeable
en perf pour des équipes de quelques joueurs.
## 2026-06-09 — Type Database minimaliste plutôt qu'un ORM
- **Choix** : `Database` expose 4 méthodes (execute / update / queryOne /
query) + un `TableBuilder` fluide. Pas d'ORM, pas d'annotations
d'entités, pas de DSL SQL.
- **Raison** : un ORM ajouterait une dépendance lourde (Hibernate / jOOQ /
…), un poids de classloading non négligeable côté serveur Bukkit, et
abstrairait des opérations qu'on veut garder triviales et lisibles. SQL
brut + PreparedStatement est largement suffisant pour les volumes d'un
serveur d'event.
- **Le `TableBuilder`** existe pour répondre au "pouvoir rapidement et
simplement créer des tables" — c'est l'API la plus user-friendly à proposer.
Pour les cas avancés (FOREIGN KEY, contraintes composites), l'utilisateur
passe par `db.execute("CREATE TABLE ...")` direct.
## 2026-06-09 — Préfixe `crcore_` sur toutes les tables internes
- **Choix** : `crcore_teams`, `crcore_team_members`, `crcore_team_scores`,
`crcore_player_profiles`, `crcore_player_scores`.
- **Raison** : éviter les collisions avec les tables custom que les plugins
de jeu créent dans la même DB. CR-Core et le plugin de jeu partagent le
même fichier SQLite (par défaut `<dataFolder>/crcore.db`) ; le préfixe
isole proprement.
+62
View File
@@ -0,0 +1,62 @@
@startuml bootstrap-sequence
title CR-Core — Bootstrap from a game plugin (onEnable)
participant "MyGamePlugin\nextends JavaPlugin" as Plugin
participant "CRCore" as Core
participant "Database" as DB
participant "SqliteTeamRepository" as TeamRepo
participant "SqlitePlayerProfileRepository" as PlayerRepo
participant "BukkitEventFiringTeamServiceImpl" as TeamSvc
participant "BukkitEventFiringPlayerProfileServiceImpl" as PlayerSvc
participant "CoreCommand\n(/core)" as Cmd
participant "Bukkit" as Bukkit
Plugin -> Core : new CRCore(this)
activate Core
Plugin -> Core : enable()
Core -> DB : new Database(<dataFolder>/crcore.db)
activate DB
DB -> DB : ensure parent dir + PRAGMA foreign_keys
DB --> Core
deactivate DB
Core -> TeamRepo : new SqliteTeamRepository(db)
activate TeamRepo
TeamRepo -> DB : ensureSchema() — crcore_teams + crcore_team_members + crcore_team_scores
TeamRepo -> DB : loadAll() — SELECT pour ré-hydrater le cache mémoire
TeamRepo --> Core
deactivate TeamRepo
Core -> PlayerRepo : new SqlitePlayerProfileRepository(db)
activate PlayerRepo
PlayerRepo -> DB : ensureSchema() — crcore_player_profiles + crcore_player_scores
PlayerRepo -> DB : loadAll()
PlayerRepo --> Core
deactivate PlayerRepo
Core -> TeamSvc : buildTeamService(teamRepo)
Core -> PlayerSvc : buildPlayerProfileService(playerRepo)
Core -> Cmd : new CoreCommand(teamSvc, playerSvc)
activate Cmd
Cmd -> Cmd : registerDefaults() — TeamGroupSubCommand avec 13 leaf sub-cmds
Cmd --> Core
deactivate Cmd
Core -> Bukkit : plugin.getCommand("core").setExecutor(cmd)
Core -> Bukkit : .setTabCompleter(cmd)
Core --> Plugin : this (chainable)
deactivate Core
note over Plugin
À ce stade :
- /core team create/delete/add/... fonctionnel
- SQLite persiste team + player + leurs scores
- Évènements Bukkit sont tirés sur chaque opération
- Le plugin de jeu peut listen avec @EventHandler
end note
@enduml
@@ -0,0 +1,76 @@
@startuml builtin-commands-diagram
title CR-Core — Default /core team commands
skinparam classAttributeIconSize 0
hide empty members
package "fr.luc.crcore.command" {
abstract class BaseCommand
abstract class SubCommand
}
package "fr.luc.crcore.command.builtin" {
class CoreCommand {
+ CoreCommand(teamSvc, playerSvc)
# registerDefaults(): void
}
CoreCommand --|> BaseCommand
package "fr.luc.crcore.command.builtin.team" {
class TeamGroupSubCommand {
+ TeamGroupSubCommand(service)
# registerDefaults(): void
}
TeamGroupSubCommand --|> SubCommand
class TeamArgumentTypes <<utility>> {
+ {static} teamByName(service): ArgumentType<Team>
}
class TeamCreateSubCommand {
+ execute(ctx): CommandResult
}
class TeamDeleteSubCommand
class TeamAddSubCommand
class TeamRemoveSubCommand
class TeamJoinSubCommand
class TeamLeaveSubCommand
class TeamInfoSubCommand
class TeamListSubCommand
class TeamTransferSubCommand
class TeamVisibilitySubCommand
class TeamScoreSubCommand
class TeamTopSubCommand
class TeamSetSpawnSubCommand
TeamCreateSubCommand --|> SubCommand
TeamDeleteSubCommand --|> SubCommand
TeamAddSubCommand --|> SubCommand
TeamRemoveSubCommand --|> SubCommand
TeamJoinSubCommand --|> SubCommand
TeamLeaveSubCommand --|> SubCommand
TeamInfoSubCommand --|> SubCommand
TeamListSubCommand --|> SubCommand
TeamTransferSubCommand --|> SubCommand
TeamVisibilitySubCommand --|> SubCommand
TeamScoreSubCommand --|> SubCommand
TeamTopSubCommand --|> SubCommand
TeamSetSpawnSubCommand --|> SubCommand
CoreCommand "1" *-- "1" TeamGroupSubCommand : contains
TeamGroupSubCommand "1" *-- "13" SubCommand : contains
}
}
note right of CoreCommand
Le plugin de jeu downstream
remplace une feuille avec :
core.getCoreCommand()
.findSubCommand("team")
.replaceSubCommand("create",
new MyCreate(svc));
end note
@enduml
+41 -49
View File
@@ -1,5 +1,5 @@
@startuml command-class-diagram @startuml command-class-diagram
title CR-Core — Command framework (class diagram) title CR-Core — Command framework (class diagram, nested sub-commands)
skinparam classAttributeIconSize 0 skinparam classAttributeIconSize 0
hide empty members hide empty members
@@ -13,7 +13,7 @@ package "fr.luc.crcore.command" {
+ isPlayerOnly(): boolean + isPlayerOnly(): boolean
+ getDescription(): String + getDescription(): String
+ execute(ctx: CommandContext): CommandResult + execute(ctx: CommandContext): CommandResult
+ tabComplete(sender, argIndex, partial): List<String> + tabComplete(sender, args: String[]): List<String>
+ matches(label: String): boolean + matches(label: String): boolean
} }
@@ -25,46 +25,41 @@ package "fr.luc.crcore.command" {
- description: String - description: String
- usage: String - usage: String
- arguments: List<ArgumentDef> - arguments: List<ArgumentDef>
# addAlias(...): void - subCommandsByName: Map<String, SubCommand>
# permission(p): void - subCommandsByAlias: Map<String, SubCommand>
# playerOnly(): void --
# description(d): void # addAlias(...) / permission / playerOnly / description / usage
# usage(u): void # argument(name, type) / optionalArgument(name, type)
# argument(name, type): void # addSubCommand(sub: SubCommand): void
# optionalArgument(name, type): void --
# buildContext(sender, label, subArgs): CommandContext + findSubCommand(label): Optional<SubCommand>
+ getRequiredArgumentCount(): int + getSubCommands(): Collection<SubCommand>
+ getTotalArgumentCount(): int + replaceSubCommand(name, newSub): Optional<SubCommand>
+ getUsage(): String + hasSubCommands(): boolean
--
+ dispatch(sender, label, args): CommandResult
+ tabComplete(sender, args): List<String>
+ execute(ctx): CommandResult
# listSubCommands(ctx): CommandResult
# checkAccess(sender): boolean
# buildContext(sender, label, rawArgs): CommandContext
} }
abstract class BaseCommand { abstract class BaseCommand {
- subCommandsByName: Map<String, SubCommand>
- subCommandsByAlias: Map<String, SubCommand>
# addSubCommand(sub: SubCommand): void
+ findSubCommand(label: String): Optional<SubCommand>
+ getSubCommands(): Collection<SubCommand>
# execute(ctx): CommandResult
+ onCommand(sender, cmd, label, args): boolean + onCommand(sender, cmd, label, args): boolean
+ onTabComplete(sender, cmd, alias, args): List<String> + onTabComplete(sender, cmd, alias, args): List<String>
# checkAccess(sender, target): boolean
# handleResult(sender, result): void # handleResult(sender, result): void
} }
abstract class SubCommand { abstract class SubCommand
+ {abstract} execute(ctx: CommandContext): CommandResult
}
class CommandContext { class CommandContext {
- sender: CommandSender - sender: CommandSender
- label: String - label: String
- rawArgs: String[] - rawArgs: String[]
- parsedArgs: Map<String, Object> - parsedArgs: Map<String, Object>
+ getSender(): CommandSender + getSender / isPlayer / getPlayer / requirePlayer
+ isPlayer(): boolean + get(name): T
+ getPlayer(): Optional<Player>
+ requirePlayer(): Player
+ get(name: String): T
+ getOptional(name): Optional<T> + getOptional(name): Optional<T>
+ has(name): boolean + has(name): boolean
+ reply(msg): void + reply(msg): void
@@ -73,15 +68,7 @@ package "fr.luc.crcore.command" {
class CommandResult { class CommandResult {
- type: Type - type: Type
- message: String - message: String
+ getType(): Type + {static} success / failure / invalidUsage / noPermission / playerOnly
+ getMessage(): String
+ isSuccess(): boolean
+ {static} success(): CommandResult
+ {static} success(msg): CommandResult
+ {static} failure(msg): CommandResult
+ {static} invalidUsage(): CommandResult
+ {static} noPermission(): CommandResult
+ {static} playerOnly(): CommandResult
} }
enum "CommandResult.Type" as ResultType { enum "CommandResult.Type" as ResultType {
@@ -99,17 +86,13 @@ package "fr.luc.crcore.command" {
+ suggestions(sender, partial): List<String> + suggestions(sender, partial): List<String>
} }
class ArgumentTypes << (S, #FFC107) static >> { class ArgumentTypes <<utility>> {
+ {static} STRING: ArgumentType<String> + STRING / INTEGER / DOUBLE / BOOLEAN / ONLINE_PLAYER
+ {static} INTEGER: ArgumentType<Integer> + enumOf(Class<E>): ArgumentType<E>
+ {static} DOUBLE: ArgumentType<Double> + choice(String...): ArgumentType<String>
+ {static} BOOLEAN: ArgumentType<Boolean>
+ {static} ONLINE_PLAYER: ArgumentType<Player>
+ {static} enumOf(type): ArgumentType<E>
+ {static} choice(choices): ArgumentType<String>
} }
class ArgumentDef << (P, #BBBBBB) package-private >> { class ArgumentDef <<package-private>> {
- name: String - name: String
- type: ArgumentType<?> - type: ArgumentType<?>
- required: boolean - required: boolean
@@ -118,15 +101,24 @@ package "fr.luc.crcore.command" {
AbstractCommand ..|> Command AbstractCommand ..|> Command
BaseCommand --|> AbstractCommand BaseCommand --|> AbstractCommand
SubCommand --|> AbstractCommand SubCommand --|> AbstractCommand
BaseCommand "1" o-- "*" SubCommand : subCommands BaseCommand ..|> "org.bukkit.command.CommandExecutor"
BaseCommand ..|> "org.bukkit.command.TabCompleter"
AbstractCommand "1" o-- "*" SubCommand : sub-commands\n(recursive)
AbstractCommand "1" *-- "*" ArgumentDef : arguments AbstractCommand "1" *-- "*" ArgumentDef : arguments
ArgumentDef --> ArgumentType ArgumentDef --> ArgumentType
CommandResult +-- ResultType CommandResult +-- ResultType
CommandException --|> RuntimeException CommandException --|> RuntimeException
BaseCommand ..> CommandContext : creates
AbstractCommand ..> CommandContext : creates AbstractCommand ..> CommandContext : creates
SubCommand ..> CommandResult : returns AbstractCommand ..> CommandResult : returns
} }
note bottom of AbstractCommand
Le routage est récursif :
/core team create → CoreCommand.dispatch("team", ["create",...])
→ TeamGroup.dispatch("create", [...])
→ TeamCreate.execute(ctx)
end note
@enduml @enduml
+86
View File
@@ -0,0 +1,86 @@
@startuml database-diagram
title CR-Core — Database (SQLite wrapper)
skinparam classAttributeIconSize 0
hide empty members
package "fr.luc.crcore.database" {
class Database {
- connection: Connection
+ Database(file: File)
+ execute(sql, params...): void
+ update(sql, params...): int
+ queryOne(sql, mapper, params...): Optional<T>
+ query(sql, mapper, params...): List<T>
+ inTransaction(block: Runnable): void
+ table(name: String): TableBuilder
+ tableExists(name: String): boolean
+ getConnection(): Connection
+ close(): void
}
Database ..|> "java.lang.AutoCloseable"
class TableBuilder {
- database: Database
- name: String
- columns: List<ColumnDef>
- ifNotExists: boolean
+ ifNotExists(): TableBuilder
+ column(name, type): ColumnDef
+ create(): void
}
class "TableBuilder.ColumnDef" as ColumnDef {
- name: String
- type: ColumnType
- primaryKey: boolean
- notNull: boolean
- unique: boolean
- defaultValue: String
+ primaryKey(): ColumnDef
+ notNull(): ColumnDef
+ unique(): ColumnDef
+ defaultValue(expr: String): ColumnDef
+ column(name, type): ColumnDef
+ create(): void
}
enum ColumnType {
INTEGER
REAL
TEXT
BLOB
BOOLEAN
UUID
--
+ getSqlType(): String
}
interface "RowMapper<T>" as RowMapper {
+ map(rs: ResultSet): T
}
class DatabaseException
DatabaseException --|> RuntimeException
Database "1" *-- "*" TableBuilder : creates
TableBuilder "1" *-- "*" ColumnDef : contains
ColumnDef --> ColumnType : type
Database ..> RowMapper : uses
Database ..> DatabaseException : throws
}
note right of Database
Repositories SQLite de CR-Core
(SqliteTeamRepository,
SqlitePlayerProfileRepository)
utilisent Database pour
persister state team/player.
Les plugins de jeu utilisent
Database.table(...) pour
créer leurs tables custom.
end note
@enduml
+89
View File
@@ -0,0 +1,89 @@
@startuml events-diagram
title CR-Core — Bukkit events (team + player)
skinparam classAttributeIconSize 0
hide empty members
package "org.bukkit.event" {
abstract class Event
}
package "fr.luc.crcore.team.event" {
abstract class TeamEvent {
- team: Team
+ getTeam(): Team
}
TeamEvent --|> Event
class TeamCreateEvent
class TeamDissolveEvent
class TeamMemberAddEvent {
+ getMember(): TeamMember
}
class TeamMemberRemoveEvent {
+ getPlayerId(): UUID
}
class PlayerJoinTeamEvent {
+ getMember(): TeamMember
}
class TeamLeadershipTransferEvent {
+ getOldLeaderId(): UUID
+ getNewLeaderId(): UUID
}
class TeamVisibilityChangeEvent {
+ getOldVisibility(): TeamVisibility
+ getNewVisibility(): TeamVisibility
}
class TeamScoreChangeEvent {
+ getScoreName(): String
+ getOldValue(): int
+ getNewValue(): int
+ getDelta(): int
}
class TeamSpawnPointChangeEvent {
+ getOldLocation(): Location
+ getNewLocation(): Location
}
TeamCreateEvent --|> TeamEvent
TeamDissolveEvent --|> TeamEvent
TeamMemberAddEvent --|> TeamEvent
TeamMemberRemoveEvent --|> TeamEvent
PlayerJoinTeamEvent --|> TeamEvent
TeamLeadershipTransferEvent --|> TeamEvent
TeamVisibilityChangeEvent --|> TeamEvent
TeamScoreChangeEvent --|> TeamEvent
TeamSpawnPointChangeEvent --|> TeamEvent
}
package "fr.luc.crcore.player.event" {
abstract class PlayerProfileEvent {
- profile: PlayerProfile
+ getProfile(): PlayerProfile
}
PlayerProfileEvent --|> Event
class PlayerProfileCreateEvent
class PlayerProfileDeleteEvent
class PlayerScoreChangeEvent {
+ getScoreName(): String
+ getOldValue(): int
+ getNewValue(): int
+ getDelta(): int
}
PlayerProfileCreateEvent --|> PlayerProfileEvent
PlayerProfileDeleteEvent --|> PlayerProfileEvent
PlayerScoreChangeEvent --|> PlayerProfileEvent
}
note right of TeamEvent
Tous post-events, non-cancellable.
Tirés par les sous-classes
BukkitEventFiring*ServiceImpl
via les hooks on* hérités.
end note
@enduml
+254 -15
View File
@@ -1,7 +1,19 @@
# Domaines fonctionnels # Domaines fonctionnels
CR-Core est une librairie. Chaque domaine est autonome ; le plugin de jeu CR-Core est une librairie. Le plugin de jeu downstream l'instancie en une
downstream pioche ce qu'il utilise. ligne via `new CRCore(this).enable()` dans son `onEnable()`, et tout est
branché : SQLite, services team + player, commandes `/core team ...`,
évènements Bukkit.
Architecture des domaines :
1. **Team** — équipes (membres, leader, visibilité, scores, classements, spawn)
2. **Player** — profils joueurs (scores nommés, classements individuels)
3. **Framework de commandes**`BaseCommand` / `SubCommand` imbriqués
4. **Commandes built-in**`/core team [create|delete|add|remove|join|leave|...]`
5. **Évènements Bukkit** — 9 events team + 3 events player
6. **Database** — wrapper SQLite + table builder pour les plugins downstream
7. **Bootstrap** — classe `CRCore` qui câble tout
--- ---
@@ -245,24 +257,29 @@ UUID pour rester déterministe).
## 3. Framework de commandes ## 3. Framework de commandes
**Statut** : framework implémenté. Pas de commande Team intégrée — c'est au **Statut** : framework implémenté avec **sous-commandes imbriquées récursives**
plugin de jeu de définir ses commandes en utilisant les briques fournies. (supporte `/core team create`, `/core team join`, etc.). Les commandes par
défaut sont fournies en section 4.
### Architecture ### Architecture
- **`Command`** (interface) — contrat partagé : `getName()`, `getAliases()`, - **`Command`** (interface) — contrat partagé : `getName()`, `getAliases()`,
`getPermission()`, `isPlayerOnly()`, `getDescription()`, `execute(ctx)`, `getPermission()`, `isPlayerOnly()`, `getDescription()`, `execute(ctx)`,
`tabComplete(sender, argIndex, partial)`, `matches(label)`. `tabComplete(sender, args)`, `matches(label)`.
- **`AbstractCommand`** (abstract, implémente `Command`) — porte tous les - **`AbstractCommand`** (abstract, implémente `Command`) — porte tous les
champs : nom, aliases, permission, player-only, description, usage, champs ET la table des sous-commandes (imbrication). Méthodes builder en
arguments. Méthodes builder en `protected` : `addAlias`, `permission`, `protected` : `addAlias`, `permission`, `playerOnly`, `description`, `usage`,
`playerOnly`, `description`, `usage`, `argument`, `optionalArgument`. `argument`, `optionalArgument`, `addSubCommand`. Routage récursif via
- **`BaseCommand extends AbstractCommand`** — implémente aussi `dispatch(...)` et `tabComplete(...)`.
`CommandExecutor` et `TabCompleter` de Bukkit. Conteneur de `SubCommand`, - **`BaseCommand extends AbstractCommand`** — implémente `CommandExecutor` et
fait le routage `args[0]` → sous-commande, gère permissions, player-only, `TabCompleter` de Bukkit, branche `onCommand``dispatch`. À utiliser pour
invalid usage, affichage de l'aide par défaut. la racine d'un arbre (`/core`).
- **`SubCommand extends AbstractCommand`** — sous-commande sans logique - **`SubCommand extends AbstractCommand`** — sous-commande ; peut être
Bukkit. La méthode abstraite `execute(CommandContext)` est à implémenter. feuille (override `execute`) ou groupe (appelle `addSubCommand` dans son
constructeur).
- **`replaceSubCommand(name, newSub)`** sur `AbstractCommand` — permet aux
plugins de jeu de remplacer une sous-commande par défaut par leur propre
implémentation (pattern standard d'override).
### Types d'arguments (`ArgumentTypes`) ### Types d'arguments (`ArgumentTypes`)
@@ -307,6 +324,228 @@ Voir [setup.md](setup.md#utilisation-depuis-un-plugin-de-jeu).
--- ---
## 4. Commandes built-in `/core team ...`
**Statut** : 13 sous-commandes prêtes à l'emploi, branchées par
`CRCore.enable()`. Chaque sous-commande vit dans
`fr.luc.crcore.command.builtin.team` et est substituable individuellement par
sous-classe ou via `replaceSubCommand`.
### Arborescence
```
/core (CoreCommand, BaseCommand racine, aliases: cr, crcore)
└── team (TeamGroupSubCommand, alias: t)
├── create <name> <tag> <color> [visibility] — créer une équipe
├── delete — dissoudre son équipe (chef)
├── add <player> — chef ajoute un membre
├── remove <player> — chef retire un membre
├── join <name> — auto-join sur team PUBLIC
├── leave — quitter son équipe
├── info [name] — infos d'une équipe
├── list — liste toutes les équipes
├── transfer <player> — transfert de leadership
├── visibility <PUBLIC|PRIVATE> — changer la visibilité
├── score <team> <name> <add|set> <value> — [admin] modifier un score
├── top [score] — classement (par score ou global)
└── setspawn — chef définit le spawn
```
### Aliases supplémentaires (équivalents Bukkit)
| Sous-commande | Aliases |
|---|---|
| `create` | `c`, `new` |
| `delete` | `disband`, `dissolve` |
| `add` | `invite` |
| `remove` | `kick`, `expel` |
| `join` | `j` |
| `leave` | `quit` |
| `info` | `i` |
| `list` | `ls` |
| `visibility` | `vis` |
| `top` | `ranking`, `leaderboard` |
| `setspawn` | `spawn` |
### Permissions par défaut
| Sous-commande | Permission |
|---|---|
| `create` | `crcore.team.create` |
| `score` | `crcore.team.score.modify` (admin) |
| autres | aucune (gated par appartenance / rôle de chef) |
### Override d'une sous-commande par défaut
```java
// Option A : remplacer une feuille
core.getCoreCommand().findSubCommand("team")
.ifPresent(team -> team.replaceSubCommand("create",
new MyCustomTeamCreate(core.getTeamService())));
// Option B : sous-classer et override execute()
public class MyTeamCreate extends TeamCreateSubCommand {
public MyTeamCreate(TeamService service) { super(service); }
@Override public CommandResult execute(CommandContext ctx) {
// règles métier custom puis fallback super
return super.execute(ctx);
}
}
```
### Diagramme
- Classes : [builtin-commands-diagram.puml](diagrams/builtin-commands-diagram.puml)
---
## 5. Évènements Bukkit
**Statut** : 12 évènements implémentés, tirés automatiquement par les services
par défaut (`BukkitEventFiringTeamServiceImpl` et `BukkitEventFiringPlayerProfileServiceImpl`).
Tous les évènements sont **post** (non-cancellable) — la validation se fait en
amont dans les services via les hooks `validate*`. Pour bloquer un comportement,
override le hook ou la sous-commande, pas l'évènement.
### Évènements Team (`fr.luc.crcore.team.event`)
| Évènement | Quand | Champs spécifiques |
|---|---|---|
| `TeamCreateEvent` | Après création + persist | — |
| `TeamDissolveEvent` | Après dissolution | — |
| `TeamMemberAddEvent` | Après ajout d'un membre (chef OU auto-join) | `getMember()` |
| `TeamMemberRemoveEvent` | Après retrait | `getPlayerId()` |
| `PlayerJoinTeamEvent` | Spécifique auto-join (joueur lui-même) | `getMember()` |
| `TeamLeadershipTransferEvent` | Après transfert | `getOldLeaderId()`, `getNewLeaderId()` |
| `TeamVisibilityChangeEvent` | Après changement effectif | `getOldVisibility()`, `getNewVisibility()` |
| `TeamScoreChangeEvent` | Après changement effectif d'un score | `getScoreName()`, `getOldValue()`, `getNewValue()`, `getDelta()` |
| `TeamSpawnPointChangeEvent` | Après changement du spawn | `getOldLocation()`, `getNewLocation()` (nullable) |
Tous étendent `TeamEvent` (porte la `Team`).
### Évènements Player (`fr.luc.crcore.player.event`)
| Évènement | Quand | Champs spécifiques |
|---|---|---|
| `PlayerProfileCreateEvent` | Après création (lazy ou explicite) | — |
| `PlayerProfileDeleteEvent` | Après suppression | — |
| `PlayerScoreChangeEvent` | Après changement effectif d'un score joueur | `getScoreName()`, `getOldValue()`, `getNewValue()`, `getDelta()` |
Tous étendent `PlayerProfileEvent` (porte le `PlayerProfile`).
### Usage
```java
@EventHandler
public void onTeamCreate(TeamCreateEvent e) {
Team t = e.getTeam();
// ...
}
```
### Diagramme
- Classes : [events-diagram.puml](diagrams/events-diagram.puml)
---
## 6. Persistance SQLite (`fr.luc.crcore.database`)
**Statut** : wrapper minimal + table builder fluide. Repositories SQLite
write-through pour Team et PlayerProfile activés par défaut via `CRCore`.
### API
- **`Database`** (AutoCloseable) — 4 méthodes principales :
- `execute(sql, params...)` — DDL ou statement sans résultat
- `update(sql, params...)` — INSERT/UPDATE/DELETE, renvoie le nombre de lignes affectées
- `queryOne(sql, mapper, params...)` — au plus une ligne (Optional)
- `query(sql, mapper, params...)` — plusieurs lignes
- `inTransaction(Runnable)` — commit/rollback auto
- `table(name)` — démarre un `TableBuilder` fluide
- `tableExists(name)` — check
- **`TableBuilder`** — `.ifNotExists().column(name, type).primaryKey().notNull()...create()`
- **`ColumnType`** — enum : INTEGER, REAL, TEXT, BLOB, BOOLEAN, UUID
- **`RowMapper<T>`** — `T map(ResultSet rs) throws SQLException` (lambda-friendly)
- **`DatabaseException`** — runtime exception, wrap les `SQLException` JDBC
Les paramètres `Object...` sont liés via PreparedStatement (anti-injection),
avec conversion auto pour `UUID` (→ TEXT), `Enum<?>` (→ name() TEXT), `Boolean`
(→ 0/1).
### Tables internes CR-Core
Le préfixe `crcore_` évite les collisions :
- `crcore_teams`, `crcore_team_members`, `crcore_team_scores`
- `crcore_player_profiles`, `crcore_player_scores`
### Tables custom côté plugin de jeu
```java
Database db = core.getDatabase();
db.table("my_kills")
.ifNotExists()
.column("player_id", ColumnType.UUID).primaryKey()
.column("kills", ColumnType.INTEGER).notNull().defaultValue("0")
.create();
```
### Diagramme
- Classes : [database-diagram.puml](diagrams/database-diagram.puml)
---
## 7. Bootstrap `CRCore`
**Statut** : implémenté. Point d'entrée unique pour les plugins de jeu.
### Usage
```java
public class MyGamePlugin extends JavaPlugin {
private CRCore core;
@Override
public void onEnable() {
core = new CRCore(this).enable();
}
@Override
public void onDisable() {
if (core != null) core.disable();
}
}
```
### Configuration
`CRCoreConfig` en builder :
```java
new CRCore(this, new CRCoreConfig()
.withSqliteFile("mygame.db") // défaut : crcore.db
.withCommandName("game")) // défaut : core
.enable();
```
`withInMemoryStorage()` désactive SQLite (tests, ou contexte stateless).
### Override de la construction des services
Sous-classer `CRCore` et redéfinir :
- `buildTeamService(repo)` — pour utiliser une impl custom du service team
- `buildPlayerProfileService(repo)` — idem player
- `buildCoreCommand(...)` — pour ajouter des groupes top-level
### Diagramme
- Séquence : [bootstrap-sequence.puml](diagrams/bootstrap-sequence.puml)
---
## Backlog / idées de futurs domaines ## Backlog / idées de futurs domaines
_(à remplir — ex. score, profil joueur, gestion d'event/round, ...)_ _(à remplir — ex. inventaires partagés d'équipe, kits, gestion de rounds, )_
+170 -115
View File
@@ -4,10 +4,9 @@
- **Type** : librairie Java (`jar`) — pas un plugin Bukkit - **Type** : librairie Java (`jar`) — pas un plugin Bukkit
- **artifactId Maven** : `CR-Core` - **artifactId Maven** : `CR-Core`
- **Build** : Maven - **Build** : Maven, Java 16
- **Java** : 16
- **API serveur (provided)** : Paper 1.16.5 - **API serveur (provided)** : Paper 1.16.5
(`com.destroystokyo.paper:paper-api:1.16.5-R0.1-SNAPSHOT`) - **SQLite (compile)** : `org.xerial:sqlite-jdbc:3.45.3.0`
- **Package racine** : `fr.luc.crcore` - **Package racine** : `fr.luc.crcore`
## Dépôts Maven ## Dépôts Maven
@@ -22,12 +21,12 @@
mvn clean install mvn clean install
``` ```
Cela publie `fr.luc:CR-Core:1.0-SNAPSHOT` dans le repo Maven local `~/.m2/`, Publie `fr.luc:CR-Core:1.0-SNAPSHOT` dans le repo Maven local `~/.m2/`,
prêt à être consommé par les plugins de jeu. prêt à être consommé par les plugins de jeu.
## Utilisation depuis un plugin de jeu ## Intégration dans un plugin de jeu
Dans le `pom.xml` du plugin de jeu : ### `pom.xml`
```xml ```xml
<dependency> <dependency>
@@ -38,106 +37,111 @@ Dans le `pom.xml` du plugin de jeu :
</dependency> </dependency>
``` ```
> Scope `compile` (et non `provided`) **si** le plugin de jeu shade CR-Core dans Le plugin de jeu doit **shader** CR-Core dans son jar final (avec
> son propre jar (recommandé pour de la pure librairie). Pensez à utiliser un `maven-shade-plugin`) pour que sqlite-jdbc + le code du noyau soient bien
> `<relocation>` dans le `maven-shade-plugin` du plugin de jeu pour éviter les embarqués sur le serveur.
> conflits si plusieurs plugins shadent CR-Core sur le même serveur.
Côté code du plugin de jeu : ### `plugin.yml` du plugin de jeu
```yaml
name: MyGame
main: fr.exemple.mygame.MyGamePlugin
version: 1.0
api-version: 1.16
commands:
core:
description: Commandes CR-Core
# aliases optionnels — équivalents au point de vue Bukkit
aliases: [cr, crcore]
```
### Code minimal
```java ```java
public final class MyGamePlugin extends JavaPlugin { public class MyGamePlugin extends JavaPlugin {
private TeamService teamService; private CRCore core;
@Override @Override
public void onEnable() { public void onEnable() {
// Instancie le service team (chaque plugin a son propre registre) // 1 ligne = SQLite + services + /core team ... opérationnels
this.teamService = new TeamServiceImpl(new InMemoryTeamRepository()); this.core = new CRCore(this).enable();
// Enregistre une commande basée sur le framework de CR-Core // Listener custom sur les events team
getCommand("team").setExecutor(new TeamCommand(teamService)); getServer().getPluginManager().registerEvents(new MyTeamListener(), this);
getCommand("team").setTabCompleter(new TeamCommand(teamService));
// Table custom pour stocker des données spécifiques au jeu
core.getDatabase().table("my_kills")
.ifNotExists()
.column("player_id", ColumnType.UUID).primaryKey()
.column("kills", ColumnType.INTEGER).notNull().defaultValue("0")
.create();
}
@Override
public void onDisable() {
if (core != null) core.disable();
} }
} }
``` ```
Exemple minimal de commande : ### Écouter les évènements
```java ```java
public class TeamCommand extends BaseCommand { public class MyTeamListener implements Listener {
public TeamCommand(TeamService service) { @EventHandler
super("team", "teams", "t"); public void onTeamCreate(TeamCreateEvent event) {
description("Manage teams"); Team team = event.getTeam();
addSubCommand(new TeamCreateSub(service)); Bukkit.broadcastMessage("Nouvelle équipe : " + team.getName());
addSubCommand(new TeamInfoSub(service)); }
@EventHandler
public void onPlayerJoinTeam(PlayerJoinTeamEvent event) {
// Auto-join uniquement (chef qui ajoute = TeamMemberAddEvent)
}
@EventHandler
public void onScoreChange(TeamScoreChangeEvent event) {
// event.getScoreName(), event.getOldValue(), event.getNewValue()
} }
} }
```
public class TeamCreateSub extends SubCommand { ### Overrider une commande par défaut
private final TeamService service; ```java
public class MyCreateCommand extends TeamCreateSubCommand {
public TeamCreateSub(TeamService service) { public MyCreateCommand(TeamService service) {
super("create", "c", "new"); super(service);
this.service = service; permission("mygame.team.create"); // permission custom
description("Create a new team");
permission("mygame.team.create");
playerOnly();
argument("name", ArgumentTypes.STRING);
argument("tag", ArgumentTypes.STRING);
argument("color", ArgumentTypes.enumOf(TeamColor.class));
} }
@Override @Override
public CommandResult execute(CommandContext ctx) { public CommandResult execute(CommandContext ctx) {
Player player = ctx.requirePlayer(); // logique custom
String name = ctx.get("name"); return super.execute(ctx);
String tag = ctx.get("tag");
TeamColor color = ctx.get("color");
try {
Team team = service.createTeam(name, tag, color, player.getUniqueId());
return CommandResult.success("Équipe " + team.getName() + " créée !");
} catch (TeamException ex) {
return CommandResult.failure(ex.getMessage());
}
} }
} }
// Dans onEnable() :
core.getCoreCommand().findSubCommand("team")
.ifPresent(team -> team.replaceSubCommand("create", new MyCreateCommand(core.getTeamService())));
``` ```
Et dans le `plugin.yml` du plugin de jeu : ### Stocker / récupérer des données custom via SQLite
```yaml
name: MyGame
main: fr.luc.mygame.MyGamePlugin
version: 1.0
api-version: 1.16
commands:
team:
description: Manage teams
aliases: [teams, t]
```
## Override
Toute classe du noyau peut être étendue :
```java ```java
public class LoggingTeamService extends TeamServiceImpl { Database db = core.getDatabase();
public LoggingTeamService(TeamRepository repo) { super(repo); } db.update("INSERT OR REPLACE INTO my_kills (player_id, kills) VALUES (?, ?)",
playerUuid, 42);
@Override int kills = db.queryOne(
protected void onAfterCreate(Team team) { "SELECT kills FROM my_kills WHERE player_id = ?",
getPlugin().getLogger().info("Team created: " + team.getName()); rs -> rs.getInt("kills"),
} playerUuid
).orElse(0);
@Override
protected Team newTeam(UUID id, String name, String tag, TeamColor color, UUID leaderId) {
return new MyCustomTeam(id, name, tag, color, leaderId);
}
}
``` ```
## Arborescence du projet ## Arborescence du projet
@@ -146,61 +150,112 @@ public class LoggingTeamService extends TeamServiceImpl {
CitesPlugin/ # dossier IntelliJ (renommer plus tard si voulu) CitesPlugin/ # dossier IntelliJ (renommer plus tard si voulu)
├── pom.xml ├── pom.xml
├── GEMINI.md ├── GEMINI.md
├── docs/ # source de vérité ├── docs/
│ ├── README.md │ ├── README.md
│ ├── setup.md │ ├── setup.md
│ ├── features.md │ ├── features.md
│ ├── decisions.md │ ├── decisions.md
│ └── diagrams/ │ └── diagrams/*.puml
│ ├── team-class-diagram.puml
│ ├── team-create-sequence.puml
│ ├── team-create-activity.puml
│ └── command-class-diagram.puml
└── src/main/java/fr/luc/crcore/ └── src/main/java/fr/luc/crcore/
├── common/ # abstractions partagées ├── CRCore.java # bootstrap orchestrator
│ ├── Identifiable.java # interface ├── CRCoreConfig.java # config (sqlite, command name, …)
│ ├── Named.java # interface ├── common/
│ ├── ScoreHolder.java # interface (impl. par Team et PlayerProfile) │ ├── Identifiable.java
│ ├── AbstractEntity.java # abstract class │ ├── Named.java
── Repository.java # interface ── ScoreHolder.java # contrat partagé Team + PlayerProfile
├── command/ # framework de commandes │ ├── AbstractEntity.java
── Command.java # interface ── Repository.java
│ ├── AbstractCommand.java # base partagée ├── database/ # wrapper SQLite
│ ├── BaseCommand.java # commande top-level (Bukkit-aware) │ ├── Database.java
│ ├── SubCommand.java # sous-commande │ ├── TableBuilder.java
│ ├── ColumnType.java
│ ├── RowMapper.java
│ └── DatabaseException.java
├── command/ # framework
│ ├── Command.java (interface)
│ ├── AbstractCommand.java # base partagée, nested sub-commands
│ ├── BaseCommand.java # top-level Bukkit-aware
│ ├── SubCommand.java
│ ├── CommandContext.java │ ├── CommandContext.java
│ ├── CommandResult.java │ ├── CommandResult.java
│ ├── CommandException.java │ ├── CommandException.java
│ ├── ArgumentType.java │ ├── ArgumentType.java
│ ├── ArgumentTypes.java # STRING, INTEGER, BOOLEAN, ONLINE_PLAYER, enumOf, choice │ ├── ArgumentTypes.java
── ArgumentDef.java # package-private ── ArgumentDef.java # package-private
├── team/ # domaine team │ └── builtin/ # commandes prêtes à l'emploi
│ ├── CoreCommand.java # /core
│ └── team/
│ ├── TeamGroupSubCommand.java # /core team (container)
│ ├── TeamArgumentTypes.java # ArgumentType<Team> avec tab-complete
│ ├── TeamCreateSubCommand.java # /core team create
│ ├── TeamDeleteSubCommand.java # /core team delete
│ ├── TeamAddSubCommand.java # /core team add
│ ├── TeamRemoveSubCommand.java # /core team remove
│ ├── TeamJoinSubCommand.java # /core team join
│ ├── TeamLeaveSubCommand.java # /core team leave
│ ├── TeamInfoSubCommand.java # /core team info
│ ├── TeamListSubCommand.java # /core team list
│ ├── TeamTransferSubCommand.java # /core team transfer
│ ├── TeamVisibilitySubCommand.java # /core team visibility
│ ├── TeamScoreSubCommand.java # /core team score (admin)
│ ├── TeamTopSubCommand.java # /core team top
│ └── TeamSetSpawnSubCommand.java # /core team setspawn
├── team/
│ ├── Team.java │ ├── Team.java
│ ├── TeamMember.java │ ├── TeamMember.java
│ ├── TeamRole.java # enum │ ├── TeamRole.java
│ ├── TeamColor.java # enum │ ├── TeamColor.java
│ ├── TeamVisibility.java # enum (PUBLIC / PRIVATE) │ ├── TeamVisibility.java
│ ├── TeamRanking.java # record (rank, team, score) │ ├── TeamRanking.java # record
│ ├── TeamRepository.java # interface │ ├── TeamRepository.java
│ ├── InMemoryTeamRepository.java # impl │ ├── InMemoryTeamRepository.java
│ ├── TeamService.java # interface │ ├── SqliteTeamRepository.java
│ ├── TeamServiceImpl.java # impl avec hooks overridables │ ├── TeamService.java
│ ├── TeamServiceImpl.java
│ ├── BukkitEventFiringTeamServiceImpl.java # impl par défaut
│ ├── TeamException.java │ ├── TeamException.java
│ ├── TeamAlreadyExistsException.java │ ├── TeamAlreadyExistsException.java
│ ├── TeamNotFoundException.java │ ├── TeamNotFoundException.java
── TeamAccessException.java # auto-join refusé ── TeamAccessException.java
└── player/ # domaine player │ └── event/ # Bukkit events team
├── PlayerProfile.java # entité, scores par joueur ├── TeamEvent.java # base
├── PlayerRanking.java # record (rank, profile, score) ├── TeamCreateEvent.java
├── PlayerProfileRepository.java # interface ├── TeamDissolveEvent.java
│ ├── TeamMemberAddEvent.java
│ ├── TeamMemberRemoveEvent.java
│ ├── PlayerJoinTeamEvent.java
│ ├── TeamLeadershipTransferEvent.java
│ ├── TeamVisibilityChangeEvent.java
│ ├── TeamScoreChangeEvent.java
│ └── TeamSpawnPointChangeEvent.java
└── player/
├── PlayerProfile.java
├── PlayerRanking.java
├── PlayerProfileRepository.java
├── InMemoryPlayerProfileRepository.java ├── InMemoryPlayerProfileRepository.java
├── PlayerProfileService.java # interface ├── SqlitePlayerProfileRepository.java
├── PlayerProfileServiceImpl.java # impl avec hooks overridables ├── PlayerProfileService.java
├── PlayerProfileServiceImpl.java
├── BukkitEventFiringPlayerProfileServiceImpl.java
├── PlayerException.java ├── PlayerException.java
── PlayerProfileNotFoundException.java ── PlayerProfileNotFoundException.java
└── event/ # Bukkit events player
├── PlayerProfileEvent.java
├── PlayerProfileCreateEvent.java
├── PlayerProfileDeleteEvent.java
└── PlayerScoreChangeEvent.java
``` ```
> **Note IntelliJ** : le dossier physique s'appelle encore `CitesPlugin/`. Pour ## Tables SQLite créées par CR-Core
> le renommer en `CR-Core/`, fermer IntelliJ, renommer, rouvrir. Le `pom.xml`
> et les packages sont déjà à jour, le nom du dossier n'a aucun impact sur le Au premier `enable()`, les tables suivantes sont créées (en `IF NOT EXISTS`) :
> build.
- `crcore_teams` — métadonnées par équipe (id, name, tag, color, leader_id,
visibility, spawn_world/x/y/z/yaw/pitch)
- `crcore_team_members` — un membre = (team_id, player_id, role, joined_at)
- `crcore_team_scores` — (team_id, score_name, value)
- `crcore_player_profiles` — un profil = (id)
- `crcore_player_scores` — (profile_id, score_name, value)
Le préfixe `crcore_` évite les collisions avec les tables custom du plugin
de jeu.
+14 -1
View File
@@ -10,12 +10,13 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<name>CR-Core</name> <name>CR-Core</name>
<description>Reusable core library for CR Minecraft game plugins (teams, commands, common abstractions).</description> <description>Reusable core library for CR Minecraft game plugins (teams, players, scores, commands, events, SQLite persistence).</description>
<properties> <properties>
<maven.compiler.source>16</maven.compiler.source> <maven.compiler.source>16</maven.compiler.source>
<maven.compiler.target>16</maven.compiler.target> <maven.compiler.target>16</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<sqlite.version>3.45.3.0</sqlite.version>
</properties> </properties>
<repositories> <repositories>
@@ -40,6 +41,18 @@
<version>1.16.5-R0.1-SNAPSHOT</version> <version>1.16.5-R0.1-SNAPSHOT</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!--
SQLite JDBC driver. Scope compile so consumer plugins that depend on
CR-Core get it transitively. They are expected to shade it into
their final jar (or declare the dependency themselves) since Paper
does not bundle it.
-->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>${sqlite.version}</version>
<scope>compile</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
+173
View File
@@ -0,0 +1,173 @@
package fr.luc.crcore;
import fr.luc.crcore.command.builtin.CoreCommand;
import fr.luc.crcore.database.Database;
import fr.luc.crcore.player.BukkitEventFiringPlayerProfileServiceImpl;
import fr.luc.crcore.player.InMemoryPlayerProfileRepository;
import fr.luc.crcore.player.PlayerProfileRepository;
import fr.luc.crcore.player.PlayerProfileService;
import fr.luc.crcore.player.SqlitePlayerProfileRepository;
import fr.luc.crcore.team.BukkitEventFiringTeamServiceImpl;
import fr.luc.crcore.team.InMemoryTeamRepository;
import fr.luc.crcore.team.SqliteTeamRepository;
import fr.luc.crcore.team.TeamRepository;
import fr.luc.crcore.team.TeamService;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.util.Objects;
/**
* Point d'entrée unique de CR-Core pour un plugin de jeu downstream.
*
* <p>Instanciée une fois dans {@code onEnable()}, branche en cascade :
* <ol>
* <li>la base SQLite (dans le dataFolder du plugin),</li>
* <li>les repositories (SQLite ou in-memory selon {@link CRCoreConfig}),</li>
* <li>les services team + player avec fire d'évènements Bukkit,</li>
* <li>la commande {@code /core} avec tous ses sous-commandes par défaut.</li>
* </ol>
*
* <h2>Utilisation minimale côté plugin de jeu</h2>
* <pre>{@code
* public class MyGamePlugin extends JavaPlugin {
*
* private CRCore core;
*
* @Override
* public void onEnable() {
* this.core = new CRCore(this).enable();
* // /core team create/delete/add/remove/join/leave/... est prêt
* }
*
* @Override
* public void onDisable() {
* if (core != null) core.disable();
* }
* }
* }</pre>
*
* <p>Le plugin de jeu doit avoir déclaré la commande dans son {@code plugin.yml} :
* <pre>{@code
* commands:
* core:
* description: Commandes CR-Core
* }</pre>
*
* <h2>Override</h2>
* Tout est accessible via les getters : {@link #getTeamService()},
* {@link #getCoreCommand()}, {@link #getDatabase()}, etc. Pour remplacer une
* sous-commande, voir {@link CoreCommand}. Pour remplacer un service complet,
* sous-classer {@code CRCore} et override {@link #buildTeamService}.
*/
public class CRCore {
private final JavaPlugin plugin;
private final CRCoreConfig config;
private Database database;
private TeamRepository teamRepository;
private TeamService teamService;
private PlayerProfileRepository playerProfileRepository;
private PlayerProfileService playerProfileService;
private CoreCommand coreCommand;
private boolean enabled = false;
/** Construit CR-Core avec la config par défaut (SQLite activée, commande "core"). */
public CRCore(JavaPlugin plugin) {
this(plugin, new CRCoreConfig());
}
public CRCore(JavaPlugin plugin, CRCoreConfig config) {
this.plugin = Objects.requireNonNull(plugin, "plugin");
this.config = Objects.requireNonNull(config, "config");
}
/**
* Branche tout : ouvre la DB, instancie les services, enregistre la
* commande. Idempotent : un second appel est no-op.
*
* @return {@code this} pour chaîner.
*/
public CRCore enable() {
if (enabled) return this;
if (config.isSqliteEnabled()) {
File dbFile = new File(plugin.getDataFolder(), config.getSqliteFilename());
if (!dbFile.getParentFile().exists() && !dbFile.getParentFile().mkdirs()) {
plugin.getLogger().warning("Impossible de créer le dataFolder : " + dbFile.getParentFile());
}
this.database = new Database(dbFile);
this.teamRepository = new SqliteTeamRepository(database);
this.playerProfileRepository = new SqlitePlayerProfileRepository(database);
} else {
this.teamRepository = new InMemoryTeamRepository();
this.playerProfileRepository = new InMemoryPlayerProfileRepository();
}
this.teamService = buildTeamService(teamRepository);
this.playerProfileService = buildPlayerProfileService(playerProfileRepository);
this.coreCommand = buildCoreCommand(teamService, playerProfileService);
registerCommand();
plugin.getLogger().info("CR-Core activé.");
enabled = true;
return this;
}
/** Libère les ressources (ferme la DB notamment). Idempotent. */
public void disable() {
if (!enabled) return;
if (database != null) {
try {
database.close();
} catch (Exception ex) {
plugin.getLogger().warning("Erreur en fermant la DB : " + ex.getMessage());
}
}
plugin.getLogger().info("CR-Core désactivé.");
enabled = false;
}
// ---- Override points ----
/** Construit le {@link TeamService}. Override pour utiliser une impl custom. */
protected TeamService buildTeamService(TeamRepository repository) {
return new BukkitEventFiringTeamServiceImpl(plugin, repository);
}
/** Construit le {@link PlayerProfileService}. Override pour une impl custom. */
protected PlayerProfileService buildPlayerProfileService(PlayerProfileRepository repository) {
return new BukkitEventFiringPlayerProfileServiceImpl(plugin, repository);
}
/** Construit le {@link CoreCommand}. Override pour ajouter des groupes top-level. */
protected CoreCommand buildCoreCommand(TeamService teamService, PlayerProfileService playerProfileService) {
return new CoreCommand(teamService, playerProfileService);
}
private void registerCommand() {
PluginCommand cmd = plugin.getCommand(config.getCommandName());
if (cmd == null) {
plugin.getLogger().warning("Commande '" + config.getCommandName() +
"' absente du plugin.yml — /" + config.getCommandName() +
" ne sera pas reconnue.");
return;
}
cmd.setExecutor(coreCommand);
cmd.setTabCompleter(coreCommand);
}
// ---- Getters ----
public JavaPlugin getPlugin() { return plugin; }
public CRCoreConfig getConfig() { return config; }
public Database getDatabase() { return database; }
public TeamRepository getTeamRepository() { return teamRepository; }
public TeamService getTeamService() { return teamService; }
public PlayerProfileRepository getPlayerProfileRepository() { return playerProfileRepository; }
public PlayerProfileService getPlayerProfileService() { return playerProfileService; }
public CoreCommand getCoreCommand() { return coreCommand; }
public boolean isEnabled() { return enabled; }
}
@@ -0,0 +1,51 @@
package fr.luc.crcore;
/**
* Configuration de {@link CRCore} fournie au constructeur. API builder : on
* chaîne les {@code with...} pour modifier les valeurs par défaut.
*
* <pre>{@code
* new CRCore(this, new CRCoreConfig()
* .withSqliteFile("mydata.db")
* .withCommandName("game"))
* .enable();
* }</pre>
*
* <p>Valeurs par défaut :
* <ul>
* <li>SQLite activé, fichier {@code crcore.db} dans le dataFolder du plugin</li>
* <li>Commande Bukkit racine : {@code core}</li>
* </ul>
*/
public class CRCoreConfig {
private boolean sqliteEnabled = true;
private String sqliteFilename = "crcore.db";
private String commandName = "core";
/** Désactive SQLite — toutes les données vivent en mémoire (perdues au reload/stop). */
public CRCoreConfig withInMemoryStorage() {
this.sqliteEnabled = false;
return this;
}
/** Active SQLite et fixe le nom du fichier (relatif au dataFolder du plugin). */
public CRCoreConfig withSqliteFile(String filename) {
this.sqliteEnabled = true;
this.sqliteFilename = filename;
return this;
}
/**
* Change le nom de la commande Bukkit racine. Doit matcher l'entrée du
* {@code commands:} dans le {@code plugin.yml} du plugin de jeu.
*/
public CRCoreConfig withCommandName(String commandName) {
this.commandName = commandName;
return this;
}
public boolean isSqliteEnabled() { return sqliteEnabled; }
public String getSqliteFilename() { return sqliteFilename; }
public String getCommandName() { return commandName; }
}
@@ -1,19 +1,49 @@
package fr.luc.crcore.command; package fr.luc.crcore.command;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Base partagée par {@link BaseCommand} (top-level Bukkit) et {@link SubCommand}
* (feuille ou groupe imbriqué). Porte tous les champs communs : nom, aliases,
* permission, player-only, description, usage, arguments typés, et un registre
* de sous-commandes imbriquées.
*
* <p>Une commande peut être :
* <ul>
* <li><b>feuille</b> : pas de sous-commandes, implémente {@link #execute}</li>
* <li><b>groupe</b> : sous-commandes via {@link #addSubCommand}, le routage
* est récursif. Si aucune sous-commande ne matche, {@link #execute} est
* appelé en fallback (par défaut : liste les sous-commandes).</li>
* <li><b>hybride</b> : groupe ET arguments propres — déconseillé, le
* routage donne priorité aux sous-commandes.</li>
* </ul>
*
* <p>L'override par les plugins de jeu se fait via {@link #replaceSubCommand}
* (remplacer une sous-commande par nom) ou en sous-classant et en redéfinissant
* {@link #execute}.
*/
public abstract class AbstractCommand implements Command { public abstract class AbstractCommand implements Command {
private final String name; private final String name;
private final List<String> aliases = new ArrayList<>(); private final List<String> aliases = new ArrayList<>();
private final List<ArgumentDef> arguments = new ArrayList<>(); private final List<ArgumentDef> arguments = new ArrayList<>();
private final Map<String, SubCommand> subCommandsByName = new LinkedHashMap<>();
private final Map<String, SubCommand> subCommandsByAlias = new HashMap<>();
private String permission; private String permission;
private boolean playerOnly; private boolean playerOnly;
private String description = ""; private String description = "";
@@ -26,65 +56,126 @@ public abstract class AbstractCommand implements Command {
} }
} }
@Override // ---- Command interface ----
public final String getName() {
return name;
}
@Override @Override public final String getName() { return name; }
public final List<String> getAliases() { @Override public final List<String> getAliases() { return Collections.unmodifiableList(aliases); }
return Collections.unmodifiableList(aliases); @Override public final String getPermission() { return permission; }
} @Override public final boolean isPlayerOnly() { return playerOnly; }
@Override public final String getDescription() { return description; }
@Override
public final String getPermission() {
return permission;
}
@Override
public final boolean isPlayerOnly() {
return playerOnly;
}
@Override
public final String getDescription() {
return description;
}
/** Usage explicite si défini via {@link #usage(String)}, sinon usage auto-construit. */
public final String getUsage() { public final String getUsage() {
return usage != null ? usage : buildDefaultUsage(); return usage != null ? usage : buildDefaultUsage();
} }
// ---- Builders (à appeler dans le constructeur des sous-classes) ----
/** Ajoute un ou plusieurs alias. Les aliases sont case-insensitive. */
protected final void addAlias(String... aliases) { protected final void addAlias(String... aliases) {
for (String alias : aliases) { for (String alias : aliases) {
this.aliases.add(alias.toLowerCase()); this.aliases.add(alias.toLowerCase());
} }
} }
/** Définit la permission Bukkit requise (ex. {@code "crcore.team.create"}). */
protected final void permission(String permission) { protected final void permission(String permission) {
this.permission = permission; this.permission = permission;
} }
/** Restreint l'exécution aux joueurs (refus pour console). */
protected final void playerOnly() { protected final void playerOnly() {
this.playerOnly = true; this.playerOnly = true;
} }
/** Description courte affichée dans l'aide. */
protected final void description(String description) { protected final void description(String description) {
this.description = Objects.requireNonNullElse(description, ""); this.description = Objects.requireNonNullElse(description, "");
} }
/** Usage explicite (sinon construit automatiquement à partir des arguments). */
protected final void usage(String usage) { protected final void usage(String usage) {
this.usage = usage; this.usage = usage;
} }
/** Déclare un argument positionnel obligatoire. */
protected final void argument(String name, ArgumentType<?> type) { protected final void argument(String name, ArgumentType<?> type) {
arguments.add(new ArgumentDef(name, type, true)); arguments.add(new ArgumentDef(name, type, true));
} }
/** Déclare un argument positionnel optionnel (peut être omis par l'utilisateur). */
protected final void optionalArgument(String name, ArgumentType<?> type) { protected final void optionalArgument(String name, ArgumentType<?> type) {
arguments.add(new ArgumentDef(name, type, false)); arguments.add(new ArgumentDef(name, type, false));
} }
// ---- Sub-command management ----
/**
* Enregistre une sous-commande. Lève {@link IllegalStateException} si une
* sous-commande du même nom existe déjà (utiliser {@link #replaceSubCommand}
* pour overrider).
*/
protected final void addSubCommand(SubCommand sub) {
Objects.requireNonNull(sub, "sub");
if (subCommandsByName.containsKey(sub.getName())) {
throw new IllegalStateException(
"Sub-command '" + sub.getName() + "' already registered on '" + name + "'");
}
subCommandsByName.put(sub.getName(), sub);
for (String alias : sub.getAliases()) {
subCommandsByAlias.put(alias, sub);
}
}
/**
* Remplace une sous-commande existante par son nom. Utilisé par les plugins
* de jeu pour overrider un comportement par défaut.
*
* <pre>{@code
* coreCommand.findSubCommand("team")
* .ifPresent(team -> team.replaceSubCommand("create", new MyCustomCreate(svc)));
* }</pre>
*
* @return l'ancienne sous-commande remplacée, ou {@link Optional#empty()}
* si aucune n'existait sous ce nom.
*/
public final Optional<SubCommand> replaceSubCommand(String name, SubCommand replacement) {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(replacement, "replacement");
String key = name.toLowerCase();
SubCommand old = subCommandsByName.remove(key);
if (old != null) {
for (String alias : old.getAliases()) {
subCommandsByAlias.remove(alias);
}
}
subCommandsByName.put(replacement.getName(), replacement);
for (String alias : replacement.getAliases()) {
subCommandsByAlias.put(alias, replacement);
}
return Optional.ofNullable(old);
}
/** Recherche une sous-commande par nom ou par alias (case-insensitive). */
public final Optional<SubCommand> findSubCommand(String label) {
if (label == null) return Optional.empty();
String lc = label.toLowerCase();
SubCommand sub = subCommandsByName.get(lc);
if (sub != null) return Optional.of(sub);
return Optional.ofNullable(subCommandsByAlias.get(lc));
}
/** Toutes les sous-commandes enregistrées, dans l'ordre d'insertion. */
public final Collection<SubCommand> getSubCommands() {
return Collections.unmodifiableCollection(subCommandsByName.values());
}
public final boolean hasSubCommands() {
return !subCommandsByName.isEmpty();
}
// ---- Argument introspection ----
public final int getRequiredArgumentCount() { public final int getRequiredArgumentCount() {
return (int) arguments.stream().filter(ArgumentDef::isRequired).count(); return (int) arguments.stream().filter(ArgumentDef::isRequired).count();
} }
@@ -97,6 +188,129 @@ public abstract class AbstractCommand implements Command {
return arguments; return arguments;
} }
// ---- Dispatch (routage récursif vers sous-commandes) ----
/**
* Achemine l'exécution : si {@code args[0]} matche une sous-commande, on
* recurse dessus avec {@code args[1..]}. Sinon on appelle {@link #execute}
* de cette commande. Gère les checks permission / player-only.
*/
public final CommandResult dispatch(CommandSender sender, String label, String[] args) {
if (!checkAccess(sender)) {
return permission != null && !sender.hasPermission(permission)
? CommandResult.noPermission()
: CommandResult.playerOnly();
}
if (args.length > 0) {
Optional<SubCommand> sub = findSubCommand(args[0]);
if (sub.isPresent()) {
String[] subArgs = Arrays.copyOfRange(args, 1, args.length);
return sub.get().dispatch(sender, label, subArgs);
}
}
if (args.length < getRequiredArgumentCount()) {
return CommandResult.invalidUsage("Usage: " + buildDefaultUsage());
}
CommandContext ctx;
try {
ctx = buildContext(sender, label, args);
} catch (CommandException ex) {
return CommandResult.failure(ex.getMessage());
}
try {
return execute(ctx);
} catch (CommandException ex) {
return CommandResult.failure(ex.getMessage());
}
}
/**
* Auto-complétion récursive : si {@code args} a une seule valeur, suggère
* les sous-commandes accessibles + les arguments propres. Sinon recurse
* vers la sous-commande qui matche.
*/
public final List<String> tabComplete(CommandSender sender, String[] args) {
if (args.length == 0) return Collections.emptyList();
if (args.length == 1) {
String partial = args[0].toLowerCase();
List<String> suggestions = new ArrayList<>();
// Sous-commandes (filtrées par permission)
for (SubCommand sub : subCommandsByName.values()) {
if (sub.getPermission() != null && !sender.hasPermission(sub.getPermission())) continue;
suggestions.add(sub.getName());
suggestions.addAll(sub.getAliases());
}
// Argument 0 si on est une feuille avec arguments
if (!arguments.isEmpty()) {
suggestions.addAll(arguments.get(0).getType().suggestions(sender, args[0]));
}
return suggestions.stream()
.filter(s -> s.toLowerCase().startsWith(partial))
.distinct()
.collect(Collectors.toList());
}
// args.length > 1 : route vers sous-commande si match
Optional<SubCommand> sub = findSubCommand(args[0]);
if (sub.isPresent()) {
if (sub.get().getPermission() != null && !sender.hasPermission(sub.get().getPermission())) {
return Collections.emptyList();
}
String[] subArgs = Arrays.copyOfRange(args, 1, args.length);
return sub.get().tabComplete(sender, subArgs);
}
// Pas de sous-commande : complète l'argument courant
int argIndex = args.length - 1;
if (argIndex < arguments.size()) {
return arguments.get(argIndex).getType().suggestions(sender, args[argIndex]);
}
return Collections.emptyList();
}
// ---- Execution (à overrider par les feuilles) ----
/**
* Logique métier de la commande. Override par les sous-classes.
*
* <p>Comportement par défaut :
* <ul>
* <li>Si cette commande a des sous-commandes → affiche la liste (aide).</li>
* <li>Sinon → renvoie {@link CommandResult#invalidUsage()}.</li>
* </ul>
*/
public CommandResult execute(CommandContext context) {
if (hasSubCommands()) {
return listSubCommands(context);
}
return CommandResult.invalidUsage("Usage: " + buildDefaultUsage());
}
// ---- Override helpers ----
/** Default {@code execute} pour les groupes : affiche la liste des sous-commandes accessibles. */
protected CommandResult listSubCommands(CommandContext context) {
StringBuilder sb = new StringBuilder(ChatColor.YELLOW.toString())
.append("Commandes disponibles pour ")
.append(ChatColor.WHITE).append('/').append(context.getLabel());
for (SubCommand sub : subCommandsByName.values()) {
if (sub.getPermission() != null && !context.getSender().hasPermission(sub.getPermission())) continue;
sb.append('\n').append(ChatColor.GRAY).append(" - ")
.append(ChatColor.WHITE).append(sub.getName());
if (!sub.getDescription().isEmpty()) {
sb.append(ChatColor.GRAY).append("").append(sub.getDescription());
}
}
context.reply(sb.toString());
return CommandResult.success();
}
/** Construit un usage par défaut à partir du nom et des arguments déclarés. */
protected String buildDefaultUsage() { protected String buildDefaultUsage() {
StringBuilder sb = new StringBuilder("/").append(name); StringBuilder sb = new StringBuilder("/").append(name);
for (ArgumentDef def : arguments) { for (ArgumentDef def : arguments) {
@@ -108,26 +322,26 @@ public abstract class AbstractCommand implements Command {
return sb.toString(); return sb.toString();
} }
@Override /** Check standard de permission + player-only. */
public List<String> tabComplete(CommandSender sender, int argIndex, String partial) { protected boolean checkAccess(CommandSender sender) {
if (argIndex >= 0 && argIndex < arguments.size()) { if (permission != null && !sender.hasPermission(permission)) return false;
return arguments.get(argIndex).getType().suggestions(sender, partial); if (playerOnly && !(sender instanceof Player)) return false;
} return true;
return Collections.emptyList();
} }
protected CommandContext buildContext(CommandSender sender, String label, String[] subArgs) { /** Parse les arguments raw et construit le {@link CommandContext}. */
protected CommandContext buildContext(CommandSender sender, String label, String[] rawArgs) {
Map<String, Object> parsed = new LinkedHashMap<>(); Map<String, Object> parsed = new LinkedHashMap<>();
int max = Math.min(subArgs.length, arguments.size()); int max = Math.min(rawArgs.length, arguments.size());
for (int i = 0; i < max; i++) { for (int i = 0; i < max; i++) {
ArgumentDef def = arguments.get(i); ArgumentDef def = arguments.get(i);
try { try {
Object value = def.getType().parse(subArgs[i]); Object value = def.getType().parse(rawArgs[i]);
parsed.put(def.getName(), value); parsed.put(def.getName(), value);
} catch (CommandException ex) { } catch (CommandException ex) {
throw new CommandException("Argument '" + def.getName() + "': " + ex.getMessage()); throw new CommandException("Argument '" + def.getName() + "': " + ex.getMessage());
} }
} }
return new CommandContext(sender, label, subArgs, parsed); return new CommandContext(sender, label, rawArgs, parsed);
} }
} }
@@ -4,172 +4,48 @@ import org.bukkit.ChatColor;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter; import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Commande top-level branchée sur Bukkit. À utiliser comme racine de l'arbre :
* <pre>{@code
* PluginCommand cmd = plugin.getCommand("core");
* cmd.setExecutor(new CoreCommand(...));
* cmd.setTabCompleter((CoreCommand) cmd.getExecutor());
* }</pre>
*
* <p>{@code BaseCommand} se contente de relayer {@code onCommand} et
* {@code onTabComplete} vers {@link AbstractCommand#dispatch} et
* {@link AbstractCommand#tabComplete}. Toute la logique (routage récursif,
* permissions, player-only, parsing d'arguments) vit dans
* {@link AbstractCommand}.
*/
public abstract class BaseCommand extends AbstractCommand public abstract class BaseCommand extends AbstractCommand
implements CommandExecutor, TabCompleter { implements CommandExecutor, TabCompleter {
private final Map<String, SubCommand> subCommandsByName = new LinkedHashMap<>();
private final Map<String, SubCommand> subCommandsByAlias = new HashMap<>();
protected BaseCommand(String name, String... aliases) { protected BaseCommand(String name, String... aliases) {
super(name, aliases); super(name, aliases);
} }
protected final void addSubCommand(SubCommand sub) {
Objects.requireNonNull(sub, "sub");
if (subCommandsByName.containsKey(sub.getName())) {
throw new IllegalStateException("Sub-command already registered: " + sub.getName());
}
subCommandsByName.put(sub.getName(), sub);
for (String alias : sub.getAliases()) {
subCommandsByAlias.put(alias, sub);
}
}
public final Collection<SubCommand> getSubCommands() {
return Collections.unmodifiableCollection(subCommandsByName.values());
}
public final Optional<SubCommand> findSubCommand(String label) {
if (label == null) return Optional.empty();
String lc = label.toLowerCase();
SubCommand sub = subCommandsByName.get(lc);
if (sub != null) return Optional.of(sub);
return Optional.ofNullable(subCommandsByAlias.get(lc));
}
/**
* Called when no sub-command matches the first argument. Override for custom
* fallback behavior. Default: lists available sub-commands.
*/
protected CommandResult execute(CommandContext context) {
if (subCommandsByName.isEmpty()) {
return CommandResult.invalidUsage("No action specified.");
}
StringBuilder sb = new StringBuilder(ChatColor.YELLOW + "Available sub-commands:");
for (SubCommand sub : subCommandsByName.values()) {
sb.append('\n').append(ChatColor.GRAY).append(" - ")
.append(ChatColor.WHITE).append(sub.getName());
if (!sub.getDescription().isEmpty()) {
sb.append(ChatColor.GRAY).append("").append(sub.getDescription());
}
}
context.reply(sb.toString());
return CommandResult.success();
}
@Override @Override
public final boolean onCommand(CommandSender sender, org.bukkit.command.Command command, public final boolean onCommand(CommandSender sender, org.bukkit.command.Command command,
String label, String[] args) { String label, String[] args) {
if (!checkAccess(sender, this)) { CommandResult result = dispatch(sender, label, args);
return true; handleResult(sender, result);
}
if (args.length == 0 || findSubCommand(args[0]).isEmpty()) {
CommandContext ctx = new CommandContext(sender, label, args, Collections.emptyMap());
try {
handleResult(sender, execute(ctx));
} catch (CommandException ex) {
sender.sendMessage(ChatColor.RED + ex.getMessage());
}
return true;
}
SubCommand sub = findSubCommand(args[0]).orElseThrow();
if (!checkAccess(sender, sub)) {
return true;
}
String[] subArgs = Arrays.copyOfRange(args, 1, args.length);
if (subArgs.length < sub.getRequiredArgumentCount()) {
sender.sendMessage(ChatColor.RED + "Usage: " + buildUsageFor(label, sub));
return true;
}
CommandContext ctx;
try {
ctx = sub.buildContext(sender, label, subArgs);
} catch (CommandException ex) {
sender.sendMessage(ChatColor.RED + ex.getMessage());
return true;
}
try {
handleResult(sender, sub.execute(ctx));
} catch (CommandException ex) {
sender.sendMessage(ChatColor.RED + ex.getMessage());
}
return true; return true;
} }
@Override @Override
public final List<String> onTabComplete(CommandSender sender, org.bukkit.command.Command command, public final List<String> onTabComplete(CommandSender sender, org.bukkit.command.Command command,
String alias, String[] args) { String alias, String[] args) {
if (args.length == 0) return Collections.emptyList(); return tabComplete(sender, args);
if (args.length == 1) {
String partial = args[0].toLowerCase();
List<String> names = new ArrayList<>();
for (SubCommand sub : subCommandsByName.values()) {
if (sub.getPermission() != null && !sender.hasPermission(sub.getPermission())) {
continue;
}
names.add(sub.getName());
names.addAll(sub.getAliases());
}
return names.stream()
.filter(n -> n.startsWith(partial))
.distinct()
.collect(Collectors.toList());
}
Optional<SubCommand> subOpt = findSubCommand(args[0]);
if (subOpt.isEmpty()) return Collections.emptyList();
SubCommand sub = subOpt.get();
if (sub.getPermission() != null && !sender.hasPermission(sub.getPermission())) {
return Collections.emptyList();
}
int argIndex = args.length - 2;
String partial = args[args.length - 1];
return sub.tabComplete(sender, argIndex, partial);
}
protected boolean checkAccess(CommandSender sender, Command target) {
if (target.getPermission() != null && !sender.hasPermission(target.getPermission())) {
sender.sendMessage(ChatColor.RED + "You don't have permission.");
return false;
}
if (target.isPlayerOnly() && !(sender instanceof Player)) {
sender.sendMessage(ChatColor.RED + "Only players can use this command.");
return false;
}
return true;
}
protected String buildUsageFor(String label, SubCommand sub) {
StringBuilder sb = new StringBuilder("/").append(label).append(' ').append(sub.getName());
for (ArgumentDef def : sub.getArgumentDefs()) {
sb.append(' ');
sb.append(def.isRequired() ? '<' : '[');
sb.append(def.getName());
sb.append(def.isRequired() ? '>' : ']');
}
return sb.toString();
} }
/**
* Affiche le {@link CommandResult} à l'utilisateur. Override pour
* personnaliser le formatage (couleurs, locales, etc.).
*/
protected void handleResult(CommandSender sender, CommandResult result) { protected void handleResult(CommandSender sender, CommandResult result) {
switch (result.getType()) { switch (result.getType()) {
case SUCCESS -> { case SUCCESS -> {
@@ -181,8 +57,8 @@ public abstract class BaseCommand extends AbstractCommand
(result.getMessage() != null ? result.getMessage() : "Command failed.")); (result.getMessage() != null ? result.getMessage() : "Command failed."));
case INVALID_USAGE -> sender.sendMessage(ChatColor.RED + case INVALID_USAGE -> sender.sendMessage(ChatColor.RED +
(result.getMessage() != null ? result.getMessage() : "Invalid usage.")); (result.getMessage() != null ? result.getMessage() : "Invalid usage."));
case NO_PERMISSION -> sender.sendMessage(ChatColor.RED + "You don't have permission."); case NO_PERMISSION -> sender.sendMessage(ChatColor.RED + "Vous n'avez pas la permission.");
case PLAYER_ONLY -> sender.sendMessage(ChatColor.RED + "Only players can use this command."); case PLAYER_ONLY -> sender.sendMessage(ChatColor.RED + "Seul un joueur peut utiliser cette commande.");
} }
} }
} }
@@ -4,6 +4,11 @@ import org.bukkit.command.CommandSender;
import java.util.List; import java.util.List;
/**
* Contrat partagé par toutes les commandes du framework CR-Core
* ({@link BaseCommand} top-level et {@link SubCommand} imbriquées). Implémenté
* concrètement par {@link AbstractCommand}.
*/
public interface Command { public interface Command {
String getName(); String getName();
@@ -16,10 +21,20 @@ public interface Command {
String getDescription(); String getDescription();
/**
* Logique d'exécution de la commande (cas feuille, ou fallback si aucune
* sous-commande ne matche).
*/
CommandResult execute(CommandContext context); CommandResult execute(CommandContext context);
List<String> tabComplete(CommandSender sender, int argIndex, String partial); /**
* Suggestions de tab-completion en fonction des arguments déjà tapés.
* {@code args} contient TOUS les arguments depuis ce niveau de commande
* (sans le nom de la commande elle-même).
*/
List<String> tabComplete(CommandSender sender, String[] args);
/** {@code true} si {@code label} match le nom ou un alias (case-insensitive). */
default boolean matches(String label) { default boolean matches(String label) {
if (label == null) return false; if (label == null) return false;
String lc = label.toLowerCase(); String lc = label.toLowerCase();
@@ -1,5 +1,16 @@
package fr.luc.crcore.command; package fr.luc.crcore.command;
/**
* Sous-commande imbriquée. Peut être :
* <ul>
* <li><b>feuille</b> — override {@link #execute(CommandContext)} avec la logique métier</li>
* <li><b>groupe</b> — appelle {@code addSubCommand(...)} dans son constructeur pour
* déléguer à des sous-sous-commandes (ex. {@code /core team create})</li>
* </ul>
*
* <p>Toute la machinerie de routage / parsing / tab-complete est héritée de
* {@link AbstractCommand}.
*/
public abstract class SubCommand extends AbstractCommand { public abstract class SubCommand extends AbstractCommand {
protected SubCommand(String name, String... aliases) { protected SubCommand(String name, String... aliases) {
@@ -0,0 +1,48 @@
package fr.luc.crcore.command.builtin;
import fr.luc.crcore.command.BaseCommand;
import fr.luc.crcore.command.builtin.team.TeamGroupSubCommand;
import fr.luc.crcore.player.PlayerProfileService;
import fr.luc.crcore.team.TeamService;
import java.util.Objects;
/**
* Commande racine {@code /core}. Container des groupes par défaut.
*
* <p>Branchée par {@code CRCore.enable()} sur la {@code PluginCommand "core"}
* du plugin de jeu (qui doit l'avoir déclarée dans son {@code plugin.yml}).
*
* <p>Sans arguments, affiche l'aide des groupes disponibles. Avec {@code team
* <action>}, route vers {@link TeamGroupSubCommand}.
*
* <h2>Override</h2>
* Pour remplacer un groupe entier :
* <pre>{@code
* core.getCoreCommand().replaceSubCommand("team", new MyTeamGroup(svc));
* }</pre>
* Pour remplacer une feuille :
* <pre>{@code
* core.getCoreCommand().findSubCommand("team")
* .ifPresent(t -> t.replaceSubCommand("create", new MyCreate(svc)));
* }</pre>
*/
public class CoreCommand extends BaseCommand {
protected final TeamService teamService;
protected final PlayerProfileService playerProfileService;
public CoreCommand(TeamService teamService, PlayerProfileService playerProfileService) {
super("core", "cr", "crcore");
this.teamService = Objects.requireNonNull(teamService, "teamService");
this.playerProfileService = Objects.requireNonNull(playerProfileService, "playerProfileService");
description("Commandes du noyau CR-Core");
registerDefaults();
}
/** Enregistre les groupes par défaut. Override pour ajouter / retirer des groupes. */
protected void registerDefaults() {
addSubCommand(new TeamGroupSubCommand(teamService));
// Futur : addSubCommand(new PlayerGroupSubCommand(playerProfileService));
}
}
@@ -0,0 +1,49 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.ArgumentTypes;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team add <player>}
*
* <p>Le chef ajoute un joueur à son équipe. Marche que la team soit PUBLIC ou
* PRIVATE — c'est une action chef, pas un auto-join.
*/
public class TeamAddSubCommand extends SubCommand {
protected final TeamService service;
public TeamAddSubCommand(TeamService service) {
super("add", "invite");
this.service = Objects.requireNonNull(service, "service");
description("Ajouter un joueur à son équipe (chef uniquement)");
playerOnly();
argument("player", ArgumentTypes.ONLINE_PLAYER);
}
@Override
public CommandResult execute(CommandContext ctx) {
Player executor = ctx.requirePlayer();
Player target = ctx.get("player");
Team team = service.getTeamOfPlayer(executor.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.getLeaderId().equals(executor.getUniqueId())) {
return CommandResult.failure("Seul le chef peut ajouter des membres.");
}
if (service.getTeamOfPlayer(target.getUniqueId()).isPresent()) {
return CommandResult.failure(target.getName() + " est déjà dans une équipe.");
}
service.addMember(team.getId(), target.getUniqueId());
return CommandResult.success(target.getName() + " ajouté à l'équipe " + team.getName() + ".");
}
}
@@ -0,0 +1,47 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.ArgumentType;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.command.CommandSender;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* Types d'arguments spécifiques aux commandes team. Fournit un
* {@link ArgumentType} qui résout un nom d'équipe en {@link Team} et propose
* la tab-complétion des équipes existantes.
*/
public final class TeamArgumentTypes {
private TeamArgumentTypes() {
}
/**
* Résout un nom d'équipe en {@link Team} (case-insensitive) en interrogeant
* le {@link TeamService}. Suggère les noms d'équipes existantes en
* tab-completion.
*/
public static ArgumentType<Team> teamByName(TeamService service) {
Objects.requireNonNull(service, "service");
return new ArgumentType<>() {
@Override
public Team parse(String input) {
return service.getTeamByName(input).orElseThrow(() ->
new fr.luc.crcore.command.CommandException(
"Aucune équipe trouvée : " + input));
}
@Override
public List<String> suggestions(CommandSender sender, String partial) {
String lower = partial.toLowerCase();
return service.getAllTeams().stream()
.map(Team::getName)
.filter(n -> n.toLowerCase().startsWith(lower))
.collect(Collectors.toList());
}
};
}
}
@@ -0,0 +1,54 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.ArgumentTypes;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamColor;
import fr.luc.crcore.team.TeamException;
import fr.luc.crcore.team.TeamService;
import fr.luc.crcore.team.TeamVisibility;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team create <name> <tag> <color> [visibility]}
*
* <p>Crée une équipe dont l'exécutant devient le chef. Visibilité par défaut :
* {@link TeamVisibility#PRIVATE}.
*/
public class TeamCreateSubCommand extends SubCommand {
protected final TeamService service;
public TeamCreateSubCommand(TeamService service) {
super("create", "c", "new");
this.service = Objects.requireNonNull(service, "service");
description("Créer une équipe");
permission("crcore.team.create");
playerOnly();
argument("name", ArgumentTypes.STRING);
argument("tag", ArgumentTypes.STRING);
argument("color", ArgumentTypes.enumOf(TeamColor.class));
optionalArgument("visibility", ArgumentTypes.enumOf(TeamVisibility.class));
}
@Override
public CommandResult execute(CommandContext ctx) {
Player player = ctx.requirePlayer();
String name = ctx.get("name");
String tag = ctx.get("tag");
TeamColor color = ctx.get("color");
TeamVisibility visibility = ctx.<TeamVisibility>getOptional("visibility")
.orElse(TeamVisibility.PRIVATE);
try {
Team team = service.createTeam(name, tag, color, player.getUniqueId(), visibility);
return CommandResult.success("Équipe " + team.getName() + " [#" + team.getTag() + "] créée.");
} catch (TeamException ex) {
return CommandResult.failure(ex.getMessage());
}
}
}
@@ -0,0 +1,45 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team delete}
*
* <p>Dissout l'équipe de l'exécutant. Réservé au chef. Pas d'argument :
* l'équipe ciblée est déduite du joueur.
*
* <p>Aliases : {@code disband}, {@code dissolve}.
*/
public class TeamDeleteSubCommand extends SubCommand {
protected final TeamService service;
public TeamDeleteSubCommand(TeamService service) {
super("delete", "disband", "dissolve");
this.service = Objects.requireNonNull(service, "service");
description("Dissoudre son équipe (chef uniquement)");
playerOnly();
}
@Override
public CommandResult execute(CommandContext ctx) {
Player player = ctx.requirePlayer();
Team team = service.getTeamOfPlayer(player.getUniqueId())
.orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.getLeaderId().equals(player.getUniqueId())) {
return CommandResult.failure("Seul le chef peut dissoudre l'équipe.");
}
service.dissolveTeam(team.getId());
return CommandResult.success("Équipe " + team.getName() + " dissoute.");
}
}
@@ -0,0 +1,51 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.TeamService;
import java.util.Objects;
/**
* Groupe {@code /core team ...} : container de toutes les sous-commandes
* d'équipe par défaut.
*
* <p>Pour overrider une sous-commande, un plugin de jeu fait :
* <pre>{@code
* core.getCoreCommand().findSubCommand("team")
* .ifPresent(team -> team.replaceSubCommand("create", new MyCustomCreate(svc)));
* }</pre>
*
* <p>Ou sous-classe {@code TeamGroupSubCommand} et redéfinit son constructeur
* pour swap ce qu'il faut.
*/
public class TeamGroupSubCommand extends SubCommand {
protected final TeamService service;
public TeamGroupSubCommand(TeamService service) {
super("team", "t");
this.service = Objects.requireNonNull(service, "service");
description("Gestion des équipes");
registerDefaults();
}
/**
* Enregistre toutes les sous-commandes par défaut. Override pour exclure
* ou ajouter des sous-commandes au lieu du jeu standard.
*/
protected void registerDefaults() {
addSubCommand(new TeamCreateSubCommand(service));
addSubCommand(new TeamDeleteSubCommand(service));
addSubCommand(new TeamAddSubCommand(service));
addSubCommand(new TeamRemoveSubCommand(service));
addSubCommand(new TeamJoinSubCommand(service));
addSubCommand(new TeamLeaveSubCommand(service));
addSubCommand(new TeamInfoSubCommand(service));
addSubCommand(new TeamListSubCommand(service));
addSubCommand(new TeamTransferSubCommand(service));
addSubCommand(new TeamVisibilitySubCommand(service));
addSubCommand(new TeamScoreSubCommand(service));
addSubCommand(new TeamTopSubCommand(service));
addSubCommand(new TeamSetSpawnSubCommand(service));
}
}
@@ -0,0 +1,72 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamMember;
import fr.luc.crcore.team.TeamService;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.entity.Player;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* {@code /core team info [name]}
*
* <p>Affiche les infos d'une équipe. Si aucun nom n'est donné, affiche celle
* de l'exécutant.
*/
public class TeamInfoSubCommand extends SubCommand {
protected final TeamService service;
public TeamInfoSubCommand(TeamService service) {
super("info", "i");
this.service = Objects.requireNonNull(service, "service");
description("Afficher les infos d'une équipe");
optionalArgument("name", TeamArgumentTypes.teamByName(service));
}
@Override
public CommandResult execute(CommandContext ctx) {
Team team = ctx.<Team>getOptional("name").orElseGet(() -> {
if (ctx.isPlayer()) {
Player p = (Player) ctx.getSender();
return service.getTeamOfPlayer(p.getUniqueId()).orElse(null);
}
return null;
});
if (team == null) {
return CommandResult.failure("Aucune équipe spécifiée et vous n'êtes pas dans une équipe.");
}
StringBuilder sb = new StringBuilder();
ChatColor c = team.getColor().getChatColor();
sb.append(c).append("=== ").append(team.getName())
.append(" [#").append(team.getTag()).append("] ===\n");
sb.append(ChatColor.GRAY).append("Couleur : ").append(c).append(team.getColor().getDisplayName()).append('\n');
sb.append(ChatColor.GRAY).append("Visibilité : ").append(ChatColor.WHITE).append(team.getVisibility()).append('\n');
sb.append(ChatColor.GRAY).append("Membres (").append(team.size()).append(") : ").append(ChatColor.WHITE);
sb.append(team.getMembers().stream()
.map(m -> Bukkit.getOfflinePlayer(m.getPlayerId()).getName() +
(m.isLeader() ? "" : ""))
.collect(Collectors.joining(", ")));
if (!team.getScores().isEmpty()) {
sb.append('\n').append(ChatColor.GRAY).append("Scores : ").append(ChatColor.WHITE)
.append(team.getScores().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining(", ")));
}
if (team.hasSpawnPoint()) {
team.getSpawnPoint().ifPresent(loc -> sb.append('\n').append(ChatColor.GRAY).append("Spawn : ")
.append(ChatColor.WHITE).append(loc.getWorld().getName()).append(' ')
.append((int) loc.getX()).append('/').append((int) loc.getY()).append('/').append((int) loc.getZ()));
}
ctx.reply(sb.toString());
return CommandResult.success();
}
}
@@ -0,0 +1,43 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamException;
import fr.luc.crcore.team.TeamService;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team join <name>}
*
* <p>Auto-join sur une équipe PUBLIC. Lève une {@link TeamException} si la
* team est privée ou si le joueur est déjà dans une équipe (rendu en
* message d'erreur).
*/
public class TeamJoinSubCommand extends SubCommand {
protected final TeamService service;
public TeamJoinSubCommand(TeamService service) {
super("join", "j");
this.service = Objects.requireNonNull(service, "service");
description("Rejoindre une équipe publique");
playerOnly();
argument("name", TeamArgumentTypes.teamByName(service));
}
@Override
public CommandResult execute(CommandContext ctx) {
Player player = ctx.requirePlayer();
Team team = ctx.get("name");
try {
service.joinTeam(team.getId(), player.getUniqueId());
return CommandResult.success("Vous avez rejoint " + team.getName() + ".");
} catch (TeamException ex) {
return CommandResult.failure(ex.getMessage());
}
}
}
@@ -0,0 +1,43 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team leave}
*
* <p>Le joueur quitte volontairement son équipe. Refusé pour le chef (il doit
* d'abord transférer le leadership ou dissoudre l'équipe).
*/
public class TeamLeaveSubCommand extends SubCommand {
protected final TeamService service;
public TeamLeaveSubCommand(TeamService service) {
super("leave", "quit");
this.service = Objects.requireNonNull(service, "service");
description("Quitter son équipe");
playerOnly();
}
@Override
public CommandResult execute(CommandContext ctx) {
Player player = ctx.requirePlayer();
Team team = service.getTeamOfPlayer(player.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (team.getLeaderId().equals(player.getUniqueId())) {
return CommandResult.failure(
"Vous êtes le chef. Transférez le leadership avec /core team transfer <player>, ou dissolvez avec /core team delete.");
}
service.removeMember(team.getId(), player.getUniqueId());
return CommandResult.success("Vous avez quitté l'équipe " + team.getName() + ".");
}
}
@@ -0,0 +1,47 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.ChatColor;
import java.util.Collection;
import java.util.Objects;
/**
* {@code /core team list}
*
* <p>Affiche toutes les équipes existantes avec leur tag, nom, taille et
* visibilité.
*/
public class TeamListSubCommand extends SubCommand {
protected final TeamService service;
public TeamListSubCommand(TeamService service) {
super("list", "ls");
this.service = Objects.requireNonNull(service, "service");
description("Lister toutes les équipes");
}
@Override
public CommandResult execute(CommandContext ctx) {
Collection<Team> teams = service.getAllTeams();
if (teams.isEmpty()) {
return CommandResult.success("Aucune équipe pour le moment.");
}
StringBuilder sb = new StringBuilder(ChatColor.YELLOW + "Équipes (" + teams.size() + ") :");
for (Team team : teams) {
ChatColor c = team.getColor().getChatColor();
sb.append('\n').append(ChatColor.GRAY).append(" - ")
.append(c).append('[').append(team.getTag()).append("] ")
.append(team.getName())
.append(ChatColor.GRAY).append(" (").append(team.size()).append(" membres, ")
.append(team.getVisibility()).append(")");
}
ctx.reply(sb.toString());
return CommandResult.success();
}
}
@@ -0,0 +1,57 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.ArgumentTypes;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team remove <player>}
*
* <p>Le chef retire un membre. Accepte les joueurs offline (utilise leur nom
* pour résoudre l'UUID via {@link Bukkit#getOfflinePlayer(String)}).
*/
public class TeamRemoveSubCommand extends SubCommand {
protected final TeamService service;
public TeamRemoveSubCommand(TeamService service) {
super("remove", "kick", "expel");
this.service = Objects.requireNonNull(service, "service");
description("Retirer un joueur de son équipe (chef uniquement)");
playerOnly();
argument("player", ArgumentTypes.STRING);
}
@Override
public CommandResult execute(CommandContext ctx) {
Player executor = ctx.requirePlayer();
String targetName = ctx.get("player");
Team team = service.getTeamOfPlayer(executor.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.getLeaderId().equals(executor.getUniqueId())) {
return CommandResult.failure("Seul le chef peut retirer des membres.");
}
@SuppressWarnings("deprecation")
OfflinePlayer target = Bukkit.getOfflinePlayer(targetName);
if (target.getUniqueId().equals(executor.getUniqueId())) {
return CommandResult.failure("Pour quitter l'équipe en tant que chef, transférez d'abord le leadership.");
}
if (!team.hasMember(target.getUniqueId())) {
return CommandResult.failure(targetName + " n'est pas dans votre équipe.");
}
service.removeMember(team.getId(), target.getUniqueId());
return CommandResult.success(targetName + " retiré de l'équipe.");
}
}
@@ -0,0 +1,50 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.ArgumentTypes;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import java.util.Objects;
/**
* {@code /core team score <team> <name> <add|set> <value>}
*
* <p>Commande admin pour ajuster les scores d'une équipe à la main (debug,
* fix, init). Le gameplay normal pilote les scores via le service, pas via
* cette commande.
*
* <p>Restreinte par défaut à la permission {@code crcore.team.score.modify}.
*/
public class TeamScoreSubCommand extends SubCommand {
protected final TeamService service;
public TeamScoreSubCommand(TeamService service) {
super("score");
this.service = Objects.requireNonNull(service, "service");
description("[Admin] Modifier le score d'une équipe");
permission("crcore.team.score.modify");
argument("team", TeamArgumentTypes.teamByName(service));
argument("name", ArgumentTypes.STRING);
argument("op", ArgumentTypes.choice("add", "set"));
argument("value", ArgumentTypes.INTEGER);
}
@Override
public CommandResult execute(CommandContext ctx) {
Team team = ctx.get("team");
String name = ctx.get("name");
String op = ctx.get("op");
int value = ctx.get("value");
int result = switch (op) {
case "add" -> service.addScore(team.getId(), name, value);
case "set" -> service.setScore(team.getId(), name, value);
default -> throw new IllegalStateException("unreachable: " + op);
};
return CommandResult.success("Score " + name + " de " + team.getName() + " = " + result);
}
}
@@ -0,0 +1,41 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team setspawn}
*
* <p>Définit le point de spawn de l'équipe à la position courante du chef.
*/
public class TeamSetSpawnSubCommand extends SubCommand {
protected final TeamService service;
public TeamSetSpawnSubCommand(TeamService service) {
super("setspawn", "spawn");
this.service = Objects.requireNonNull(service, "service");
description("Définir le point de spawn de l'équipe (chef uniquement)");
playerOnly();
}
@Override
public CommandResult execute(CommandContext ctx) {
Player player = ctx.requirePlayer();
Team team = service.getTeamOfPlayer(player.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.getLeaderId().equals(player.getUniqueId())) {
return CommandResult.failure("Seul le chef peut définir le spawn.");
}
service.setSpawnPoint(team.getId(), player.getLocation());
return CommandResult.success("Spawn de l'équipe défini à votre position.");
}
}
@@ -0,0 +1,58 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.ArgumentTypes;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.TeamRanking;
import fr.luc.crcore.team.TeamService;
import org.bukkit.ChatColor;
import java.util.List;
import java.util.Objects;
/**
* {@code /core team top [score]}
*
* <p>Affiche le classement des équipes. Sans argument, classement global
* (somme de tous les scores). Avec un nom de score, classement sur ce score
* précis.
*/
public class TeamTopSubCommand extends SubCommand {
protected final TeamService service;
protected final int limit;
public TeamTopSubCommand(TeamService service) {
this(service, 10);
}
public TeamTopSubCommand(TeamService service, int limit) {
super("top", "ranking", "leaderboard");
this.service = Objects.requireNonNull(service, "service");
this.limit = limit;
description("Classement des équipes");
optionalArgument("score", ArgumentTypes.STRING);
}
@Override
public CommandResult execute(CommandContext ctx) {
String scoreName = ctx.<String>getOptional("score").orElse(null);
List<TeamRanking> ranking = scoreName == null
? service.getTopGlobalRanking(limit)
: service.getTopRankingByScore(scoreName, limit);
if (ranking.isEmpty()) {
return CommandResult.success("Aucune équipe à classer.");
}
StringBuilder sb = new StringBuilder(ChatColor.YELLOW + "Top " + ranking.size() +
(scoreName == null ? " (global) :" : " (" + scoreName + ") :"));
for (TeamRanking r : ranking) {
sb.append('\n').append(ChatColor.GRAY).append(" ").append(r.rank()).append(". ")
.append(r.team().getColor().getChatColor()).append(r.team().getName())
.append(ChatColor.GRAY).append("").append(ChatColor.WHITE).append(r.score());
}
ctx.reply(sb.toString());
return CommandResult.success();
}
}
@@ -0,0 +1,51 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.ArgumentTypes;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team transfer <player>}
*
* <p>Le chef transmet son rôle à un autre membre existant de son équipe.
*/
public class TeamTransferSubCommand extends SubCommand {
protected final TeamService service;
public TeamTransferSubCommand(TeamService service) {
super("transfer");
this.service = Objects.requireNonNull(service, "service");
description("Transférer le rôle de chef à un autre membre");
playerOnly();
argument("player", ArgumentTypes.STRING);
}
@Override
public CommandResult execute(CommandContext ctx) {
Player executor = ctx.requirePlayer();
String targetName = ctx.get("player");
Team team = service.getTeamOfPlayer(executor.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.getLeaderId().equals(executor.getUniqueId())) {
return CommandResult.failure("Seul le chef peut transférer le leadership.");
}
@SuppressWarnings("deprecation")
OfflinePlayer target = Bukkit.getOfflinePlayer(targetName);
if (!team.hasMember(target.getUniqueId())) {
return CommandResult.failure(targetName + " n'est pas dans votre équipe.");
}
service.transferLeadership(team.getId(), target.getUniqueId());
return CommandResult.success("Leadership transféré à " + targetName + ".");
}
}
@@ -0,0 +1,46 @@
package fr.luc.crcore.command.builtin.team;
import fr.luc.crcore.command.ArgumentTypes;
import fr.luc.crcore.command.CommandContext;
import fr.luc.crcore.command.CommandResult;
import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import fr.luc.crcore.team.TeamVisibility;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team visibility <PUBLIC|PRIVATE>}
*
* <p>Le chef change la visibilité de son équipe. PUBLIC = les autres joueurs
* peuvent rejoindre avec {@code /core team join}.
*/
public class TeamVisibilitySubCommand extends SubCommand {
protected final TeamService service;
public TeamVisibilitySubCommand(TeamService service) {
super("visibility", "vis");
this.service = Objects.requireNonNull(service, "service");
description("Changer la visibilité de son équipe");
playerOnly();
argument("visibility", ArgumentTypes.enumOf(TeamVisibility.class));
}
@Override
public CommandResult execute(CommandContext ctx) {
Player player = ctx.requirePlayer();
TeamVisibility visibility = ctx.get("visibility");
Team team = service.getTeamOfPlayer(player.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.getLeaderId().equals(player.getUniqueId())) {
return CommandResult.failure("Seul le chef peut changer la visibilité.");
}
service.setVisibility(team.getId(), visibility);
return CommandResult.success("Visibilité réglée sur " + visibility + ".");
}
}
@@ -4,6 +4,17 @@ import java.util.Collection;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
/**
* Contrat CRUD générique pour tout aggregate {@link Identifiable}. Permet de
* brancher différents backends (in-memory, SQLite, …) sans toucher au code
* service.
*
* <p>Implémentations CR-Core par défaut :
* <ul>
* <li>{@code InMemoryTeamRepository} / {@code SqliteTeamRepository}</li>
* <li>{@code InMemoryPlayerProfileRepository} / {@code SqlitePlayerProfileRepository}</li>
* </ul>
*/
public interface Repository<T extends Identifiable> { public interface Repository<T extends Identifiable> {
T save(T entity); T save(T entity);
@@ -2,6 +2,18 @@ package fr.luc.crcore.common;
import java.util.Map; import java.util.Map;
/**
* Contrat partagé par tout ce qui porte des scores nommés. Implémenté par
* {@link fr.luc.crcore.team.Team} et {@link fr.luc.crcore.player.PlayerProfile}.
*
* <p>Les scores sont identifiés par un nom libre (ex. {@code "kills"},
* {@code "objectives"}, {@code "global"}) et stockés comme entiers. Un jeu
* mono-score peut conventionnellement utiliser {@code "global"}.
*
* <p>{@link #getScore(String)} renvoie 0 pour un score jamais initialisé
* (utile pour {@code addScore("kills", 1)} sans set préalable). Pour
* distinguer "jamais set" et "set à 0", utiliser {@link #hasScore(String)}.
*/
public interface ScoreHolder { public interface ScoreHolder {
int getScore(String scoreName); int getScore(String scoreName);
@@ -0,0 +1,36 @@
package fr.luc.crcore.database;
/**
* Types de colonnes supportés par {@link TableBuilder}, chacun mappé sur un
* type natif SQLite. Volontairement réduit : SQLite est faiblement typé, ces
* 6 types couvrent largement les besoins d'un plugin Minecraft.
*
* <ul>
* <li>{@link #INTEGER} — entiers 32/64 bits</li>
* <li>{@link #REAL} — flottants (double)</li>
* <li>{@link #TEXT} — chaînes UTF-8</li>
* <li>{@link #BLOB} — données binaires brutes</li>
* <li>{@link #BOOLEAN} — stocké comme INTEGER (0/1) côté SQLite</li>
* <li>{@link #UUID} — stocké comme TEXT (forme canonique 36 caractères)</li>
* </ul>
*/
public enum ColumnType {
INTEGER("INTEGER"),
REAL("REAL"),
TEXT("TEXT"),
BLOB("BLOB"),
BOOLEAN("INTEGER"),
UUID("TEXT");
private final String sqlType;
ColumnType(String sqlType) {
this.sqlType = sqlType;
}
/** Nom SQL natif côté SQLite. */
public String getSqlType() {
return sqlType;
}
}
@@ -0,0 +1,207 @@
package fr.luc.crcore.database;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/**
* Façade SQLite minimaliste pour CR-Core et les plugins de jeu downstream.
*
* <p>Ouvre une connexion JDBC vers un fichier SQLite et expose 4 méthodes pour
* couvrir 95 % des besoins :
*
* <ul>
* <li>{@link #execute(String, Object...)} — DDL ou DML qui ne renvoie rien</li>
* <li>{@link #update(String, Object...)} — INSERT/UPDATE/DELETE, renvoie le nombre de lignes affectées</li>
* <li>{@link #queryOne(String, RowMapper, Object...)} — SELECT renvoyant au plus une ligne</li>
* <li>{@link #query(String, RowMapper, Object...)} — SELECT renvoyant plusieurs lignes</li>
* </ul>
*
* <p>Les paramètres ({@code Object...}) sont liés via PreparedStatement (donc
* pas d'injection SQL possible). Les {@link UUID} sont automatiquement
* convertis en {@code TEXT}.
*
* <p>Pour créer une table de manière fluide, voir {@link #table(String)}.
*
* <p>Cette classe n'est pas thread-safe. Un plugin Bukkit accède en général
* à la DB depuis le main thread ; pour de l'async, synchroniser explicitement
* ou ouvrir plusieurs instances.
*/
public class Database implements AutoCloseable {
private final Connection connection;
/**
* Ouvre (ou crée) un fichier SQLite. Le dossier parent doit exister.
*
* @throws DatabaseException si le driver JDBC SQLite est absent du
* classpath, ou si l'ouverture du fichier échoue.
*/
public Database(File file) {
Objects.requireNonNull(file, "file");
try {
// Force le chargement du driver (utile sur certains classloaders Bukkit).
Class.forName("org.sqlite.JDBC");
String url = "jdbc:sqlite:" + file.getAbsolutePath();
this.connection = DriverManager.getConnection(url);
// Active les foreign keys (désactivées par défaut sur SQLite).
execute("PRAGMA foreign_keys = ON");
} catch (ClassNotFoundException ex) {
throw new DatabaseException(
"SQLite JDBC driver not found on classpath (org.sqlite.JDBC).", ex);
} catch (SQLException ex) {
throw new DatabaseException("Failed to open SQLite database: " + file, ex);
}
}
/** Connexion JDBC sous-jacente, pour les cas avancés (transactions custom, etc.). */
public Connection getConnection() {
return connection;
}
/**
* Démarre la création d'une table de manière fluide.
*
* <pre>{@code
* db.table("foo")
* .ifNotExists()
* .column("id", ColumnType.UUID).primaryKey()
* .column("name", ColumnType.TEXT).notNull()
* .create();
* }</pre>
*/
public TableBuilder table(String name) {
return new TableBuilder(this, name);
}
/** Vérifie l'existence d'une table dans la base. */
public boolean tableExists(String name) {
Objects.requireNonNull(name, "name");
return queryOne(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
rs -> rs.getString(1),
name
).isPresent();
}
/**
* Exécute un statement SQL (DDL ou autre) sans collecter de résultat. Pour
* les INSERT/UPDATE/DELETE préférer {@link #update}.
*/
public void execute(String sql, Object... params) {
try (PreparedStatement stmt = prepare(sql, params)) {
stmt.execute();
} catch (SQLException ex) {
throw new DatabaseException("execute() failed: " + sql, ex);
}
}
/**
* Exécute un INSERT/UPDATE/DELETE et renvoie le nombre de lignes affectées.
*/
public int update(String sql, Object... params) {
try (PreparedStatement stmt = prepare(sql, params)) {
return stmt.executeUpdate();
} catch (SQLException ex) {
throw new DatabaseException("update() failed: " + sql, ex);
}
}
/**
* Exécute un SELECT et renvoie au plus une ligne. {@link Optional#empty()}
* si aucune ligne. Lève {@link DatabaseException} si plusieurs lignes
* matchent — penser à mettre {@code LIMIT 1} ou un {@code WHERE} unique.
*/
public <T> Optional<T> queryOne(String sql, RowMapper<T> mapper, Object... params) {
Objects.requireNonNull(mapper, "mapper");
try (PreparedStatement stmt = prepare(sql, params);
ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) return Optional.empty();
T value = mapper.map(rs);
if (rs.next()) {
throw new DatabaseException("queryOne() returned more than one row: " + sql);
}
return Optional.ofNullable(value);
} catch (SQLException ex) {
throw new DatabaseException("queryOne() failed: " + sql, ex);
}
}
/** Exécute un SELECT et renvoie la liste de toutes les lignes mappées. */
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... params) {
Objects.requireNonNull(mapper, "mapper");
List<T> results = new ArrayList<>();
try (PreparedStatement stmt = prepare(sql, params);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
results.add(mapper.map(rs));
}
} catch (SQLException ex) {
throw new DatabaseException("query() failed: " + sql, ex);
}
return results;
}
/**
* Exécute un bloc dans une transaction. Commit automatique si le bloc se
* termine sans exception, rollback sinon. Pour des opérations multiples
* qui doivent être atomiques (ex. créer une équipe + ses membres).
*/
public void inTransaction(Runnable block) {
Objects.requireNonNull(block, "block");
try {
connection.setAutoCommit(false);
try {
block.run();
connection.commit();
} catch (RuntimeException ex) {
connection.rollback();
throw ex;
} finally {
connection.setAutoCommit(true);
}
} catch (SQLException ex) {
throw new DatabaseException("Transaction failed", ex);
}
}
@Override
public void close() {
try {
if (connection != null && !connection.isClosed()) {
connection.close();
}
} catch (SQLException ex) {
throw new DatabaseException("Failed to close database", ex);
}
}
// ---- Internal ----
private PreparedStatement prepare(String sql, Object[] params) throws SQLException {
PreparedStatement stmt = connection.prepareStatement(sql);
if (params != null) {
for (int i = 0; i < params.length; i++) {
stmt.setObject(i + 1, normalize(params[i]));
}
}
return stmt;
}
/** Convertit les valeurs Java non-natives SQL en types utilisables par JDBC. */
private static Object normalize(Object value) {
if (value == null) return null;
if (value instanceof UUID uuid) return uuid.toString();
if (value instanceof Enum<?> e) return e.name();
if (value instanceof Boolean b) return b ? 1 : 0;
return value;
}
}
@@ -0,0 +1,18 @@
package fr.luc.crcore.database;
/**
* Exception levée pour toute erreur de persistance (ouverture de connexion,
* exécution SQL, mapping de résultat). Toujours basée sur une {@link Throwable}
* d'origine (généralement {@link java.sql.SQLException}) accessible via
* {@link #getCause()}.
*/
public class DatabaseException extends RuntimeException {
public DatabaseException(String message) {
super(message);
}
public DatabaseException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,22 @@
package fr.luc.crcore.database;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* Convertit une ligne d'un {@link ResultSet} en un objet Java.
*
* <p>Utilisé par {@link Database#query} et {@link Database#queryOne}. Le mapper
* <b>ne doit pas</b> appeler {@code rs.next()} : c'est {@link Database} qui
* itère.
*
* <pre>{@code
* RowMapper<String> nameMapper = rs -> rs.getString("name");
* List<String> names = db.query("SELECT name FROM teams", nameMapper);
* }</pre>
*/
@FunctionalInterface
public interface RowMapper<T> {
T map(ResultSet rs) throws SQLException;
}
@@ -0,0 +1,120 @@
package fr.luc.crcore.database;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Builder fluide pour créer une table SQL en quelques lignes. Obtenu via
* {@link Database#table(String)}.
*
* <pre>{@code
* db.table("my_scores")
* .ifNotExists()
* .column("player_id", ColumnType.UUID).primaryKey()
* .column("score", ColumnType.INTEGER).notNull().defaultValue("0")
* .column("updated_at", ColumnType.INTEGER).notNull()
* .create();
* }</pre>
*
* <p>Ne supporte pas (volontairement) les FOREIGN KEY ni les contraintes
* multi-colonnes pour rester simple. Pour ces cas, utiliser
* {@link Database#execute(String, Object...)} avec un {@code CREATE TABLE}
* brut.
*/
public class TableBuilder {
private final Database database;
private final String name;
private final List<ColumnDef> columns = new ArrayList<>();
private boolean ifNotExists = false;
TableBuilder(Database database, String name) {
this.database = Objects.requireNonNull(database, "database");
this.name = Objects.requireNonNull(name, "name");
}
/** Ajoute {@code IF NOT EXISTS} à la création (idempotent). */
public TableBuilder ifNotExists() {
this.ifNotExists = true;
return this;
}
/**
* Démarre la définition d'une colonne. Renvoie un {@link ColumnDef} sur
* lequel on chaîne {@code .primaryKey()}, {@code .notNull()}, etc.
*/
public ColumnDef column(String name, ColumnType type) {
ColumnDef def = new ColumnDef(this, name, type);
columns.add(def);
return def;
}
/** Exécute le {@code CREATE TABLE}. Lève {@link DatabaseException} en cas d'échec. */
public void create() {
if (columns.isEmpty()) {
throw new IllegalStateException("Cannot create table '" + name + "' with no columns.");
}
StringBuilder sql = new StringBuilder("CREATE TABLE ");
if (ifNotExists) sql.append("IF NOT EXISTS ");
sql.append('\"').append(name).append("\" (");
for (int i = 0; i < columns.size(); i++) {
if (i > 0) sql.append(", ");
sql.append(columns.get(i).toSql());
}
sql.append(')');
database.execute(sql.toString());
}
/**
* Définition d'une colonne en cours de construction. Toutes les méthodes
* renvoient {@code this} (ou le {@link TableBuilder} pour continuer la
* définition d'une autre colonne).
*/
public static class ColumnDef {
private final TableBuilder parent;
private final String name;
private final ColumnType type;
private boolean primaryKey = false;
private boolean notNull = false;
private boolean unique = false;
private String defaultValue = null;
ColumnDef(TableBuilder parent, String name, ColumnType type) {
this.parent = parent;
this.name = Objects.requireNonNull(name, "name");
this.type = Objects.requireNonNull(type, "type");
}
public ColumnDef primaryKey() { this.primaryKey = true; return this; }
public ColumnDef notNull() { this.notNull = true; return this; }
public ColumnDef unique() { this.unique = true; return this; }
/** Valeur par défaut en clause {@code DEFAULT}. Le texte est inséré tel quel — penser à quoter les String. */
public ColumnDef defaultValue(String sqlExpression) {
this.defaultValue = sqlExpression;
return this;
}
/** Démarre une nouvelle colonne (raccourci pour {@code .build().column(...)}). */
public ColumnDef column(String name, ColumnType type) {
return parent.column(name, type);
}
/** Termine la définition et lance le {@code CREATE TABLE}. */
public void create() {
parent.create();
}
String toSql() {
StringBuilder sb = new StringBuilder();
sb.append('\"').append(name).append("\" ").append(type.getSqlType());
if (primaryKey) sb.append(" PRIMARY KEY");
if (notNull) sb.append(" NOT NULL");
if (unique) sb.append(" UNIQUE");
if (defaultValue != null) sb.append(" DEFAULT ").append(defaultValue);
return sb.toString();
}
}
}
@@ -0,0 +1,43 @@
package fr.luc.crcore.player;
import fr.luc.crcore.player.event.PlayerProfileCreateEvent;
import fr.luc.crcore.player.event.PlayerProfileDeleteEvent;
import fr.luc.crcore.player.event.PlayerScoreChangeEvent;
import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.Objects;
/**
* Variante de {@link PlayerProfileServiceImpl} qui tire des évènements Bukkit
* via les hooks {@code on...}. Utilisée par défaut par {@code CRCore}.
*/
public class BukkitEventFiringPlayerProfileServiceImpl extends PlayerProfileServiceImpl {
private final JavaPlugin plugin;
public BukkitEventFiringPlayerProfileServiceImpl(JavaPlugin plugin, PlayerProfileRepository repository) {
super(repository);
this.plugin = Objects.requireNonNull(plugin, "plugin");
}
protected JavaPlugin getPlugin() {
return plugin;
}
@Override
protected void onProfileCreated(PlayerProfile profile) {
Bukkit.getPluginManager().callEvent(new PlayerProfileCreateEvent(profile));
}
@Override
protected void onProfileDeleted(PlayerProfile profile) {
Bukkit.getPluginManager().callEvent(new PlayerProfileDeleteEvent(profile));
}
@Override
protected void onScoreChanged(PlayerProfile profile, String scoreName, int oldValue, int newValue) {
Bukkit.getPluginManager().callEvent(
new PlayerScoreChangeEvent(profile, scoreName, oldValue, newValue));
}
}
@@ -9,6 +9,14 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
/**
* Profil persistant d'un joueur. Identifié par l'UUID Bukkit du joueur,
* porte ses scores nommés.
*
* <p>Indépendant du domaine team : un joueur peut entrer / quitter / changer
* d'équipe sans toucher à son profil. Géré par {@link PlayerProfileService}
* qui auto-crée le profil à la première écriture de score.
*/
public class PlayerProfile extends AbstractEntity implements ScoreHolder { public class PlayerProfile extends AbstractEntity implements ScoreHolder {
private final Map<String, Integer> scores; private final Map<String, Integer> scores;
@@ -6,6 +6,13 @@ import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* Façade pour les profils joueurs : lifecycle, scores, classements.
*
* <p>Auto-création : {@link #addScore}, {@link #setScore} créent
* automatiquement le profil s'il n'existe pas (via {@link #getOrCreateProfile}).
* Pas besoin d'initialiser explicitement.
*/
public interface PlayerProfileService { public interface PlayerProfileService {
PlayerProfile getOrCreateProfile(UUID playerId); PlayerProfile getOrCreateProfile(UUID playerId);
@@ -0,0 +1,96 @@
package fr.luc.crcore.player;
import fr.luc.crcore.database.ColumnType;
import fr.luc.crcore.database.Database;
import java.util.Objects;
import java.util.UUID;
/**
* Implémentation {@link PlayerProfileRepository} adossée à SQLite.
*
* <p>Mêmes principes que {@link fr.luc.crcore.team.SqliteTeamRepository} :
* cache mémoire en write-through, schéma créé à l'init, état rechargé depuis
* la DB au constructeur.
*
* <p>Schéma (2 tables) :
* <ul>
* <li>{@code crcore_player_profiles} — une ligne par joueur</li>
* <li>{@code crcore_player_scores} — une ligne par (joueur, score nommé)</li>
* </ul>
*/
public class SqlitePlayerProfileRepository extends InMemoryPlayerProfileRepository {
private static final String TABLE_PROFILES = "crcore_player_profiles";
private static final String TABLE_SCORES = "crcore_player_scores";
private final Database db;
public SqlitePlayerProfileRepository(Database db) {
this.db = Objects.requireNonNull(db, "db");
ensureSchema();
loadAll();
}
private void ensureSchema() {
db.table(TABLE_PROFILES).ifNotExists()
.column("id", ColumnType.UUID).primaryKey()
.create();
db.table(TABLE_SCORES).ifNotExists()
.column("profile_id", ColumnType.UUID).notNull()
.column("score_name", ColumnType.TEXT).notNull()
.column("value", ColumnType.INTEGER).notNull()
.create();
}
private void loadAll() {
var ids = db.query(
"SELECT id FROM " + TABLE_PROFILES,
rs -> UUID.fromString(rs.getString("id"))
);
for (UUID id : ids) {
PlayerProfile profile = new PlayerProfile(id);
db.query(
"SELECT score_name, value FROM " + TABLE_SCORES + " WHERE profile_id = ?",
rs -> {
profile.setScore(rs.getString("score_name"), rs.getInt("value"));
return null;
},
id
);
super.save(profile); // injecte dans le cache hérité sans repasser par notre override
}
}
@Override
public PlayerProfile save(PlayerProfile profile) {
super.save(profile);
persist(profile);
return profile;
}
@Override
public boolean delete(UUID id) {
boolean removed = super.delete(id);
if (removed) {
db.update("DELETE FROM " + TABLE_SCORES + " WHERE profile_id = ?", id);
db.update("DELETE FROM " + TABLE_PROFILES + " WHERE id = ?", id);
}
return removed;
}
private void persist(PlayerProfile profile) {
db.update(
"INSERT OR REPLACE INTO " + TABLE_PROFILES + " (id) VALUES (?)",
profile.getId()
);
db.update("DELETE FROM " + TABLE_SCORES + " WHERE profile_id = ?", profile.getId());
profile.getScores().forEach((name, value) ->
db.update(
"INSERT INTO " + TABLE_SCORES + " (profile_id, score_name, value) VALUES (?, ?, ?)",
profile.getId(), name, value
)
);
}
}
@@ -0,0 +1,23 @@
package fr.luc.crcore.player.event;
import fr.luc.crcore.player.PlayerProfile;
import org.bukkit.event.HandlerList;
/** Déclenché juste après la création d'un profil (lazy ou explicite). */
public class PlayerProfileCreateEvent extends PlayerProfileEvent {
private static final HandlerList HANDLERS = new HandlerList();
public PlayerProfileCreateEvent(PlayerProfile profile) {
super(profile);
}
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,23 @@
package fr.luc.crcore.player.event;
import fr.luc.crcore.player.PlayerProfile;
import org.bukkit.event.HandlerList;
/** Déclenché juste après la suppression d'un profil. */
public class PlayerProfileDeleteEvent extends PlayerProfileEvent {
private static final HandlerList HANDLERS = new HandlerList();
public PlayerProfileDeleteEvent(PlayerProfile profile) {
super(profile);
}
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,26 @@
package fr.luc.crcore.player.event;
import fr.luc.crcore.player.PlayerProfile;
import org.bukkit.event.Event;
import java.util.Objects;
/**
* Base abstraite pour tous les évènements liés à un {@link PlayerProfile}.
*
* <p>Chaque sous-classe concrète doit fournir sa propre {@code HandlerList}
* statique (contrainte Bukkit).
*/
public abstract class PlayerProfileEvent extends Event {
private final PlayerProfile profile;
protected PlayerProfileEvent(PlayerProfile profile) {
this.profile = Objects.requireNonNull(profile, "profile");
}
/** Le profil concerné. */
public PlayerProfile getProfile() {
return profile;
}
}
@@ -0,0 +1,40 @@
package fr.luc.crcore.player.event;
import fr.luc.crcore.player.PlayerProfile;
import org.bukkit.event.HandlerList;
import java.util.Objects;
/**
* Déclenché après changement effectif d'un score joueur. {@link #getScoreName()}
* donne le nom du score touché.
*/
public class PlayerScoreChangeEvent extends PlayerProfileEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final String scoreName;
private final int oldValue;
private final int newValue;
public PlayerScoreChangeEvent(PlayerProfile profile, String scoreName, int oldValue, int newValue) {
super(profile);
this.scoreName = Objects.requireNonNull(scoreName, "scoreName");
this.oldValue = oldValue;
this.newValue = newValue;
}
public String getScoreName() { return scoreName; }
public int getOldValue() { return oldValue; }
public int getNewValue() { return newValue; }
public int getDelta() { return newValue - oldValue; }
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,89 @@
package fr.luc.crcore.team;
import fr.luc.crcore.team.event.PlayerJoinTeamEvent;
import fr.luc.crcore.team.event.TeamCreateEvent;
import fr.luc.crcore.team.event.TeamDissolveEvent;
import fr.luc.crcore.team.event.TeamLeadershipTransferEvent;
import fr.luc.crcore.team.event.TeamMemberAddEvent;
import fr.luc.crcore.team.event.TeamMemberRemoveEvent;
import fr.luc.crcore.team.event.TeamScoreChangeEvent;
import fr.luc.crcore.team.event.TeamSpawnPointChangeEvent;
import fr.luc.crcore.team.event.TeamVisibilityChangeEvent;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.Objects;
import java.util.UUID;
/**
* Variante de {@link TeamServiceImpl} qui, en plus de la logique métier,
* <b>tire des évènements Bukkit</b> via les hooks {@code on...} hérités.
*
* <p>C'est cette implémentation qu'utilise {@code CRCore} par défaut. Les
* plugins de jeu peuvent toujours sous-classer pour ajouter d'autres effets
* (logs, scoreboard sync, etc.) en overridant les mêmes hooks et en appelant
* {@code super}.
*/
public class BukkitEventFiringTeamServiceImpl extends TeamServiceImpl {
private final JavaPlugin plugin;
public BukkitEventFiringTeamServiceImpl(JavaPlugin plugin, TeamRepository repository) {
super(repository);
this.plugin = Objects.requireNonNull(plugin, "plugin");
}
protected JavaPlugin getPlugin() {
return plugin;
}
@Override
protected void onAfterCreate(Team team) {
Bukkit.getPluginManager().callEvent(new TeamCreateEvent(team));
}
@Override
protected void onAfterDissolve(Team team) {
Bukkit.getPluginManager().callEvent(new TeamDissolveEvent(team));
}
@Override
protected void onMemberAdded(Team team, TeamMember member) {
Bukkit.getPluginManager().callEvent(new TeamMemberAddEvent(team, member));
}
@Override
protected void onMemberRemoved(Team team, UUID playerId) {
Bukkit.getPluginManager().callEvent(new TeamMemberRemoveEvent(team, playerId));
}
@Override
protected void onPlayerJoined(Team team, TeamMember member) {
Bukkit.getPluginManager().callEvent(new PlayerJoinTeamEvent(team, member));
}
@Override
protected void onLeadershipTransferred(Team team, UUID oldLeaderId, UUID newLeaderId) {
Bukkit.getPluginManager().callEvent(
new TeamLeadershipTransferEvent(team, oldLeaderId, newLeaderId));
}
@Override
protected void onVisibilityChanged(Team team, TeamVisibility oldValue, TeamVisibility newValue) {
Bukkit.getPluginManager().callEvent(
new TeamVisibilityChangeEvent(team, oldValue, newValue));
}
@Override
protected void onScoreChanged(Team team, String scoreName, int oldValue, int newValue) {
Bukkit.getPluginManager().callEvent(
new TeamScoreChangeEvent(team, scoreName, oldValue, newValue));
}
@Override
protected void onSpawnPointChanged(Team team, Location oldLocation, Location newLocation) {
Bukkit.getPluginManager().callEvent(
new TeamSpawnPointChangeEvent(team, oldLocation, newLocation));
}
}
@@ -0,0 +1,209 @@
package fr.luc.crcore.team;
import fr.luc.crcore.database.ColumnType;
import fr.luc.crcore.database.Database;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/**
* Implémentation {@link TeamRepository} adossée à SQLite.
*
* <p>Stratégie : <b>write-through cache</b>. On hérite de
* {@link InMemoryTeamRepository} pour conserver les requêtes rapides
* (findAll, findByName, etc. en mémoire), et on overrride {@link #save} et
* {@link #delete} pour persister synchroniquement vers SQLite. Le constructeur
* crée les tables si nécessaire et recharge l'état complet depuis la DB.
*
* <p>Schéma (3 tables) :
* <ul>
* <li>{@code crcore_teams} — une ligne par équipe (champs scalaires)</li>
* <li>{@code crcore_team_members} — une ligne par membre</li>
* <li>{@code crcore_team_scores} — une ligne par (équipe, score nommé)</li>
* </ul>
*
* <p>Sur {@link #save}, on supprime puis ré-insère membres et scores de
* l'équipe (approche simple et robuste pour des volumes faibles d'event).
*/
public class SqliteTeamRepository extends InMemoryTeamRepository {
private static final String TABLE_TEAMS = "crcore_teams";
private static final String TABLE_MEMBERS = "crcore_team_members";
private static final String TABLE_SCORES = "crcore_team_scores";
private final Database db;
public SqliteTeamRepository(Database db) {
this.db = Objects.requireNonNull(db, "db");
ensureSchema();
loadAll();
}
private void ensureSchema() {
db.table(TABLE_TEAMS).ifNotExists()
.column("id", ColumnType.UUID).primaryKey()
.column("name", ColumnType.TEXT).notNull().unique()
.column("tag", ColumnType.TEXT).notNull().unique()
.column("color", ColumnType.TEXT).notNull()
.column("leader_id", ColumnType.UUID).notNull()
.column("visibility", ColumnType.TEXT).notNull()
.column("spawn_world", ColumnType.TEXT)
.column("spawn_x", ColumnType.REAL)
.column("spawn_y", ColumnType.REAL)
.column("spawn_z", ColumnType.REAL)
.column("spawn_yaw", ColumnType.REAL)
.column("spawn_pitch", ColumnType.REAL)
.create();
db.table(TABLE_MEMBERS).ifNotExists()
.column("team_id", ColumnType.UUID).notNull()
.column("player_id", ColumnType.UUID).notNull()
.column("role", ColumnType.TEXT).notNull()
.column("joined_at", ColumnType.INTEGER).notNull()
.create();
// Index logique (team_id, player_id) — pas créé explicitement, SQLite gère via lookups.
db.table(TABLE_SCORES).ifNotExists()
.column("team_id", ColumnType.UUID).notNull()
.column("score_name", ColumnType.TEXT).notNull()
.column("value", ColumnType.INTEGER).notNull()
.create();
}
/** Recharge tous les Teams depuis la DB dans le cache mémoire hérité. */
private void loadAll() {
// On query toutes les équipes en flat puis on ré-hydrate.
var teamRows = db.query(
"SELECT id, name, tag, color, leader_id, visibility, " +
"spawn_world, spawn_x, spawn_y, spawn_z, spawn_yaw, spawn_pitch " +
"FROM " + TABLE_TEAMS,
rs -> new TeamRow(
UUID.fromString(rs.getString("id")),
rs.getString("name"),
rs.getString("tag"),
TeamColor.valueOf(rs.getString("color")),
UUID.fromString(rs.getString("leader_id")),
TeamVisibility.valueOf(rs.getString("visibility")),
rs.getString("spawn_world"),
(Double) rs.getObject("spawn_x"),
(Double) rs.getObject("spawn_y"),
(Double) rs.getObject("spawn_z"),
(Float) (rs.getObject("spawn_yaw") == null ? null : (float) rs.getDouble("spawn_yaw")),
(Float) (rs.getObject("spawn_pitch") == null ? null : (float) rs.getDouble("spawn_pitch"))
)
);
for (TeamRow row : teamRows) {
Team team = new Team(row.id, row.name, row.tag, row.color, row.leaderId, row.visibility);
// Membres
var members = db.query(
"SELECT player_id, role, joined_at FROM " + TABLE_MEMBERS + " WHERE team_id = ?",
rs -> new MemberRow(
UUID.fromString(rs.getString("player_id")),
TeamRole.valueOf(rs.getString("role")),
Instant.ofEpochMilli(rs.getLong("joined_at"))
),
row.id
);
// Le leader est ajouté par le constructeur de Team avec role LEADER.
// On ajoute les autres membres manuellement via addMember (qui les marque MEMBER).
for (MemberRow m : members) {
if (!m.playerId.equals(row.leaderId)) {
team.addMember(m.playerId);
}
}
// Scores
db.query(
"SELECT score_name, value FROM " + TABLE_SCORES + " WHERE team_id = ?",
rs -> {
team.setScore(rs.getString("score_name"), rs.getInt("value"));
return null;
},
row.id
);
// Spawn point — différé : nécessite un World qui n'est pas forcément chargé
// au moment du load. Le serveur charge les worlds avant les plugins normalement,
// mais on est défensif.
if (row.spawnWorld != null && row.spawnX != null) {
var world = org.bukkit.Bukkit.getWorld(row.spawnWorld);
if (world != null) {
org.bukkit.Location loc = new org.bukkit.Location(
world, row.spawnX, row.spawnY, row.spawnZ);
if (row.spawnYaw != null) loc.setYaw(row.spawnYaw);
if (row.spawnPitch != null) loc.setPitch(row.spawnPitch);
team.setSpawnPoint(loc);
}
}
// Inject dans le cache mémoire hérité (super.save persiste à nouveau — on évite ça).
super.save(team);
}
}
@Override
public Team save(Team team) {
super.save(team); // met à jour le cache mémoire
persist(team);
return team;
}
@Override
public boolean delete(UUID id) {
boolean removed = super.delete(id);
if (removed) {
db.update("DELETE FROM " + TABLE_SCORES + " WHERE team_id = ?", id);
db.update("DELETE FROM " + TABLE_MEMBERS + " WHERE team_id = ?", id);
db.update("DELETE FROM " + TABLE_TEAMS + " WHERE id = ?", id);
}
return removed;
}
private void persist(Team team) {
var spawn = team.getSpawnPoint();
String spawnWorld = spawn.map(l -> l.getWorld().getName()).orElse(null);
Double spawnX = spawn.map(org.bukkit.Location::getX).orElse(null);
Double spawnY = spawn.map(org.bukkit.Location::getY).orElse(null);
Double spawnZ = spawn.map(org.bukkit.Location::getZ).orElse(null);
Float spawnYaw = spawn.map(org.bukkit.Location::getYaw).orElse(null);
Float spawnPitch = spawn.map(org.bukkit.Location::getPitch).orElse(null);
// INSERT OR REPLACE = upsert simple sur SQLite (la PK gère la collision).
db.update(
"INSERT OR REPLACE INTO " + TABLE_TEAMS +
" (id, name, tag, color, leader_id, visibility, " +
" spawn_world, spawn_x, spawn_y, spawn_z, spawn_yaw, spawn_pitch) " +
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
team.getId(), team.getName(), team.getTag(), team.getColor(),
team.getLeaderId(), team.getVisibility(),
spawnWorld, spawnX, spawnY, spawnZ, spawnYaw, spawnPitch
);
// Approche simple : on remplace en bloc les membres et les scores.
db.update("DELETE FROM " + TABLE_MEMBERS + " WHERE team_id = ?", team.getId());
for (TeamMember m : team.getMembers()) {
db.update(
"INSERT INTO " + TABLE_MEMBERS +
" (team_id, player_id, role, joined_at) VALUES (?, ?, ?, ?)",
team.getId(), m.getPlayerId(), m.getRole(), m.getJoinedAt().toEpochMilli()
);
}
db.update("DELETE FROM " + TABLE_SCORES + " WHERE team_id = ?", team.getId());
team.getScores().forEach((name, value) ->
db.update(
"INSERT INTO " + TABLE_SCORES + " (team_id, score_name, value) VALUES (?, ?, ?)",
team.getId(), name, value
)
);
}
// Tuples internes pour le load.
private record TeamRow(
UUID id, String name, String tag, TeamColor color,
UUID leaderId, TeamVisibility visibility,
String spawnWorld, Double spawnX, Double spawnY, Double spawnZ,
Float spawnYaw, Float spawnPitch
) {}
private record MemberRow(UUID playerId, TeamRole role, Instant joinedAt) {}
}
@@ -14,6 +14,21 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
/**
* Représente une équipe de joueurs. Aggregate mutable : ajout / retrait de
* membres, transfert de leadership, mise à jour de visibilité, des scores
* nommés, et du point de spawn passent par les méthodes de cette classe.
*
* <p>L'identité d'une équipe est son UUID ({@link #getId()}). Deux Team avec
* le même UUID sont égales, quel que soit le reste de leur état.
*
* <p>Implémente {@link Named} (a un nom) et {@link ScoreHolder} (porte des
* scores nommés). Hérite de {@link AbstractEntity} pour l'identité.
*
* <p>Toutes les modifications passent normalement par le {@link TeamService},
* qui orchestre persistance + hooks + évènements Bukkit. Modifier une instance
* directement (ex. {@code team.addMember(...)}) court-circuite la persistance.
*/
public class Team extends AbstractEntity implements Named, ScoreHolder { public class Team extends AbstractEntity implements Named, ScoreHolder {
private final String name; private final String name;
@@ -8,6 +8,19 @@ import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* Façade pour toutes les opérations sur les équipes : lifecycle (create /
* dissolve), membres, scores, classements, point de spawn, visibilité.
*
* <p>Toute logique d'écriture passe par le service (jamais directement sur
* {@link Team}) — il garantit l'unicité nom/tag, déclenche les hooks
* d'override et tire les évènements Bukkit (via la sous-classe par défaut
* {@code BukkitEventFiringTeamServiceImpl}).
*
* <p>L'implémentation par défaut est {@link TeamServiceImpl} avec ses ~12
* hooks {@code protected} surchargeables (factories {@code newTeam},
* {@code newRanking}, et hooks {@code on...} autour de chaque opération).
*/
public interface TeamService { public interface TeamService {
// ---- Lifecycle ---- // ---- Lifecycle ----
@@ -0,0 +1,39 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamMember;
import org.bukkit.event.HandlerList;
import java.util.Objects;
/**
* Déclenché spécifiquement quand un joueur rejoint une équipe <b>par sa propre
* action</b> (auto-join sur une équipe PUBLIC via {@code TeamService.joinTeam}).
*
* <p>Dans ce cas, {@link TeamMemberAddEvent} est <i>aussi</i> tiré juste avant.
* Pour réagir uniquement aux auto-joins, écouter ce {@code PlayerJoinTeamEvent}.
*/
public class PlayerJoinTeamEvent extends TeamEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final TeamMember member;
public PlayerJoinTeamEvent(Team team, TeamMember member) {
super(team);
this.member = Objects.requireNonNull(member, "member");
}
public TeamMember getMember() {
return member;
}
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,26 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import org.bukkit.event.HandlerList;
/**
* Déclenché juste après qu'une équipe a été créée et persistée. Non annulable
* (la validation est faite côté service avant création).
*/
public class TeamCreateEvent extends TeamEvent {
private static final HandlerList HANDLERS = new HandlerList();
public TeamCreateEvent(Team team) {
super(team);
}
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,23 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import org.bukkit.event.HandlerList;
/** Déclenché juste après la dissolution d'une équipe. */
public class TeamDissolveEvent extends TeamEvent {
private static final HandlerList HANDLERS = new HandlerList();
public TeamDissolveEvent(Team team) {
super(team);
}
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,27 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import org.bukkit.event.Event;
import java.util.Objects;
/**
* Base abstraite pour tous les évènements Bukkit liés à une équipe. Porte
* toujours la {@link Team} concernée.
*
* <p>Chaque sous-classe concrète doit fournir sa propre {@code HandlerList}
* statique (contrainte Bukkit — pas de moyen de partager via héritage).
*/
public abstract class TeamEvent extends Event {
private final Team team;
protected TeamEvent(Team team) {
this.team = Objects.requireNonNull(team, "team");
}
/** L'équipe concernée par l'évènement. */
public Team getTeam() {
return team;
}
}
@@ -0,0 +1,34 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import org.bukkit.event.HandlerList;
import java.util.Objects;
import java.util.UUID;
/** Déclenché après un transfert de leadership. {@link #getOldLeaderId()} et {@link #getNewLeaderId()} renvoient les UUID des deux joueurs. */
public class TeamLeadershipTransferEvent extends TeamEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final UUID oldLeaderId;
private final UUID newLeaderId;
public TeamLeadershipTransferEvent(Team team, UUID oldLeaderId, UUID newLeaderId) {
super(team);
this.oldLeaderId = Objects.requireNonNull(oldLeaderId, "oldLeaderId");
this.newLeaderId = Objects.requireNonNull(newLeaderId, "newLeaderId");
}
public UUID getOldLeaderId() { return oldLeaderId; }
public UUID getNewLeaderId() { return newLeaderId; }
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,40 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamMember;
import org.bukkit.event.HandlerList;
import java.util.Objects;
/**
* Déclenché quand un membre est ajouté à une équipe — par action du chef
* ({@code TeamService.addMember}) OU par auto-join du joueur
* ({@code TeamService.joinTeam}).
*
* <p>Pour distinguer les deux cas, écouter aussi {@link PlayerJoinTeamEvent}
* qui n'est tiré que dans le second cas.
*/
public class TeamMemberAddEvent extends TeamEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final TeamMember member;
public TeamMemberAddEvent(Team team, TeamMember member) {
super(team);
this.member = Objects.requireNonNull(member, "member");
}
public TeamMember getMember() {
return member;
}
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,33 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import org.bukkit.event.HandlerList;
import java.util.Objects;
import java.util.UUID;
/** Déclenché après le retrait d'un membre d'une équipe (action chef ou départ volontaire). */
public class TeamMemberRemoveEvent extends TeamEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final UUID playerId;
public TeamMemberRemoveEvent(Team team, UUID playerId) {
super(team);
this.playerId = Objects.requireNonNull(playerId, "playerId");
}
public UUID getPlayerId() {
return playerId;
}
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,40 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import org.bukkit.event.HandlerList;
import java.util.Objects;
/**
* Déclenché après changement effectif d'un score d'équipe (uniquement si la
* valeur change). {@link #getScoreName()} donne le nom du score touché.
*/
public class TeamScoreChangeEvent extends TeamEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final String scoreName;
private final int oldValue;
private final int newValue;
public TeamScoreChangeEvent(Team team, String scoreName, int oldValue, int newValue) {
super(team);
this.scoreName = Objects.requireNonNull(scoreName, "scoreName");
this.oldValue = oldValue;
this.newValue = newValue;
}
public String getScoreName() { return scoreName; }
public int getOldValue() { return oldValue; }
public int getNewValue() { return newValue; }
public int getDelta() { return newValue - oldValue; }
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,38 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import org.bukkit.Location;
import org.bukkit.event.HandlerList;
/**
* Déclenché après changement du point de spawn d'une équipe. {@code oldLocation}
* et {@code newLocation} peuvent être {@code null} (clear / setup initial).
*/
public class TeamSpawnPointChangeEvent extends TeamEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final Location oldLocation;
private final Location newLocation;
public TeamSpawnPointChangeEvent(Team team, Location oldLocation, Location newLocation) {
super(team);
this.oldLocation = oldLocation;
this.newLocation = newLocation;
}
/** L'ancien spawn, ou {@code null} s'il n'y en avait pas. */
public Location getOldLocation() { return oldLocation; }
/** Le nouveau spawn, ou {@code null} si on a fait un clear. */
public Location getNewLocation() { return newLocation; }
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -0,0 +1,34 @@
package fr.luc.crcore.team.event;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamVisibility;
import org.bukkit.event.HandlerList;
import java.util.Objects;
/** Déclenché après changement effectif de visibilité PUBLIC ↔ PRIVATE. */
public class TeamVisibilityChangeEvent extends TeamEvent {
private static final HandlerList HANDLERS = new HandlerList();
private final TeamVisibility oldVisibility;
private final TeamVisibility newVisibility;
public TeamVisibilityChangeEvent(Team team, TeamVisibility oldVisibility, TeamVisibility newVisibility) {
super(team);
this.oldVisibility = Objects.requireNonNull(oldVisibility, "oldVisibility");
this.newVisibility = Objects.requireNonNull(newVisibility, "newVisibility");
}
public TeamVisibility getOldVisibility() { return oldVisibility; }
public TeamVisibility getNewVisibility() { return newVisibility; }
@Override
public HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
}