diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md index d1361ce..7a01e53 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -8,41 +8,59 @@ être lue depuis `docs/` et écrite 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é : - - `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/setup.md` — installation, build, intégration côté plugin de jeu - `docs/README.md` — vue d'ensemble et index - `docs/diagrams/*.puml` — diagrammes (classe / séquence / activité) -- En cas de conflit code ↔ `docs/`, `docs/` fait foi : aligner le code, ou - mettre la doc à jour explicitement avec l'utilisateur. +- En cas de conflit code ↔ `docs/`, `docs/` fait foi. ## Nature du projet -**CR-Core** est une **librairie Java/Maven pure** — pas un plugin Bukkit. Elle -fournit les briques réutilisables (domaine team, framework de commandes, -abstractions communes) que chaque plugin de jeu (futur `CitesPlugin`, etc.) -consomme en dépendance Maven. +**CR-Core** est une **librairie Java/Maven** consommée par des plugins Bukkit +("plugins de jeu"). Pas de `plugin.yml` côté core — c'est le plugin de jeu +qui en a un et qui instancie {@code CRCore} dans son {@code onEnable()}. - **Nom** : CR-Core (artifactId Maven : `CR-Core`) -- **Type** : librairie (`jar`) — pas de `plugin.yml`, pas de `JavaPlugin` -- **Cible runtime** : serveur Paper/Spigot 1.16.5 (le plugin de jeu downstream - est responsable du `plugin.yml` et de l'enregistrement des commandes) +- **Type** : librairie (`jar`) +- **Cible runtime** : serveur Paper/Spigot 1.16.5 - **Build** : Maven, Java 16 - **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`) +- `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 -Toutes les classes du noyau sont conçues pour être étendues. Les services -fournissent une implémentation "tout faite" (ex. `TeamServiceImpl`), mais chaque -étape importante est exposée via une méthode `protected` (`newTeam`, -`validateName`, `onAfterCreate`, …) qu'une sous-classe peut overrider. +Chaque service expose des **hooks `protected`** que les plugins de jeu peuvent +overrider : +- factories (`newTeam`, `newRanking`, `newProfile`) +- validations (`validateName`, `validateJoinable`, …) +- post-hooks (`onAfterCreate`, `onMemberAdded`, `onScoreChanged`, …) + +Chaque commande built-in (`TeamCreateSubCommand`, etc.) est elle aussi +substituable par nom via `replaceSubCommand(...)`. **Règles** : -- Pas de `final` sur les classes du noyau, sauf raison forte. -- Méthodes-clés en `protected` (pas `private`) pour permettre l'override. +- Pas de `final` sur les classes du noyau. +- Méthodes-clés en `protected` (pas `private`). - Hooks `onBefore...` / `onAfter...` partout où ça a du sens. -- Factories (`newTeam`, `newMember`, …) pour permettre de substituer des - sous-classes sans réécrire le service. +- Factories pour permettre les sous-classes. ## Workflow attendu @@ -54,6 +72,7 @@ fournissent une implémentation "tout faite" (ex. `TeamServiceImpl`), mais chaqu ## Conventions de code - 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, `exception` — chacun dans son fichier. diff --git a/docs/README.md b/docs/README.md index 012d56f..e70dac3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,29 +5,35 @@ mécaniques, règles et spécifications du noyau sont consignées ici. ## Objectif du projet -**CR-Core** est une **librairie Maven** réutilisable pour construire des plugins -Minecraft. Elle fournit : +**CR-Core** est une **librairie Maven** réutilisable pour construire des +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`, - `AbstractEntity`, `Repository`) ; -- un **domaine équipes** clé en main (`Team`, `TeamMember`, `TeamService`, - `TeamRepository`) — visibilité, scores, classements, spawn, overridable ; -- un **domaine profils joueurs** (`PlayerProfile`, `PlayerProfileService`) — - scores nommés par joueur, classements individuels ; -- un **framework de commandes** (`BaseCommand`, `SubCommand`, `ArgumentType`, - tab-completion intégrée). - -Les plugins de jeu (futur `CitesPlugin`, BedWars, etc.) déclarent CR-Core en -dépendance Maven `provided` (ou shadent la lib) et l'utilisent directement. - -- Cible runtime : **Minecraft 1.16.5** (API Paper). -- Build : Maven, Java 16. +- **Abstractions communes** — `Identifiable`, `Named`, `ScoreHolder`, + `AbstractEntity`, `Repository`. +- **Domaine Team** — équipes (nom, tag, couleur, chef, membres, + visibilité PUBLIC/PRIVATE, scores nommés, classements, point de spawn), + service overridable, exceptions dédiées. +- **Domaine Player** — profils joueurs (scores nommés, classements + individuels), service auto-créant les profils à la demande. +- **Framework de commandes** — `BaseCommand` / `SubCommand` imbriqués, + arguments typés, tab-complétion, permissions, player-only. +- **Commandes par défaut** — `/core team [create|delete|add|remove|join|` + `leave|info|list|transfer|visibility|score|top|setspawn]` fonctionnelles + out-of-the-box, chacune substituable par sous-classe. +- **Évènements Bukkit** — 9 events team + 3 events player, à écouter avec + `@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 - `README.md` — Ce fichier. Vue d'ensemble et index. - `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). - `diagrams/` — Diagrammes PlantUML (`.puml`). @@ -36,17 +42,22 @@ dépendance Maven `provided` (ou shadent la lib) et l'utilisent directement. | Fichier | Type | Sujet | |---|---|---| | [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-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) | -| [command-class-diagram.puml](diagrams/command-class-diagram.puml) | Classe | Framework de commandes | +| [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 (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 - Code : **anglais standard**, séparation stricte interfaces / enums / classes abstraites / classes concrètes / exceptions. - Doc & messages joueur : **français**. +- JavaDoc en français sur les classes publiques et méthodes non triviales. - Package racine : `fr.luc.crcore`. - Classes du noyau **non-`final`**, méthodes-clés `protected`, hooks `onBefore…`/`onAfter…` et factories `new…` pour l'override. diff --git a/docs/decisions.md b/docs/decisions.md index c160890..763dba4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 implémentations (Map + getters/setters), interface commune pour le 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.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 `/crcore.db`) ; le préfixe + isole proprement. diff --git a/docs/diagrams/bootstrap-sequence.puml b/docs/diagrams/bootstrap-sequence.puml new file mode 100644 index 0000000..62970f5 --- /dev/null +++ b/docs/diagrams/bootstrap-sequence.puml @@ -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(/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 diff --git a/docs/diagrams/builtin-commands-diagram.puml b/docs/diagrams/builtin-commands-diagram.puml new file mode 100644 index 0000000..cf5db85 --- /dev/null +++ b/docs/diagrams/builtin-commands-diagram.puml @@ -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 <> { + + {static} teamByName(service): ArgumentType + } + + 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 diff --git a/docs/diagrams/command-class-diagram.puml b/docs/diagrams/command-class-diagram.puml index 69ec552..02602d9 100644 --- a/docs/diagrams/command-class-diagram.puml +++ b/docs/diagrams/command-class-diagram.puml @@ -1,5 +1,5 @@ @startuml command-class-diagram -title CR-Core — Command framework (class diagram) +title CR-Core — Command framework (class diagram, nested sub-commands) skinparam classAttributeIconSize 0 hide empty members @@ -13,7 +13,7 @@ package "fr.luc.crcore.command" { + isPlayerOnly(): boolean + getDescription(): String + execute(ctx: CommandContext): CommandResult - + tabComplete(sender, argIndex, partial): List + + tabComplete(sender, args: String[]): List + matches(label: String): boolean } @@ -25,46 +25,41 @@ package "fr.luc.crcore.command" { - description: String - usage: String - arguments: List - # addAlias(...): void - # permission(p): void - # playerOnly(): void - # description(d): void - # usage(u): void - # argument(name, type): void - # optionalArgument(name, type): void - # buildContext(sender, label, subArgs): CommandContext - + getRequiredArgumentCount(): int - + getTotalArgumentCount(): int - + getUsage(): String + - subCommandsByName: Map + - subCommandsByAlias: Map + -- + # addAlias(...) / permission / playerOnly / description / usage + # argument(name, type) / optionalArgument(name, type) + # addSubCommand(sub: SubCommand): void + -- + + findSubCommand(label): Optional + + getSubCommands(): Collection + + replaceSubCommand(name, newSub): Optional + + hasSubCommands(): boolean + -- + + dispatch(sender, label, args): CommandResult + + tabComplete(sender, args): List + + execute(ctx): CommandResult + # listSubCommands(ctx): CommandResult + # checkAccess(sender): boolean + # buildContext(sender, label, rawArgs): CommandContext } abstract class BaseCommand { - - subCommandsByName: Map - - subCommandsByAlias: Map - # addSubCommand(sub: SubCommand): void - + findSubCommand(label: String): Optional - + getSubCommands(): Collection - # execute(ctx): CommandResult + onCommand(sender, cmd, label, args): boolean + onTabComplete(sender, cmd, alias, args): List - # checkAccess(sender, target): boolean # handleResult(sender, result): void } - abstract class SubCommand { - + {abstract} execute(ctx: CommandContext): CommandResult - } + abstract class SubCommand class CommandContext { - sender: CommandSender - label: String - rawArgs: String[] - parsedArgs: Map - + getSender(): CommandSender - + isPlayer(): boolean - + getPlayer(): Optional - + requirePlayer(): Player - + get(name: String): T + + getSender / isPlayer / getPlayer / requirePlayer + + get(name): T + getOptional(name): Optional + has(name): boolean + reply(msg): void @@ -73,15 +68,7 @@ package "fr.luc.crcore.command" { class CommandResult { - type: Type - message: String - + getType(): Type - + getMessage(): String - + isSuccess(): boolean - + {static} success(): CommandResult - + {static} success(msg): CommandResult - + {static} failure(msg): CommandResult - + {static} invalidUsage(): CommandResult - + {static} noPermission(): CommandResult - + {static} playerOnly(): CommandResult + + {static} success / failure / invalidUsage / noPermission / playerOnly } enum "CommandResult.Type" as ResultType { @@ -99,17 +86,13 @@ package "fr.luc.crcore.command" { + suggestions(sender, partial): List } - class ArgumentTypes << (S, #FFC107) static >> { - + {static} STRING: ArgumentType - + {static} INTEGER: ArgumentType - + {static} DOUBLE: ArgumentType - + {static} BOOLEAN: ArgumentType - + {static} ONLINE_PLAYER: ArgumentType - + {static} enumOf(type): ArgumentType - + {static} choice(choices): ArgumentType + class ArgumentTypes <> { + + STRING / INTEGER / DOUBLE / BOOLEAN / ONLINE_PLAYER + + enumOf(Class): ArgumentType + + choice(String...): ArgumentType } - class ArgumentDef << (P, #BBBBBB) package-private >> { + class ArgumentDef <> { - name: String - type: ArgumentType - required: boolean @@ -118,15 +101,24 @@ package "fr.luc.crcore.command" { AbstractCommand ..|> Command BaseCommand --|> 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 ArgumentDef --> ArgumentType CommandResult +-- ResultType CommandException --|> RuntimeException - BaseCommand ..> 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 diff --git a/docs/diagrams/database-diagram.puml b/docs/diagrams/database-diagram.puml new file mode 100644 index 0000000..a10bbff --- /dev/null +++ b/docs/diagrams/database-diagram.puml @@ -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 + + query(sql, mapper, params...): List + + 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 + - 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" 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 diff --git a/docs/diagrams/events-diagram.puml b/docs/diagrams/events-diagram.puml new file mode 100644 index 0000000..45ed307 --- /dev/null +++ b/docs/diagrams/events-diagram.puml @@ -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 diff --git a/docs/features.md b/docs/features.md index b629d7c..f485566 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,7 +1,19 @@ # Domaines fonctionnels -CR-Core est une librairie. Chaque domaine est autonome ; le plugin de jeu -downstream pioche ce qu'il utilise. +CR-Core est une librairie. Le plugin de jeu downstream l'instancie en une +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 -**Statut** : framework implémenté. Pas de commande Team intégrée — c'est au -plugin de jeu de définir ses commandes en utilisant les briques fournies. +**Statut** : framework implémenté avec **sous-commandes imbriquées récursives** +(supporte `/core team create`, `/core team join`, etc.). Les commandes par +défaut sont fournies en section 4. ### Architecture - **`Command`** (interface) — contrat partagé : `getName()`, `getAliases()`, `getPermission()`, `isPlayerOnly()`, `getDescription()`, `execute(ctx)`, - `tabComplete(sender, argIndex, partial)`, `matches(label)`. + `tabComplete(sender, args)`, `matches(label)`. - **`AbstractCommand`** (abstract, implémente `Command`) — porte tous les - champs : nom, aliases, permission, player-only, description, usage, - arguments. Méthodes builder en `protected` : `addAlias`, `permission`, - `playerOnly`, `description`, `usage`, `argument`, `optionalArgument`. -- **`BaseCommand extends AbstractCommand`** — implémente aussi - `CommandExecutor` et `TabCompleter` de Bukkit. Conteneur de `SubCommand`, - fait le routage `args[0]` → sous-commande, gère permissions, player-only, - invalid usage, affichage de l'aide par défaut. -- **`SubCommand extends AbstractCommand`** — sous-commande sans logique - Bukkit. La méthode abstraite `execute(CommandContext)` est à implémenter. + champs ET la table des sous-commandes (imbrication). Méthodes builder en + `protected` : `addAlias`, `permission`, `playerOnly`, `description`, `usage`, + `argument`, `optionalArgument`, `addSubCommand`. Routage récursif via + `dispatch(...)` et `tabComplete(...)`. +- **`BaseCommand extends AbstractCommand`** — implémente `CommandExecutor` et + `TabCompleter` de Bukkit, branche `onCommand` → `dispatch`. À utiliser pour + la racine d'un arbre (`/core`). +- **`SubCommand extends AbstractCommand`** — sous-commande ; peut être + 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`) @@ -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 [visibility] — créer une équipe + ├── delete — dissoudre son équipe (chef) + ├── add — chef ajoute un membre + ├── remove — chef retire un membre + ├── join — auto-join sur team PUBLIC + ├── leave — quitter son équipe + ├── info [name] — infos d'une équipe + ├── list — liste toutes les équipes + ├── transfer — transfert de leadership + ├── visibility — changer la visibilité + ├── score — [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 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 -_(à remplir — ex. score, profil joueur, gestion d'event/round, ...)_ +_(à remplir — ex. inventaires partagés d'équipe, kits, gestion de rounds, …)_ diff --git a/docs/setup.md b/docs/setup.md index e92056e..5eba80e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -4,10 +4,9 @@ - **Type** : librairie Java (`jar`) — pas un plugin Bukkit - **artifactId Maven** : `CR-Core` -- **Build** : Maven -- **Java** : 16 +- **Build** : Maven, Java 16 - **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` ## Dépôts Maven @@ -22,12 +21,12 @@ 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. -## Utilisation depuis un plugin de jeu +## Intégration dans un plugin de jeu -Dans le `pom.xml` du plugin de jeu : +### `pom.xml` ```xml @@ -38,106 +37,111 @@ Dans le `pom.xml` du plugin de jeu : ``` -> Scope `compile` (et non `provided`) **si** le plugin de jeu shade CR-Core dans -> son propre jar (recommandé pour de la pure librairie). Pensez à utiliser un -> `` dans le `maven-shade-plugin` du plugin de jeu pour éviter les -> conflits si plusieurs plugins shadent CR-Core sur le même serveur. +Le plugin de jeu doit **shader** CR-Core dans son jar final (avec +`maven-shade-plugin`) pour que sqlite-jdbc + le code du noyau soient bien +embarqués sur le 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 -public final class MyGamePlugin extends JavaPlugin { +public class MyGamePlugin extends JavaPlugin { - private TeamService teamService; + private CRCore core; @Override public void onEnable() { - // Instancie le service team (chaque plugin a son propre registre) - this.teamService = new TeamServiceImpl(new InMemoryTeamRepository()); + // 1 ligne = SQLite + services + /core team ... opérationnels + this.core = new CRCore(this).enable(); - // Enregistre une commande basée sur le framework de CR-Core - getCommand("team").setExecutor(new TeamCommand(teamService)); - getCommand("team").setTabCompleter(new TeamCommand(teamService)); + // Listener custom sur les events team + getServer().getPluginManager().registerEvents(new MyTeamListener(), this); + + // 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 -public class TeamCommand extends BaseCommand { +public class MyTeamListener implements Listener { - public TeamCommand(TeamService service) { - super("team", "teams", "t"); - description("Manage teams"); - addSubCommand(new TeamCreateSub(service)); - addSubCommand(new TeamInfoSub(service)); + @EventHandler + public void onTeamCreate(TeamCreateEvent event) { + Team team = event.getTeam(); + Bukkit.broadcastMessage("Nouvelle équipe : " + team.getName()); + } + + @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; - - public TeamCreateSub(TeamService service) { - super("create", "c", "new"); - this.service = service; - description("Create a new team"); - permission("mygame.team.create"); - playerOnly(); - argument("name", ArgumentTypes.STRING); - argument("tag", ArgumentTypes.STRING); - argument("color", ArgumentTypes.enumOf(TeamColor.class)); +```java +public class MyCreateCommand extends TeamCreateSubCommand { + public MyCreateCommand(TeamService service) { + super(service); + permission("mygame.team.create"); // permission custom } @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"); - 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()); - } + // logique custom + return super.execute(ctx); } } + +// Dans onEnable() : +core.getCoreCommand().findSubCommand("team") + .ifPresent(team -> team.replaceSubCommand("create", new MyCreateCommand(core.getTeamService()))); ``` -Et dans le `plugin.yml` du plugin de jeu : - -```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 : +### Stocker / récupérer des données custom via SQLite ```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 - protected void onAfterCreate(Team team) { - getPlugin().getLogger().info("Team created: " + team.getName()); - } - - @Override - protected Team newTeam(UUID id, String name, String tag, TeamColor color, UUID leaderId) { - return new MyCustomTeam(id, name, tag, color, leaderId); - } -} +int kills = db.queryOne( + "SELECT kills FROM my_kills WHERE player_id = ?", + rs -> rs.getInt("kills"), + playerUuid +).orElse(0); ``` ## Arborescence du projet @@ -146,61 +150,112 @@ public class LoggingTeamService extends TeamServiceImpl { CitesPlugin/ # dossier IntelliJ (renommer plus tard si voulu) ├── pom.xml ├── GEMINI.md -├── docs/ # source de vérité +├── docs/ │ ├── README.md │ ├── setup.md │ ├── features.md │ ├── decisions.md -│ └── diagrams/ -│ ├── team-class-diagram.puml -│ ├── team-create-sequence.puml -│ ├── team-create-activity.puml -│ └── command-class-diagram.puml +│ └── diagrams/*.puml └── src/main/java/fr/luc/crcore/ - ├── common/ # abstractions partagées - │ ├── Identifiable.java # interface - │ ├── Named.java # interface - │ ├── ScoreHolder.java # interface (impl. par Team et PlayerProfile) - │ ├── AbstractEntity.java # abstract class - │ └── Repository.java # interface - ├── command/ # framework de commandes - │ ├── Command.java # interface - │ ├── AbstractCommand.java # base partagée - │ ├── BaseCommand.java # commande top-level (Bukkit-aware) - │ ├── SubCommand.java # sous-commande + ├── CRCore.java # bootstrap orchestrator + ├── CRCoreConfig.java # config (sqlite, command name, …) + ├── common/ + │ ├── Identifiable.java + │ ├── Named.java + │ ├── ScoreHolder.java # contrat partagé Team + PlayerProfile + │ ├── AbstractEntity.java + │ └── Repository.java + ├── database/ # wrapper SQLite + │ ├── Database.java + │ ├── 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 │ ├── CommandResult.java │ ├── CommandException.java │ ├── ArgumentType.java - │ ├── ArgumentTypes.java # STRING, INTEGER, BOOLEAN, ONLINE_PLAYER, enumOf, choice - │ └── ArgumentDef.java # package-private - ├── team/ # domaine team + │ ├── ArgumentTypes.java + │ ├── ArgumentDef.java # package-private + │ └── builtin/ # commandes prêtes à l'emploi + │ ├── CoreCommand.java # /core + │ └── team/ + │ ├── TeamGroupSubCommand.java # /core team (container) + │ ├── TeamArgumentTypes.java # ArgumentType 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 │ ├── TeamMember.java - │ ├── TeamRole.java # enum - │ ├── TeamColor.java # enum - │ ├── TeamVisibility.java # enum (PUBLIC / PRIVATE) - │ ├── TeamRanking.java # record (rank, team, score) - │ ├── TeamRepository.java # interface - │ ├── InMemoryTeamRepository.java # impl - │ ├── TeamService.java # interface - │ ├── TeamServiceImpl.java # impl avec hooks overridables + │ ├── TeamRole.java + │ ├── TeamColor.java + │ ├── TeamVisibility.java + │ ├── TeamRanking.java # record + │ ├── TeamRepository.java + │ ├── InMemoryTeamRepository.java + │ ├── SqliteTeamRepository.java + │ ├── TeamService.java + │ ├── TeamServiceImpl.java + │ ├── BukkitEventFiringTeamServiceImpl.java # impl par défaut │ ├── TeamException.java │ ├── TeamAlreadyExistsException.java │ ├── TeamNotFoundException.java - │ └── TeamAccessException.java # auto-join refusé - └── player/ # domaine player - ├── PlayerProfile.java # entité, scores par joueur - ├── PlayerRanking.java # record (rank, profile, score) - ├── PlayerProfileRepository.java # interface + │ ├── TeamAccessException.java + │ └── event/ # Bukkit events team + │ ├── TeamEvent.java # base + │ ├── TeamCreateEvent.java + │ ├── 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 - ├── PlayerProfileService.java # interface - ├── PlayerProfileServiceImpl.java # impl avec hooks overridables + ├── SqlitePlayerProfileRepository.java + ├── PlayerProfileService.java + ├── PlayerProfileServiceImpl.java + ├── BukkitEventFiringPlayerProfileServiceImpl.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 -> 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 -> build. +## Tables SQLite créées par CR-Core + +Au premier `enable()`, les tables suivantes sont créées (en `IF NOT EXISTS`) : + +- `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. diff --git a/pom.xml b/pom.xml index 956acb2..ff60716 100644 --- a/pom.xml +++ b/pom.xml @@ -10,12 +10,13 @@ jar CR-Core - Reusable core library for CR Minecraft game plugins (teams, commands, common abstractions). + Reusable core library for CR Minecraft game plugins (teams, players, scores, commands, events, SQLite persistence). 16 16 UTF-8 + 3.45.3.0 @@ -40,6 +41,18 @@ 1.16.5-R0.1-SNAPSHOT provided + + + org.xerial + sqlite-jdbc + ${sqlite.version} + compile + diff --git a/src/main/java/fr/luc/crcore/CRCore.java b/src/main/java/fr/luc/crcore/CRCore.java new file mode 100644 index 0000000..2231502 --- /dev/null +++ b/src/main/java/fr/luc/crcore/CRCore.java @@ -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. + * + *

Instanciée une fois dans {@code onEnable()}, branche en cascade : + *

    + *
  1. la base SQLite (dans le dataFolder du plugin),
  2. + *
  3. les repositories (SQLite ou in-memory selon {@link CRCoreConfig}),
  4. + *
  5. les services team + player avec fire d'évènements Bukkit,
  6. + *
  7. la commande {@code /core} avec tous ses sous-commandes par défaut.
  8. + *
+ * + *

Utilisation minimale côté plugin de jeu

+ *
{@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();
+ *     }
+ * }
+ * }
+ * + *

Le plugin de jeu doit avoir déclaré la commande dans son {@code plugin.yml} : + *

{@code
+ * commands:
+ *   core:
+ *     description: Commandes CR-Core
+ * }
+ * + *

Override

+ * 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; } +} diff --git a/src/main/java/fr/luc/crcore/CRCoreConfig.java b/src/main/java/fr/luc/crcore/CRCoreConfig.java new file mode 100644 index 0000000..ca5c4f8 --- /dev/null +++ b/src/main/java/fr/luc/crcore/CRCoreConfig.java @@ -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. + * + *
{@code
+ * new CRCore(this, new CRCoreConfig()
+ *     .withSqliteFile("mydata.db")
+ *     .withCommandName("game"))
+ *     .enable();
+ * }
+ * + *

Valeurs par défaut : + *

    + *
  • SQLite activé, fichier {@code crcore.db} dans le dataFolder du plugin
  • + *
  • Commande Bukkit racine : {@code core}
  • + *
+ */ +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; } +} diff --git a/src/main/java/fr/luc/crcore/command/AbstractCommand.java b/src/main/java/fr/luc/crcore/command/AbstractCommand.java index b49e6e3..1391c68 100644 --- a/src/main/java/fr/luc/crcore/command/AbstractCommand.java +++ b/src/main/java/fr/luc/crcore/command/AbstractCommand.java @@ -1,19 +1,49 @@ package fr.luc.crcore.command; +import org.bukkit.ChatColor; import org.bukkit.command.CommandSender; +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.Map; 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. + * + *

Une commande peut être : + *

    + *
  • feuille : pas de sous-commandes, implémente {@link #execute}
  • + *
  • groupe : 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).
  • + *
  • hybride : groupe ET arguments propres — déconseillé, le + * routage donne priorité aux sous-commandes.
  • + *
+ * + *

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 { private final String name; private final List aliases = new ArrayList<>(); private final List arguments = new ArrayList<>(); + private final Map subCommandsByName = new LinkedHashMap<>(); + private final Map subCommandsByAlias = new HashMap<>(); + private String permission; private boolean playerOnly; private String description = ""; @@ -26,65 +56,126 @@ public abstract class AbstractCommand implements Command { } } - @Override - public final String getName() { - return name; - } + // ---- Command interface ---- - @Override - public final List getAliases() { - 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 getName() { return name; } + @Override public final List getAliases() { 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; } + /** Usage explicite si défini via {@link #usage(String)}, sinon usage auto-construit. */ public final String getUsage() { 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) { for (String alias : aliases) { this.aliases.add(alias.toLowerCase()); } } + /** Définit la permission Bukkit requise (ex. {@code "crcore.team.create"}). */ protected final void permission(String permission) { this.permission = permission; } + /** Restreint l'exécution aux joueurs (refus pour console). */ protected final void playerOnly() { this.playerOnly = true; } + /** Description courte affichée dans l'aide. */ protected final void description(String description) { this.description = Objects.requireNonNullElse(description, ""); } + /** Usage explicite (sinon construit automatiquement à partir des arguments). */ protected final void usage(String usage) { this.usage = usage; } + /** Déclare un argument positionnel obligatoire. */ protected final void argument(String name, ArgumentType type) { 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) { 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. + * + *

{@code
+     * coreCommand.findSubCommand("team")
+     *     .ifPresent(team -> team.replaceSubCommand("create", new MyCustomCreate(svc)));
+     * }
+ * + * @return l'ancienne sous-commande remplacée, ou {@link Optional#empty()} + * si aucune n'existait sous ce nom. + */ + public final Optional 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 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 getSubCommands() { + return Collections.unmodifiableCollection(subCommandsByName.values()); + } + + public final boolean hasSubCommands() { + return !subCommandsByName.isEmpty(); + } + + // ---- Argument introspection ---- + public final int getRequiredArgumentCount() { return (int) arguments.stream().filter(ArgumentDef::isRequired).count(); } @@ -97,6 +188,129 @@ public abstract class AbstractCommand implements Command { 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 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 tabComplete(CommandSender sender, String[] args) { + if (args.length == 0) return Collections.emptyList(); + + if (args.length == 1) { + String partial = args[0].toLowerCase(); + List 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 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. + * + *

Comportement par défaut : + *

    + *
  • Si cette commande a des sous-commandes → affiche la liste (aide).
  • + *
  • Sinon → renvoie {@link CommandResult#invalidUsage()}.
  • + *
+ */ + 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() { StringBuilder sb = new StringBuilder("/").append(name); for (ArgumentDef def : arguments) { @@ -108,26 +322,26 @@ public abstract class AbstractCommand implements Command { return sb.toString(); } - @Override - public List tabComplete(CommandSender sender, int argIndex, String partial) { - if (argIndex >= 0 && argIndex < arguments.size()) { - return arguments.get(argIndex).getType().suggestions(sender, partial); - } - return Collections.emptyList(); + /** Check standard de permission + player-only. */ + protected boolean checkAccess(CommandSender sender) { + if (permission != null && !sender.hasPermission(permission)) return false; + if (playerOnly && !(sender instanceof Player)) return false; + return true; } - 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 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++) { ArgumentDef def = arguments.get(i); try { - Object value = def.getType().parse(subArgs[i]); + Object value = def.getType().parse(rawArgs[i]); parsed.put(def.getName(), value); } catch (CommandException ex) { throw new CommandException("Argument '" + def.getName() + "': " + ex.getMessage()); } } - return new CommandContext(sender, label, subArgs, parsed); + return new CommandContext(sender, label, rawArgs, parsed); } } diff --git a/src/main/java/fr/luc/crcore/command/BaseCommand.java b/src/main/java/fr/luc/crcore/command/BaseCommand.java index 4eb1e00..ff94e72 100644 --- a/src/main/java/fr/luc/crcore/command/BaseCommand.java +++ b/src/main/java/fr/luc/crcore/command/BaseCommand.java @@ -4,172 +4,48 @@ import org.bukkit.ChatColor; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; 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.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 : + *
{@code
+ * PluginCommand cmd = plugin.getCommand("core");
+ * cmd.setExecutor(new CoreCommand(...));
+ * cmd.setTabCompleter((CoreCommand) cmd.getExecutor());
+ * }
+ * + *

{@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 implements CommandExecutor, TabCompleter { - private final Map subCommandsByName = new LinkedHashMap<>(); - private final Map subCommandsByAlias = new HashMap<>(); - protected BaseCommand(String name, String... 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 getSubCommands() { - return Collections.unmodifiableCollection(subCommandsByName.values()); - } - - public final Optional 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 public final boolean onCommand(CommandSender sender, org.bukkit.command.Command command, String label, String[] args) { - if (!checkAccess(sender, this)) { - return true; - } - - 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()); - } + CommandResult result = dispatch(sender, label, args); + handleResult(sender, result); return true; } @Override public final List onTabComplete(CommandSender sender, org.bukkit.command.Command command, String alias, String[] args) { - if (args.length == 0) return Collections.emptyList(); - - if (args.length == 1) { - String partial = args[0].toLowerCase(); - List 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 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(); + return tabComplete(sender, args); } + /** + * Affiche le {@link CommandResult} à l'utilisateur. Override pour + * personnaliser le formatage (couleurs, locales, etc.). + */ protected void handleResult(CommandSender sender, CommandResult result) { switch (result.getType()) { case SUCCESS -> { @@ -181,8 +57,8 @@ public abstract class BaseCommand extends AbstractCommand (result.getMessage() != null ? result.getMessage() : "Command failed.")); case INVALID_USAGE -> sender.sendMessage(ChatColor.RED + (result.getMessage() != null ? result.getMessage() : "Invalid usage.")); - case NO_PERMISSION -> sender.sendMessage(ChatColor.RED + "You don't have permission."); - case PLAYER_ONLY -> sender.sendMessage(ChatColor.RED + "Only players can use this command."); + case NO_PERMISSION -> sender.sendMessage(ChatColor.RED + "Vous n'avez pas la permission."); + case PLAYER_ONLY -> sender.sendMessage(ChatColor.RED + "Seul un joueur peut utiliser cette commande."); } } } diff --git a/src/main/java/fr/luc/crcore/command/Command.java b/src/main/java/fr/luc/crcore/command/Command.java index b934803..d68bf71 100644 --- a/src/main/java/fr/luc/crcore/command/Command.java +++ b/src/main/java/fr/luc/crcore/command/Command.java @@ -4,6 +4,11 @@ import org.bukkit.command.CommandSender; 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 { String getName(); @@ -16,10 +21,20 @@ public interface Command { String getDescription(); + /** + * Logique d'exécution de la commande (cas feuille, ou fallback si aucune + * sous-commande ne matche). + */ CommandResult execute(CommandContext context); - List 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 tabComplete(CommandSender sender, String[] args); + /** {@code true} si {@code label} match le nom ou un alias (case-insensitive). */ default boolean matches(String label) { if (label == null) return false; String lc = label.toLowerCase(); diff --git a/src/main/java/fr/luc/crcore/command/SubCommand.java b/src/main/java/fr/luc/crcore/command/SubCommand.java index 4e9abaf..723234d 100644 --- a/src/main/java/fr/luc/crcore/command/SubCommand.java +++ b/src/main/java/fr/luc/crcore/command/SubCommand.java @@ -1,5 +1,16 @@ package fr.luc.crcore.command; +/** + * Sous-commande imbriquée. Peut être : + *

    + *
  • feuille — override {@link #execute(CommandContext)} avec la logique métier
  • + *
  • groupe — appelle {@code addSubCommand(...)} dans son constructeur pour + * déléguer à des sous-sous-commandes (ex. {@code /core team create})
  • + *
+ * + *

Toute la machinerie de routage / parsing / tab-complete est héritée de + * {@link AbstractCommand}. + */ public abstract class SubCommand extends AbstractCommand { protected SubCommand(String name, String... aliases) { diff --git a/src/main/java/fr/luc/crcore/command/builtin/CoreCommand.java b/src/main/java/fr/luc/crcore/command/builtin/CoreCommand.java new file mode 100644 index 0000000..a4d5466 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/CoreCommand.java @@ -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. + * + *

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}). + * + *

Sans arguments, affiche l'aide des groupes disponibles. Avec {@code team + * }, route vers {@link TeamGroupSubCommand}. + * + *

Override

+ * Pour remplacer un groupe entier : + *
{@code
+ * core.getCoreCommand().replaceSubCommand("team", new MyTeamGroup(svc));
+ * }
+ * Pour remplacer une feuille : + *
{@code
+ * core.getCoreCommand().findSubCommand("team")
+ *     .ifPresent(t -> t.replaceSubCommand("create", new MyCreate(svc)));
+ * }
+ */ +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)); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamAddSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamAddSubCommand.java new file mode 100644 index 0000000..85945bd --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamAddSubCommand.java @@ -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 } + * + *

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() + "."); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamArgumentTypes.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamArgumentTypes.java new file mode 100644 index 0000000..3d53753 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamArgumentTypes.java @@ -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 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 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()); + } + }; + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamCreateSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamCreateSubCommand.java new file mode 100644 index 0000000..78a8a9a --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamCreateSubCommand.java @@ -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 [visibility]} + * + *

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.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()); + } + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamDeleteSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamDeleteSubCommand.java new file mode 100644 index 0000000..f98eac9 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamDeleteSubCommand.java @@ -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} + * + *

Dissout l'équipe de l'exécutant. Réservé au chef. Pas d'argument : + * l'équipe ciblée est déduite du joueur. + * + *

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."); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamGroupSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamGroupSubCommand.java new file mode 100644 index 0000000..dfb3d22 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamGroupSubCommand.java @@ -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. + * + *

Pour overrider une sous-commande, un plugin de jeu fait : + *

{@code
+ * core.getCoreCommand().findSubCommand("team")
+ *     .ifPresent(team -> team.replaceSubCommand("create", new MyCustomCreate(svc)));
+ * }
+ * + *

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)); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamInfoSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamInfoSubCommand.java new file mode 100644 index 0000000..019186f --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamInfoSubCommand.java @@ -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]} + * + *

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.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(); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamJoinSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamJoinSubCommand.java new file mode 100644 index 0000000..a4d572b --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamJoinSubCommand.java @@ -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 } + * + *

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()); + } + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamLeaveSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamLeaveSubCommand.java new file mode 100644 index 0000000..f6cb6d9 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamLeaveSubCommand.java @@ -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} + * + *

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 , ou dissolvez avec /core team delete."); + } + service.removeMember(team.getId(), player.getUniqueId()); + return CommandResult.success("Vous avez quitté l'équipe " + team.getName() + "."); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamListSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamListSubCommand.java new file mode 100644 index 0000000..f318dd9 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamListSubCommand.java @@ -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} + * + *

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 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(); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamRemoveSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamRemoveSubCommand.java new file mode 100644 index 0000000..858b2cf --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamRemoveSubCommand.java @@ -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 } + * + *

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."); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamScoreSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamScoreSubCommand.java new file mode 100644 index 0000000..7b5d956 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamScoreSubCommand.java @@ -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 } + * + *

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

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); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamSetSpawnSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamSetSpawnSubCommand.java new file mode 100644 index 0000000..0f122a3 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamSetSpawnSubCommand.java @@ -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} + * + *

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."); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamTopSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamTopSubCommand.java new file mode 100644 index 0000000..5853030 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamTopSubCommand.java @@ -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]} + * + *

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.getOptional("score").orElse(null); + List 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(); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamTransferSubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamTransferSubCommand.java new file mode 100644 index 0000000..7b30357 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamTransferSubCommand.java @@ -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 } + * + *

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 + "."); + } +} diff --git a/src/main/java/fr/luc/crcore/command/builtin/team/TeamVisibilitySubCommand.java b/src/main/java/fr/luc/crcore/command/builtin/team/TeamVisibilitySubCommand.java new file mode 100644 index 0000000..697e10e --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/builtin/team/TeamVisibilitySubCommand.java @@ -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 } + * + *

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 + "."); + } +} diff --git a/src/main/java/fr/luc/crcore/common/Repository.java b/src/main/java/fr/luc/crcore/common/Repository.java index e860f1d..7f745b0 100644 --- a/src/main/java/fr/luc/crcore/common/Repository.java +++ b/src/main/java/fr/luc/crcore/common/Repository.java @@ -4,6 +4,17 @@ import java.util.Collection; import java.util.Optional; 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. + * + *

Implémentations CR-Core par défaut : + *

    + *
  • {@code InMemoryTeamRepository} / {@code SqliteTeamRepository}
  • + *
  • {@code InMemoryPlayerProfileRepository} / {@code SqlitePlayerProfileRepository}
  • + *
+ */ public interface Repository { T save(T entity); diff --git a/src/main/java/fr/luc/crcore/common/ScoreHolder.java b/src/main/java/fr/luc/crcore/common/ScoreHolder.java index 6559d03..85ecc1a 100644 --- a/src/main/java/fr/luc/crcore/common/ScoreHolder.java +++ b/src/main/java/fr/luc/crcore/common/ScoreHolder.java @@ -2,6 +2,18 @@ package fr.luc.crcore.common; 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}. + * + *

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"}. + * + *

{@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 { int getScore(String scoreName); diff --git a/src/main/java/fr/luc/crcore/database/ColumnType.java b/src/main/java/fr/luc/crcore/database/ColumnType.java new file mode 100644 index 0000000..66ee48f --- /dev/null +++ b/src/main/java/fr/luc/crcore/database/ColumnType.java @@ -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. + * + *

    + *
  • {@link #INTEGER} — entiers 32/64 bits
  • + *
  • {@link #REAL} — flottants (double)
  • + *
  • {@link #TEXT} — chaînes UTF-8
  • + *
  • {@link #BLOB} — données binaires brutes
  • + *
  • {@link #BOOLEAN} — stocké comme INTEGER (0/1) côté SQLite
  • + *
  • {@link #UUID} — stocké comme TEXT (forme canonique 36 caractères)
  • + *
+ */ +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; + } +} diff --git a/src/main/java/fr/luc/crcore/database/Database.java b/src/main/java/fr/luc/crcore/database/Database.java new file mode 100644 index 0000000..456f356 --- /dev/null +++ b/src/main/java/fr/luc/crcore/database/Database.java @@ -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. + * + *

Ouvre une connexion JDBC vers un fichier SQLite et expose 4 méthodes pour + * couvrir 95 % des besoins : + * + *

    + *
  • {@link #execute(String, Object...)} — DDL ou DML qui ne renvoie rien
  • + *
  • {@link #update(String, Object...)} — INSERT/UPDATE/DELETE, renvoie le nombre de lignes affectées
  • + *
  • {@link #queryOne(String, RowMapper, Object...)} — SELECT renvoyant au plus une ligne
  • + *
  • {@link #query(String, RowMapper, Object...)} — SELECT renvoyant plusieurs lignes
  • + *
+ * + *

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}. + * + *

Pour créer une table de manière fluide, voir {@link #table(String)}. + * + *

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

{@code
+     * db.table("foo")
+     *     .ifNotExists()
+     *     .column("id", ColumnType.UUID).primaryKey()
+     *     .column("name", ColumnType.TEXT).notNull()
+     *     .create();
+     * }
+ */ + 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 Optional queryOne(String sql, RowMapper 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 List query(String sql, RowMapper mapper, Object... params) { + Objects.requireNonNull(mapper, "mapper"); + List 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; + } +} diff --git a/src/main/java/fr/luc/crcore/database/DatabaseException.java b/src/main/java/fr/luc/crcore/database/DatabaseException.java new file mode 100644 index 0000000..7226b41 --- /dev/null +++ b/src/main/java/fr/luc/crcore/database/DatabaseException.java @@ -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); + } +} diff --git a/src/main/java/fr/luc/crcore/database/RowMapper.java b/src/main/java/fr/luc/crcore/database/RowMapper.java new file mode 100644 index 0000000..56aec97 --- /dev/null +++ b/src/main/java/fr/luc/crcore/database/RowMapper.java @@ -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. + * + *

Utilisé par {@link Database#query} et {@link Database#queryOne}. Le mapper + * ne doit pas appeler {@code rs.next()} : c'est {@link Database} qui + * itère. + * + *

{@code
+ * RowMapper nameMapper = rs -> rs.getString("name");
+ * List names = db.query("SELECT name FROM teams", nameMapper);
+ * }
+ */ +@FunctionalInterface +public interface RowMapper { + + T map(ResultSet rs) throws SQLException; +} diff --git a/src/main/java/fr/luc/crcore/database/TableBuilder.java b/src/main/java/fr/luc/crcore/database/TableBuilder.java new file mode 100644 index 0000000..6373cee --- /dev/null +++ b/src/main/java/fr/luc/crcore/database/TableBuilder.java @@ -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)}. + * + *
{@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();
+ * }
+ * + *

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 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(); + } + } +} diff --git a/src/main/java/fr/luc/crcore/player/BukkitEventFiringPlayerProfileServiceImpl.java b/src/main/java/fr/luc/crcore/player/BukkitEventFiringPlayerProfileServiceImpl.java new file mode 100644 index 0000000..a78affc --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/BukkitEventFiringPlayerProfileServiceImpl.java @@ -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)); + } +} diff --git a/src/main/java/fr/luc/crcore/player/PlayerProfile.java b/src/main/java/fr/luc/crcore/player/PlayerProfile.java index 6790020..95b87e4 100644 --- a/src/main/java/fr/luc/crcore/player/PlayerProfile.java +++ b/src/main/java/fr/luc/crcore/player/PlayerProfile.java @@ -9,6 +9,14 @@ import java.util.Map; import java.util.Objects; import java.util.UUID; +/** + * Profil persistant d'un joueur. Identifié par l'UUID Bukkit du joueur, + * porte ses scores nommés. + * + *

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 { private final Map scores; diff --git a/src/main/java/fr/luc/crcore/player/PlayerProfileService.java b/src/main/java/fr/luc/crcore/player/PlayerProfileService.java index f5b4f38..3b2f6ef 100644 --- a/src/main/java/fr/luc/crcore/player/PlayerProfileService.java +++ b/src/main/java/fr/luc/crcore/player/PlayerProfileService.java @@ -6,6 +6,13 @@ import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; +/** + * Façade pour les profils joueurs : lifecycle, scores, classements. + * + *

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 { PlayerProfile getOrCreateProfile(UUID playerId); diff --git a/src/main/java/fr/luc/crcore/player/SqlitePlayerProfileRepository.java b/src/main/java/fr/luc/crcore/player/SqlitePlayerProfileRepository.java new file mode 100644 index 0000000..6b6d1f2 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/SqlitePlayerProfileRepository.java @@ -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. + * + *

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

Schéma (2 tables) : + *

    + *
  • {@code crcore_player_profiles} — une ligne par joueur
  • + *
  • {@code crcore_player_scores} — une ligne par (joueur, score nommé)
  • + *
+ */ +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 + ) + ); + } +} diff --git a/src/main/java/fr/luc/crcore/player/event/PlayerProfileCreateEvent.java b/src/main/java/fr/luc/crcore/player/event/PlayerProfileCreateEvent.java new file mode 100644 index 0000000..845803c --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/event/PlayerProfileCreateEvent.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/player/event/PlayerProfileDeleteEvent.java b/src/main/java/fr/luc/crcore/player/event/PlayerProfileDeleteEvent.java new file mode 100644 index 0000000..460f377 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/event/PlayerProfileDeleteEvent.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/player/event/PlayerProfileEvent.java b/src/main/java/fr/luc/crcore/player/event/PlayerProfileEvent.java new file mode 100644 index 0000000..bfceec2 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/event/PlayerProfileEvent.java @@ -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}. + * + *

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; + } +} diff --git a/src/main/java/fr/luc/crcore/player/event/PlayerScoreChangeEvent.java b/src/main/java/fr/luc/crcore/player/event/PlayerScoreChangeEvent.java new file mode 100644 index 0000000..7ba7e7b --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/event/PlayerScoreChangeEvent.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/BukkitEventFiringTeamServiceImpl.java b/src/main/java/fr/luc/crcore/team/BukkitEventFiringTeamServiceImpl.java new file mode 100644 index 0000000..08b5234 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/BukkitEventFiringTeamServiceImpl.java @@ -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, + * tire des évènements Bukkit via les hooks {@code on...} hérités. + * + *

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)); + } +} diff --git a/src/main/java/fr/luc/crcore/team/SqliteTeamRepository.java b/src/main/java/fr/luc/crcore/team/SqliteTeamRepository.java new file mode 100644 index 0000000..0f7093f --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/SqliteTeamRepository.java @@ -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. + * + *

Stratégie : write-through cache. 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. + * + *

Schéma (3 tables) : + *

    + *
  • {@code crcore_teams} — une ligne par équipe (champs scalaires)
  • + *
  • {@code crcore_team_members} — une ligne par membre
  • + *
  • {@code crcore_team_scores} — une ligne par (équipe, score nommé)
  • + *
+ * + *

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) {} +} diff --git a/src/main/java/fr/luc/crcore/team/Team.java b/src/main/java/fr/luc/crcore/team/Team.java index 09f5af1..43f596c 100644 --- a/src/main/java/fr/luc/crcore/team/Team.java +++ b/src/main/java/fr/luc/crcore/team/Team.java @@ -14,6 +14,21 @@ import java.util.Optional; import java.util.Set; 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. + * + *

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

Implémente {@link Named} (a un nom) et {@link ScoreHolder} (porte des + * scores nommés). Hérite de {@link AbstractEntity} pour l'identité. + * + *

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 { private final String name; diff --git a/src/main/java/fr/luc/crcore/team/TeamService.java b/src/main/java/fr/luc/crcore/team/TeamService.java index 368ee50..1d7fc44 100644 --- a/src/main/java/fr/luc/crcore/team/TeamService.java +++ b/src/main/java/fr/luc/crcore/team/TeamService.java @@ -8,6 +8,19 @@ import java.util.Optional; import java.util.UUID; 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é. + * + *

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}). + * + *

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 { // ---- Lifecycle ---- diff --git a/src/main/java/fr/luc/crcore/team/event/PlayerJoinTeamEvent.java b/src/main/java/fr/luc/crcore/team/event/PlayerJoinTeamEvent.java new file mode 100644 index 0000000..0c6698d --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/PlayerJoinTeamEvent.java @@ -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 par sa propre + * action (auto-join sur une équipe PUBLIC via {@code TeamService.joinTeam}). + * + *

Dans ce cas, {@link TeamMemberAddEvent} est aussi 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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/event/TeamCreateEvent.java b/src/main/java/fr/luc/crcore/team/event/TeamCreateEvent.java new file mode 100644 index 0000000..6b9306d --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/TeamCreateEvent.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/event/TeamDissolveEvent.java b/src/main/java/fr/luc/crcore/team/event/TeamDissolveEvent.java new file mode 100644 index 0000000..0bdd9e0 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/TeamDissolveEvent.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/event/TeamEvent.java b/src/main/java/fr/luc/crcore/team/event/TeamEvent.java new file mode 100644 index 0000000..2697a5d --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/TeamEvent.java @@ -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. + * + *

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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/event/TeamLeadershipTransferEvent.java b/src/main/java/fr/luc/crcore/team/event/TeamLeadershipTransferEvent.java new file mode 100644 index 0000000..0714f60 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/TeamLeadershipTransferEvent.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/event/TeamMemberAddEvent.java b/src/main/java/fr/luc/crcore/team/event/TeamMemberAddEvent.java new file mode 100644 index 0000000..38a9b5c --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/TeamMemberAddEvent.java @@ -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}). + * + *

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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/event/TeamMemberRemoveEvent.java b/src/main/java/fr/luc/crcore/team/event/TeamMemberRemoveEvent.java new file mode 100644 index 0000000..0265af6 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/TeamMemberRemoveEvent.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/event/TeamScoreChangeEvent.java b/src/main/java/fr/luc/crcore/team/event/TeamScoreChangeEvent.java new file mode 100644 index 0000000..a05aa43 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/TeamScoreChangeEvent.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/event/TeamSpawnPointChangeEvent.java b/src/main/java/fr/luc/crcore/team/event/TeamSpawnPointChangeEvent.java new file mode 100644 index 0000000..77e7cae --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/TeamSpawnPointChangeEvent.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/event/TeamVisibilityChangeEvent.java b/src/main/java/fr/luc/crcore/team/event/TeamVisibilityChangeEvent.java new file mode 100644 index 0000000..ffa993a --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/event/TeamVisibilityChangeEvent.java @@ -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; + } +}