feat: initial CR-Core library (team + player + command framework)
Pure Maven library for CR Minecraft game plugins, targeting Paper 1.16.5. Common abstractions (fr.luc.crcore.common): Identifiable, Named, ScoreHolder, AbstractEntity, Repository<T>. Team domain (fr.luc.crcore.team): Team entity with name/tag/color/leader/ visibility (PUBLIC|PRIVATE)/members/scores/spawn point, TeamMember, TeamRole/TeamColor/TeamVisibility enums, TeamRanking record, TeamService with overridable hooks (factories, validations, lifecycle events), in-memory repository, dedicated exception hierarchy. Player domain (fr.luc.crcore.player): PlayerProfile with named scores per player, PlayerProfileService with auto-creation, individual rankings, exception hierarchy. Both Team and PlayerProfile implement ScoreHolder. Command framework (fr.luc.crcore.command): Command interface, AbstractCommand base, BaseCommand (CommandExecutor + TabCompleter), SubCommand, CommandContext, CommandResult, ArgumentType<T> + ArgumentTypes catalogue (STRING, INTEGER, DOUBLE, BOOLEAN, ONLINE_PLAYER, enumOf, choice). Docs (docs/) is the single source of truth: README, setup, features, decisions log, and 6 PlantUML diagrams (team class/sequence/activity/join, player class, command class). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+48
@@ -0,0 +1,48 @@
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
.kotlin
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
.idea/libraries/
|
||||
.idea/workspace.xml
|
||||
.idea/shelf/
|
||||
.idea/tasks.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/dictionaries
|
||||
.idea/httpRequests
|
||||
.idea/dataSources/
|
||||
.idea/dataSources.ids
|
||||
.idea/dataSources.local.xml
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+14
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
<option name="originalFiles">
|
||||
<list>
|
||||
<option value="$PROJECT_DIR$/pom.xml" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" default="true" project-jdk-name="25" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,59 @@
|
||||
# GEMINI.md — Instructions pour l'assistant
|
||||
|
||||
## Source de vérité
|
||||
|
||||
**Le dossier `docs/` est la source unique de vérité du projet.**
|
||||
|
||||
- Toute spécification, règle, décision technique, commande ou contrainte doit
|
||||
ê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/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.
|
||||
|
||||
## 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.
|
||||
|
||||
- **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)
|
||||
- **Build** : Maven, Java 16
|
||||
- **Package racine** : `fr.luc.crcore`
|
||||
|
||||
## 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.
|
||||
|
||||
**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.
|
||||
- Hooks `onBefore...` / `onAfter...` partout où ça a du sens.
|
||||
- Factories (`newTeam`, `newMember`, …) pour permettre de substituer des
|
||||
sous-classes sans réécrire le service.
|
||||
|
||||
## Workflow attendu
|
||||
|
||||
1. Lire le contexte pertinent dans `docs/`.
|
||||
2. Discuter / valider l'approche avec l'utilisateur si nécessaire.
|
||||
3. Mettre à jour `docs/` (et le `.puml` concerné) avec la décision ou la spec.
|
||||
4. Implémenter le code conformément à la doc.
|
||||
|
||||
## Conventions de code
|
||||
|
||||
- Code (classes, méthodes, attributs, variables) en **anglais standard**.
|
||||
- Messages joueur et documentation en français.
|
||||
- Séparation stricte : `interface`, `enum`, `abstract class`, `class` concrète,
|
||||
`exception` — chacun dans son fichier.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Documentation CR-Core
|
||||
|
||||
Ce dossier est la **source de vérité** du projet. Toutes les décisions, idées,
|
||||
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 :
|
||||
|
||||
- des abstractions communes (`Identifiable`, `Named`, `ScoreHolder`,
|
||||
`AbstractEntity`, `Repository<T>`) ;
|
||||
- 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.
|
||||
|
||||
## 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).
|
||||
- `decisions.md` — Journal des décisions importantes (ADR léger).
|
||||
- `diagrams/` — Diagrammes PlantUML (`.puml`).
|
||||
|
||||
## Diagrammes
|
||||
|
||||
| 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-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 |
|
||||
|
||||
## Conventions
|
||||
|
||||
- Code : **anglais standard**, séparation stricte interfaces / enums / classes
|
||||
abstraites / classes concrètes / exceptions.
|
||||
- Doc & messages joueur : **français**.
|
||||
- Package racine : `fr.luc.crcore`.
|
||||
- Classes du noyau **non-`final`**, méthodes-clés `protected`, hooks
|
||||
`onBefore…`/`onAfter…` et factories `new…` pour l'override.
|
||||
|
||||
## Contribuer à la doc
|
||||
|
||||
Chaque nouvelle idée, règle, commande ou contrainte discutée doit être ajoutée
|
||||
au fichier approprié, **avant ou pendant** l'implémentation. La doc précède le
|
||||
code.
|
||||
@@ -0,0 +1,234 @@
|
||||
# Journal des décisions
|
||||
|
||||
Format léger : une décision = un titre + contexte + choix + raison.
|
||||
|
||||
## 2026-06-08 — Cible Minecraft 1.16.5 / Paper
|
||||
|
||||
- **Choix** : utiliser l'API Paper 1.16.5 (`paper-api`) plutôt que Spigot.
|
||||
- **Raison** : Paper est un sur-ensemble de Spigot, expose plus d'API utiles
|
||||
pour les évènements, et reste compatible avec un serveur Spigot 1.16.5.
|
||||
- **Conséquence** : un serveur Paper 1.16.5 est recommandé pour le test.
|
||||
|
||||
## 2026-06-08 — Java 16
|
||||
|
||||
- **Choix** : `maven.compiler.source/target = 16`.
|
||||
- **Raison** : Paper 1.16.5 tourne avec un JDK 8–16. Java 16 donne accès aux
|
||||
records, pattern matching simple, etc., tout en restant exécutable sur un
|
||||
serveur 1.16.5.
|
||||
|
||||
## 2026-06-08 — `docs/` = source de vérité
|
||||
|
||||
- **Choix** : toutes les décisions, règles de gameplay, commandes et idées
|
||||
doivent être notées dans `docs/` avant ou pendant l'implémentation.
|
||||
- **Raison** : éviter la dérive entre intention et code, garder une trace
|
||||
partageable des échanges.
|
||||
|
||||
## 2026-06-08 — Code en anglais standard
|
||||
|
||||
- **Choix** : tout le code (classes, méthodes, attributs, variables) est écrit
|
||||
en anglais. La doc utilisateur (`docs/`, messages joueurs) reste en français.
|
||||
- **Raison** : conventions standards du monde Java/Bukkit, lisibilité par tout
|
||||
développeur, cohérence avec l'API Paper.
|
||||
|
||||
## 2026-06-08 — Architecture en couches pour le domaine
|
||||
|
||||
- **Choix** : séparer chaque domaine fonctionnel (ex. `team`) en :
|
||||
- **Interfaces** d'abstraction (ex. `Identifiable`, `Named`, `Repository<T>`,
|
||||
`TeamRepository`, `TeamService`).
|
||||
- **Enums** pour les ensembles fermés (`TeamRole`, `TeamColor`).
|
||||
- **Classe abstraite** commune `AbstractEntity` (gère `id` + `equals/hashCode`).
|
||||
- **Classes concrètes** : entités (`Team`, `TeamMember`), implémentations
|
||||
(`InMemoryTeamRepository`, `TeamServiceImpl`).
|
||||
- **Exceptions** dédiées avec hiérarchie (`TeamException` ➜
|
||||
`TeamAlreadyExistsException`, `TeamNotFoundException`).
|
||||
- **Raison** : testabilité (mocker une interface), évolutivité (changer le
|
||||
backend de persistance sans toucher au service), lisibilité.
|
||||
|
||||
## 2026-06-08 — Persistence en mémoire pour démarrer
|
||||
|
||||
- **Choix** : `InMemoryTeamRepository` (Map<UUID, Team>) comme première
|
||||
implémentation.
|
||||
- **Raison** : permet d'avancer sur le gameplay sans dépendre d'un schéma de
|
||||
stockage. À remplacer par une implémentation YAML/SQLite/Postgres plus tard
|
||||
sans toucher au service.
|
||||
|
||||
## 2026-06-08 — `Team` = entité mutable, `TeamMember` = quasi-immutable
|
||||
|
||||
- **Choix** : `Team` mute (ajouts/retraits de membres, transfert de leadership)
|
||||
; `TeamMember` est immuable, sa transition de rôle passe par `withRole(...)`
|
||||
qui renvoie une nouvelle instance.
|
||||
- **Raison** : un `TeamMember` est identifié par son `playerId` ; il est plus
|
||||
simple de raisonner sur des états successifs en remplaçant l'instance. Le
|
||||
`Set<TeamMember>` reste cohérent car `equals/hashCode` ne dépend que du
|
||||
`playerId`.
|
||||
|
||||
## 2026-06-08 — Renommage du projet : CitesPlugin ➜ CR-Core
|
||||
|
||||
- **Choix** : le projet devient **CR-Core**, un plugin "noyau" réutilisable.
|
||||
Les anciens packages `fr.luc.citesplugin.*` sont déplacés sous `fr.luc.crcore.*`.
|
||||
L'ancien `CitesPlugin` (le jeu lui-même) deviendra un futur plugin séparé qui
|
||||
déclarera `depend: [CR-Core]`.
|
||||
- **Raison** : centraliser les briques transverses (équipes, futurs scores,
|
||||
profils joueurs, etc.) pour pouvoir les réutiliser sur plusieurs plugins de
|
||||
jeu sans dupliquer le code.
|
||||
- **Conséquence** : downstream consomme CR-Core soit via la façade statique
|
||||
`fr.luc.crcore.CR` (ex. `CR.teams()`), soit via le `ServicesManager` Bukkit
|
||||
(`getServicesManager().load(TeamService.class)`).
|
||||
|
||||
## 2026-06-08 — Distribution : CR-Core devient une librairie Maven pure (révision)
|
||||
|
||||
- **Révision** des décisions précédentes "plugin Bukkit autonome" et
|
||||
"architecture en modules (PluginModule / ModuleRegistry)".
|
||||
- **Choix** : CR-Core n'est plus un plugin Bukkit, c'est une **librairie**
|
||||
(`jar`) sans `plugin.yml` ni `JavaPlugin`. Chaque plugin de jeu consomme la
|
||||
lib en dépendance Maven, instancie lui-même ses services (`new TeamServiceImpl(...)`)
|
||||
et garde son propre registre.
|
||||
- **Raison** : la complexité du système de modules + façade statique +
|
||||
`ServicesManager` n'apporte rien quand on est dans un contexte d'events
|
||||
ponctuels où chaque jeu vit dans sa propre session. La lib est nettement plus
|
||||
simple à utiliser et à tester. Le partage d'état entre jeux n'est pas un
|
||||
besoin réel pour les events entre amis.
|
||||
- **Conséquence** : suppression de `CRCorePlugin`, `CR` (façade), `plugin.yml`,
|
||||
`PluginModule`, `AbstractModule`, `ModuleRegistry`, `TeamModule`. Suppression
|
||||
aussi de `maven-shade-plugin` côté core (c'est le plugin de jeu qui décide
|
||||
de shader ou non).
|
||||
|
||||
## 2026-06-08 — Overridabilité par défaut
|
||||
|
||||
- **Choix** : toutes les classes du noyau sont conçues pour être étendues.
|
||||
Pas de `final` sur les classes ; méthodes-clés en `protected` ; factories
|
||||
`newXxx(...)` pour substituer des sous-classes ; hooks `onBeforeXxx` /
|
||||
`onAfterXxx` autour des opérations importantes.
|
||||
- **Exemples sur `TeamServiceImpl`** : `newTeam`, `validateName`, `validateTag`,
|
||||
`validateLeader`, `onBeforeSave`, `onAfterCreate`, `onBeforeDissolve`,
|
||||
`onAfterDissolve`, `onMemberAdded`, `onMemberRemoved`,
|
||||
`onLeadershipTransferred`. Sur `Team` : `newMember`.
|
||||
- **Raison** : le noyau doit fournir un comportement par défaut "qui marche",
|
||||
mais chaque jeu doit pouvoir greffer ses propres règles (logging, persistance
|
||||
custom, validations supplémentaires, hooks d'events Bukkit) sans réécrire
|
||||
toute la classe.
|
||||
|
||||
## 2026-06-08 — Framework de commandes intégré
|
||||
|
||||
- **Choix** : CR-Core fournit `Command` (interface), `AbstractCommand`
|
||||
(classe abstraite avec tous les builders), `BaseCommand` (top-level
|
||||
Bukkit-aware, conteneur de sous-commandes), `SubCommand` (feuille), plus
|
||||
`CommandContext`, `CommandResult`, `ArgumentType<T>` et un jeu de types
|
||||
built-in (`STRING`, `INTEGER`, `DOUBLE`, `BOOLEAN`, `ONLINE_PLAYER`,
|
||||
`enumOf(...)`, `choice(...)`).
|
||||
- **Raison** : chaque plugin de jeu aura ses commandes ; mutualiser le routage
|
||||
args[0]→sous-commande, les checks permission / player-only, le parsing des
|
||||
arguments et la tab-completion évite de redévelopper le même squelette à
|
||||
chaque fois.
|
||||
- **Non-choix** : pas d'annotations (`@Command`, `@Argument`) — la résolution
|
||||
par réflexion ajouterait une dépendance d'outillage et compliquerait le
|
||||
debug. L'API builder reste assez concise.
|
||||
- **Découplage** : le framework ne contient pas de commande "team" prête à
|
||||
l'emploi. C'est au plugin de jeu de définir ses commandes en utilisant les
|
||||
briques. Ça permet à chaque jeu d'avoir ses propres permissions, messages,
|
||||
et règles métier.
|
||||
|
||||
## 2026-06-08 — Visibilité publique / privée des équipes
|
||||
|
||||
- **Choix** : ajout d'un enum `TeamVisibility { PUBLIC, PRIVATE }` porté par
|
||||
`Team`. Une équipe `PUBLIC` peut être rejointe par un joueur via
|
||||
`TeamService.joinTeam(teamId, playerId)` ; une équipe `PRIVATE` ne peut
|
||||
recevoir des membres que via `addMember` appelé par le chef.
|
||||
- **Défaut** : `PRIVATE` à la création (le chef garde le contrôle ; il faut
|
||||
une action explicite pour ouvrir l'équipe au public). Une surcharge
|
||||
`createTeam(..., visibility)` permet de créer directement en `PUBLIC`.
|
||||
- **Nouvelle exception** : `TeamAccessException extends TeamException` —
|
||||
levée quand un auto-join est refusé (team privée, ou joueur déjà dans une
|
||||
équipe, ou refus custom dans `validateJoinable`).
|
||||
- **Nouveaux hooks** : `validateJoinable(team, playerId)`,
|
||||
`onPlayerJoined(team, member)`, `onVisibilityChanged(team, oldV, newV)`.
|
||||
- **Décision écartée pour l'instant** : un mode `INVITE_ONLY` avec un système
|
||||
d'invitations pendantes (Player A invite Player B → B accepte). Pas
|
||||
indispensable pour démarrer ; le chef peut déjà ajouter directement via
|
||||
`addMember`. À reconsidérer si le besoin remonte.
|
||||
|
||||
## 2026-06-08 — Scores nommés (Map<String, Integer>) plutôt qu'un score unique
|
||||
|
||||
- **Choix** : chaque équipe porte un `Map<String, Integer>` de scores nommés
|
||||
(`"kills"`, `"objectives"`, `"global"`, …) plutôt qu'un seul `int score`.
|
||||
- **Raison** : tous les jeux n'ont pas la même métrique. BedWars a "beds_broken"
|
||||
+ "final_kills" ; un mode Capture the Flag a "flags" + "kills" ; un mode
|
||||
simple peut n'utiliser que `"global"`. Un Map évite d'imposer un schéma fixe
|
||||
et reste compact pour les cas mono-score.
|
||||
- **Conséquence** : les noms de scores sont libres et non typés au niveau du
|
||||
noyau ; chaque jeu choisit ses propres noms. Pour de la sûreté, un jeu peut
|
||||
exposer un enum ou des constantes côté plugin.
|
||||
- **Type** : `Integer` plutôt que `Long` ou `Double`. Suffisant pour des
|
||||
scores de match (limite ~2 milliards). Si un jeu a besoin de Long ou Double,
|
||||
il peut wrapper et stocker un encodage custom ; ou bien on étendra l'API
|
||||
plus tard.
|
||||
|
||||
## 2026-06-08 — Classements : ranking par score + ranking global (= somme)
|
||||
|
||||
- **Choix** : `TeamService` expose `getRankingByScore(scoreName)` et
|
||||
`getGlobalRanking()`. Le ranking global est calculé comme la **somme** de
|
||||
tous les scores nommés de chaque équipe.
|
||||
- **Raison** : couvre les deux cas usuels (« qui a le plus de kills ? » et
|
||||
« qui a la meilleure perf globale ? ») sans imposer de pondération par
|
||||
défaut. Un jeu qui veut une formule custom (pondérée, ratio, …) override
|
||||
`rank(ToIntFunction<Team>)` ou ajoute une méthode de service dans sa propre
|
||||
sous-classe.
|
||||
- **Format du résultat** : `record TeamRanking(int rank, Team team, int score)`.
|
||||
Le `rank` est 1-based, le tri est descendant sur le score, le tiebreaker est
|
||||
alphabétique (case-insensitive) sur le nom de l'équipe.
|
||||
- **Records** : choix d'un record Java 16 plutôt qu'une classe immutable
|
||||
manuelle — moins de boilerplate, `equals/hashCode/toString` gratuits. Les
|
||||
records étant `final`, un jeu qui veut un type custom devra wrapper et
|
||||
override `newRanking(...)` au niveau du service.
|
||||
|
||||
## 2026-06-08 — Spawn point par équipe (Bukkit Location)
|
||||
|
||||
- **Choix** : chaque `Team` peut avoir un `Location` Bukkit optionnel comme
|
||||
point de spawn. Stocké en mémoire pour l'instant.
|
||||
- **Clonage défensif** : `getSpawnPoint()` retourne un `Optional<Location>` où
|
||||
la `Location` est **clonée** ; idem à l'entrée dans `setSpawnPoint`.
|
||||
`Location` étant mutable côté Bukkit, ça évite que du code externe modifie
|
||||
accidentellement le spawn en faisant `team.getSpawnPoint().get().setX(...)`.
|
||||
- **Persistance différée** : `Location` n'est pas trivialement sérialisable
|
||||
(référence au `World`). On utilisera `ConfigurationSerializable` quand on
|
||||
branchera un repo fichier ; pour l'instant, le `InMemoryTeamRepository`
|
||||
s'en moque.
|
||||
- **Pas de téléport intégré** : le noyau ne fournit pas `teleportToSpawn(...)`.
|
||||
C'est au plugin de jeu d'enchaîner `player.teleport(team.getSpawnPoint())`
|
||||
s'il veut. La lib reste purement "data + règles".
|
||||
|
||||
## 2026-06-08 — Scores joueurs : domaine `player` indépendant du domaine `team`
|
||||
|
||||
- **Choix** : ajout d'un domaine `fr.luc.crcore.player` complet et parallèle au
|
||||
domaine `team` : `PlayerProfile` (entité identifiée par l'UUID Bukkit),
|
||||
`PlayerProfileService`, `PlayerProfileRepository`,
|
||||
`InMemoryPlayerProfileRepository`, `PlayerRanking` (record),
|
||||
`PlayerException` / `PlayerProfileNotFoundException`.
|
||||
- **Pourquoi pas sur `TeamMember`** : `TeamMember` est immuable (transitions de
|
||||
rôle via `withRole`), et un joueur peut changer/quitter une équipe — son
|
||||
profil doit persister. Mettre les scores sur `TeamMember` aurait couplé la
|
||||
durée de vie du score à l'appartenance à l'équipe.
|
||||
- **Auto-création** : `addScore` / `setScore` créent le profil automatiquement
|
||||
s'il n'existe pas (`getOrCreateProfile(playerId)`). Pas besoin d'appeler
|
||||
explicitement un `register(playerId)` avant de tracker un score.
|
||||
- **Symétrie** : les noms de méthodes, hooks et factories reflètent
|
||||
exactement le domaine team (`newProfile`/`newTeam`,
|
||||
`newRanking`/`newRanking`, `rank(scoreFn)`, `onScoreChanged`,
|
||||
`getRankingByScore`, `getGlobalRanking`, etc.).
|
||||
|
||||
## 2026-06-08 — Interface `ScoreHolder` mutualisée
|
||||
|
||||
- **Choix** : extraction d'une interface `fr.luc.crcore.common.ScoreHolder`
|
||||
qui déclare le contrat de scoring (`getScore`, `addScore`, `setScore`,
|
||||
`resetScore`, `getTotalScore`, etc.). `Team` et `PlayerProfile`
|
||||
l'implémentent.
|
||||
- **Raison** : documenter le contrat et permettre du code générique côté
|
||||
plugin de jeu (ex. `ScoreHolder.getTotalScore()` traité uniformément pour
|
||||
l'affichage). Pas de classe abstraite partagée pour éviter le couplage
|
||||
serré (Team et PlayerProfile ont des cycles de vie très différents) ; on
|
||||
reste sur deux implémentations indépendantes mais avec un contrat commun.
|
||||
- **Pas de `Scoreboard` aggregate** : envisagé un composant `Scoreboard`
|
||||
composé dans Team et PlayerProfile, mais ça aurait imposé une indirection
|
||||
pour ~8 méthodes simples. Choix actuel : duplication contrôlée des
|
||||
implémentations (Map + getters/setters), interface commune pour le
|
||||
contrat.
|
||||
@@ -0,0 +1,132 @@
|
||||
@startuml command-class-diagram
|
||||
title CR-Core — Command framework (class diagram)
|
||||
|
||||
skinparam classAttributeIconSize 0
|
||||
hide empty members
|
||||
|
||||
package "fr.luc.crcore.command" {
|
||||
|
||||
interface Command {
|
||||
+ getName(): String
|
||||
+ getAliases(): List<String>
|
||||
+ getPermission(): String
|
||||
+ isPlayerOnly(): boolean
|
||||
+ getDescription(): String
|
||||
+ execute(ctx: CommandContext): CommandResult
|
||||
+ tabComplete(sender, argIndex, partial): List<String>
|
||||
+ matches(label: String): boolean
|
||||
}
|
||||
|
||||
abstract class AbstractCommand {
|
||||
- name: String
|
||||
- aliases: List<String>
|
||||
- permission: String
|
||||
- playerOnly: boolean
|
||||
- description: String
|
||||
- usage: String
|
||||
- arguments: List<ArgumentDef>
|
||||
# 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
|
||||
}
|
||||
|
||||
abstract class BaseCommand {
|
||||
- subCommandsByName: Map<String, SubCommand>
|
||||
- subCommandsByAlias: Map<String, SubCommand>
|
||||
# addSubCommand(sub: SubCommand): void
|
||||
+ findSubCommand(label: String): Optional<SubCommand>
|
||||
+ getSubCommands(): Collection<SubCommand>
|
||||
# execute(ctx): CommandResult
|
||||
+ onCommand(sender, cmd, label, args): boolean
|
||||
+ onTabComplete(sender, cmd, alias, args): List<String>
|
||||
# checkAccess(sender, target): boolean
|
||||
# handleResult(sender, result): void
|
||||
}
|
||||
|
||||
abstract class SubCommand {
|
||||
+ {abstract} execute(ctx: CommandContext): CommandResult
|
||||
}
|
||||
|
||||
class CommandContext {
|
||||
- sender: CommandSender
|
||||
- label: String
|
||||
- rawArgs: String[]
|
||||
- parsedArgs: Map<String, Object>
|
||||
+ getSender(): CommandSender
|
||||
+ isPlayer(): boolean
|
||||
+ getPlayer(): Optional<Player>
|
||||
+ requirePlayer(): Player
|
||||
+ get(name: String): T
|
||||
+ getOptional(name): Optional<T>
|
||||
+ has(name): boolean
|
||||
+ reply(msg): void
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
enum "CommandResult.Type" as ResultType {
|
||||
SUCCESS
|
||||
FAILURE
|
||||
INVALID_USAGE
|
||||
NO_PERMISSION
|
||||
PLAYER_ONLY
|
||||
}
|
||||
|
||||
class CommandException
|
||||
|
||||
interface "ArgumentType<T>" as ArgumentType {
|
||||
+ parse(input: String): T
|
||||
+ suggestions(sender, partial): List<String>
|
||||
}
|
||||
|
||||
class ArgumentTypes << (S, #FFC107) static >> {
|
||||
+ {static} STRING: ArgumentType<String>
|
||||
+ {static} INTEGER: ArgumentType<Integer>
|
||||
+ {static} DOUBLE: ArgumentType<Double>
|
||||
+ {static} BOOLEAN: ArgumentType<Boolean>
|
||||
+ {static} ONLINE_PLAYER: ArgumentType<Player>
|
||||
+ {static} enumOf(type): ArgumentType<E>
|
||||
+ {static} choice(choices): ArgumentType<String>
|
||||
}
|
||||
|
||||
class ArgumentDef << (P, #BBBBBB) package-private >> {
|
||||
- name: String
|
||||
- type: ArgumentType<?>
|
||||
- required: boolean
|
||||
}
|
||||
|
||||
AbstractCommand ..|> Command
|
||||
BaseCommand --|> AbstractCommand
|
||||
SubCommand --|> AbstractCommand
|
||||
BaseCommand "1" o-- "*" SubCommand : subCommands
|
||||
AbstractCommand "1" *-- "*" ArgumentDef : arguments
|
||||
ArgumentDef --> ArgumentType
|
||||
CommandResult +-- ResultType
|
||||
CommandException --|> RuntimeException
|
||||
|
||||
BaseCommand ..> CommandContext : creates
|
||||
AbstractCommand ..> CommandContext : creates
|
||||
SubCommand ..> CommandResult : returns
|
||||
}
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,110 @@
|
||||
@startuml player-class-diagram
|
||||
title CR-Core — Player domain (class diagram)
|
||||
|
||||
skinparam classAttributeIconSize 0
|
||||
hide empty members
|
||||
|
||||
' === Common abstractions ===
|
||||
|
||||
package "fr.luc.crcore.common" {
|
||||
|
||||
interface Identifiable {
|
||||
+ getId(): UUID
|
||||
}
|
||||
|
||||
interface ScoreHolder {
|
||||
+ getScore(name): int
|
||||
+ hasScore(name): boolean
|
||||
+ getScores(): Map<String, Integer>
|
||||
+ getTotalScore(): int
|
||||
+ addScore(name, delta): int
|
||||
+ setScore(name, value): int
|
||||
+ resetScore(name): boolean
|
||||
+ resetAllScores(): void
|
||||
}
|
||||
|
||||
abstract class AbstractEntity {
|
||||
- id: UUID
|
||||
+ getId(): UUID
|
||||
}
|
||||
|
||||
interface "Repository<T extends Identifiable>" as Repository {
|
||||
+ save(entity: T): T
|
||||
+ findById(id): Optional<T>
|
||||
+ findAll(): Collection<T>
|
||||
+ delete(id): boolean
|
||||
}
|
||||
|
||||
AbstractEntity ..|> Identifiable
|
||||
}
|
||||
|
||||
' === Player domain ===
|
||||
|
||||
package "fr.luc.crcore.player" {
|
||||
|
||||
class PlayerProfile {
|
||||
- scores: Map<String, Integer>
|
||||
+ PlayerProfile(playerId: UUID)
|
||||
+ getPlayerId(): UUID
|
||||
}
|
||||
|
||||
class PlayerRanking <<record>> {
|
||||
+ rank: int
|
||||
+ profile: PlayerProfile
|
||||
+ score: int
|
||||
}
|
||||
|
||||
interface PlayerProfileRepository
|
||||
|
||||
class InMemoryPlayerProfileRepository {
|
||||
- profiles: Map<UUID, PlayerProfile>
|
||||
}
|
||||
|
||||
interface PlayerProfileService {
|
||||
+ getOrCreateProfile(playerId): PlayerProfile
|
||||
+ getProfile(playerId): Optional<PlayerProfile>
|
||||
+ deleteProfile(playerId): boolean
|
||||
+ getAllProfiles(): Collection<PlayerProfile>
|
||||
--
|
||||
+ addScore(playerId, name, delta): int
|
||||
+ setScore(playerId, name, value): int
|
||||
+ getScore(playerId, name): int
|
||||
+ resetScore(playerId, name): boolean
|
||||
+ resetAllScores(playerId): void
|
||||
--
|
||||
+ getRankingByScore(name): List<PlayerRanking>
|
||||
+ getGlobalRanking(): List<PlayerRanking>
|
||||
+ getTopRankingByScore(name, limit): List<PlayerRanking>
|
||||
+ getTopGlobalRanking(limit): List<PlayerRanking>
|
||||
}
|
||||
|
||||
class PlayerProfileServiceImpl {
|
||||
- repository: PlayerProfileRepository
|
||||
--
|
||||
# newProfile(playerId): PlayerProfile
|
||||
# newRanking(rank, profile, score): PlayerRanking
|
||||
# rank(scoreFn): List<PlayerRanking>
|
||||
--
|
||||
# onProfileCreated(profile): void
|
||||
# onProfileDeleted(profile): void
|
||||
# onScoreChanged(profile, name, oldV, newV): void
|
||||
}
|
||||
|
||||
class PlayerException
|
||||
class PlayerProfileNotFoundException
|
||||
|
||||
PlayerProfile --|> AbstractEntity
|
||||
PlayerProfile ..|> ScoreHolder
|
||||
PlayerProfileRepository --|> Repository
|
||||
InMemoryPlayerProfileRepository ..|> PlayerProfileRepository
|
||||
PlayerProfileServiceImpl ..|> PlayerProfileService
|
||||
|
||||
PlayerRanking --> PlayerProfile
|
||||
PlayerProfileServiceImpl o--> PlayerProfileRepository
|
||||
PlayerProfileService ..> PlayerRanking : produces
|
||||
|
||||
PlayerException --|> RuntimeException
|
||||
PlayerProfileNotFoundException --|> PlayerException
|
||||
}
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,239 @@
|
||||
@startuml team-class-diagram
|
||||
title CR-Core — Team domain (class diagram)
|
||||
|
||||
skinparam classAttributeIconSize 0
|
||||
hide empty members
|
||||
|
||||
' === Common abstractions ===
|
||||
|
||||
package "fr.luc.crcore.common" {
|
||||
|
||||
interface Identifiable {
|
||||
+ getId(): UUID
|
||||
}
|
||||
|
||||
interface Named {
|
||||
+ getName(): String
|
||||
}
|
||||
|
||||
interface ScoreHolder {
|
||||
+ getScore(name): int
|
||||
+ hasScore(name): boolean
|
||||
+ getScores(): Map<String, Integer>
|
||||
+ getTotalScore(): int
|
||||
+ addScore(name, delta): int
|
||||
+ setScore(name, value): int
|
||||
+ resetScore(name): boolean
|
||||
+ resetAllScores(): void
|
||||
}
|
||||
|
||||
abstract class AbstractEntity {
|
||||
- id: UUID
|
||||
+ AbstractEntity(id: UUID)
|
||||
+ getId(): UUID
|
||||
+ equals(o: Object): boolean
|
||||
+ hashCode(): int
|
||||
}
|
||||
|
||||
interface "Repository<T extends Identifiable>" as Repository {
|
||||
+ save(entity: T): T
|
||||
+ findById(id: UUID): Optional<T>
|
||||
+ findAll(): Collection<T>
|
||||
+ delete(id: UUID): boolean
|
||||
}
|
||||
|
||||
AbstractEntity ..|> Identifiable
|
||||
}
|
||||
|
||||
' === Team domain ===
|
||||
|
||||
package "fr.luc.crcore.team" {
|
||||
|
||||
enum TeamRole {
|
||||
LEADER
|
||||
MEMBER
|
||||
--
|
||||
+ isLeader(): boolean
|
||||
}
|
||||
|
||||
enum TeamVisibility {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
--
|
||||
+ isPublic(): boolean
|
||||
+ isPrivate(): boolean
|
||||
}
|
||||
|
||||
enum TeamColor {
|
||||
RED
|
||||
BLUE
|
||||
GREEN
|
||||
YELLOW
|
||||
AQUA
|
||||
LIGHT_PURPLE
|
||||
GOLD
|
||||
WHITE
|
||||
BLACK
|
||||
DARK_BLUE
|
||||
DARK_GREEN
|
||||
DARK_AQUA
|
||||
DARK_RED
|
||||
DARK_PURPLE
|
||||
DARK_GRAY
|
||||
GRAY
|
||||
--
|
||||
+ getChatColor(): ChatColor
|
||||
+ getDyeColor(): DyeColor
|
||||
+ getDisplayName(): String
|
||||
}
|
||||
|
||||
class TeamMember {
|
||||
- role: TeamRole
|
||||
- joinedAt: Instant
|
||||
+ getPlayerId(): UUID
|
||||
+ getRole(): TeamRole
|
||||
+ getJoinedAt(): Instant
|
||||
+ isLeader(): boolean
|
||||
+ withRole(role: TeamRole): TeamMember
|
||||
}
|
||||
|
||||
class Team {
|
||||
- name: String
|
||||
- tag: String
|
||||
- color: TeamColor
|
||||
- leaderId: UUID
|
||||
- visibility: TeamVisibility
|
||||
- members: Set<TeamMember>
|
||||
- scores: Map<String, Integer>
|
||||
- spawnPoint: Location
|
||||
--
|
||||
+ getName(): String
|
||||
+ getTag(): String
|
||||
+ getColor(): TeamColor
|
||||
+ getLeaderId(): UUID
|
||||
+ getLeader(): TeamMember
|
||||
+ getVisibility(): TeamVisibility
|
||||
+ setVisibility(v): void
|
||||
+ isPublic(): boolean
|
||||
+ getMembers(): Set<TeamMember>
|
||||
+ getMember(playerId): Optional<TeamMember>
|
||||
+ hasMember(playerId): boolean
|
||||
+ size(): int
|
||||
+ addMember(playerId): TeamMember
|
||||
+ removeMember(playerId): boolean
|
||||
+ transferLeadership(newLeaderId): void
|
||||
--
|
||||
+ getScore(name): int
|
||||
+ hasScore(name): boolean
|
||||
+ getScores(): Map<String, Integer>
|
||||
+ getTotalScore(): int
|
||||
+ addScore(name, delta): int
|
||||
+ setScore(name, value): int
|
||||
+ resetScore(name): boolean
|
||||
+ resetAllScores(): void
|
||||
--
|
||||
+ getSpawnPoint(): Optional<Location>
|
||||
+ hasSpawnPoint(): boolean
|
||||
+ setSpawnPoint(loc): void
|
||||
+ clearSpawnPoint(): void
|
||||
--
|
||||
# newMember(playerId, role): TeamMember
|
||||
}
|
||||
|
||||
class TeamRanking <<record>> {
|
||||
+ rank: int
|
||||
+ team: Team
|
||||
+ score: int
|
||||
}
|
||||
|
||||
interface TeamRepository {
|
||||
+ findByName(name: String): Optional<Team>
|
||||
+ findByTag(tag: String): Optional<Team>
|
||||
+ findByMember(playerId: UUID): Optional<Team>
|
||||
}
|
||||
|
||||
class InMemoryTeamRepository {
|
||||
- teams: Map<UUID, Team>
|
||||
}
|
||||
|
||||
interface TeamService {
|
||||
+ createTeam(name, tag, color, leaderId, [visibility]): Team
|
||||
+ dissolveTeam(teamId): boolean
|
||||
+ addMember(teamId, playerId): boolean
|
||||
+ removeMember(teamId, playerId): boolean
|
||||
+ joinTeam(teamId, playerId): boolean
|
||||
+ transferLeadership(teamId, newLeaderId): boolean
|
||||
+ setVisibility(teamId, visibility): void
|
||||
--
|
||||
+ addScore(teamId, name, delta): int
|
||||
+ setScore(teamId, name, value): int
|
||||
+ getScore(teamId, name): int
|
||||
+ resetScore(teamId, name): boolean
|
||||
+ resetAllScores(teamId): void
|
||||
--
|
||||
+ getRankingByScore(name): List<TeamRanking>
|
||||
+ getGlobalRanking(): List<TeamRanking>
|
||||
+ getTopRankingByScore(name, limit): List<TeamRanking>
|
||||
+ getTopGlobalRanking(limit): List<TeamRanking>
|
||||
--
|
||||
+ setSpawnPoint(teamId, loc): void
|
||||
+ clearSpawnPoint(teamId): void
|
||||
+ getSpawnPoint(teamId): Optional<Location>
|
||||
--
|
||||
+ getTeam / getTeamByName / getTeamByTag / getTeamOfPlayer
|
||||
+ getAllTeams(): Collection<Team>
|
||||
}
|
||||
|
||||
class TeamServiceImpl {
|
||||
- repository: TeamRepository
|
||||
--
|
||||
# newTeam(...): Team
|
||||
# newRanking(rank, team, score): TeamRanking
|
||||
# rank(scoreFn): List<TeamRanking>
|
||||
--
|
||||
# validateName(name): void
|
||||
# validateTag(tag): void
|
||||
# validateLeader(leaderId): void
|
||||
# validateJoinable(team, playerId): void
|
||||
--
|
||||
# onBeforeSave(team): void
|
||||
# onAfterCreate(team): void
|
||||
# onBeforeDissolve(team): void
|
||||
# onAfterDissolve(team): void
|
||||
# onMemberAdded(team, member): void
|
||||
# onMemberRemoved(team, playerId): void
|
||||
# onPlayerJoined(team, member): void
|
||||
# onLeadershipTransferred(team, oldId, newId): void
|
||||
# onVisibilityChanged(team, oldV, newV): void
|
||||
# onScoreChanged(team, name, oldV, newV): void
|
||||
# onSpawnPointChanged(team, oldLoc, newLoc): void
|
||||
}
|
||||
|
||||
class TeamException
|
||||
class TeamAlreadyExistsException
|
||||
class TeamNotFoundException
|
||||
class TeamAccessException
|
||||
|
||||
TeamMember --|> AbstractEntity
|
||||
Team --|> AbstractEntity
|
||||
Team ..|> Named
|
||||
Team ..|> ScoreHolder
|
||||
TeamRepository --|> Repository
|
||||
InMemoryTeamRepository ..|> TeamRepository
|
||||
TeamServiceImpl ..|> TeamService
|
||||
|
||||
Team "1" o-- "1..*" TeamMember : members
|
||||
Team --> TeamColor : color
|
||||
Team --> TeamVisibility : visibility
|
||||
TeamMember --> TeamRole : role
|
||||
TeamRanking --> Team
|
||||
TeamServiceImpl o--> TeamRepository
|
||||
TeamService ..> TeamRanking : produces
|
||||
|
||||
TeamException --|> RuntimeException
|
||||
TeamAlreadyExistsException --|> TeamException
|
||||
TeamNotFoundException --|> TeamException
|
||||
TeamAccessException --|> TeamException
|
||||
}
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,40 @@
|
||||
@startuml team-create-activity
|
||||
title CR-Core — Create Team (activity diagram)
|
||||
|
||||
start
|
||||
|
||||
:Player runs /team create <name> <tag> <color> [visibility];
|
||||
|
||||
if (Name already in use?) then (yes)
|
||||
:Reply "team name already taken";
|
||||
stop
|
||||
else (no)
|
||||
endif
|
||||
|
||||
if (Tag already in use?) then (yes)
|
||||
:Reply "team tag already taken";
|
||||
stop
|
||||
else (no)
|
||||
endif
|
||||
|
||||
if (Player already in a team?) then (yes)
|
||||
:Reply "you already belong to a team";
|
||||
stop
|
||||
else (no)
|
||||
endif
|
||||
|
||||
if (Color valid?) then (no)
|
||||
:Reply "unknown color";
|
||||
stop
|
||||
else (yes)
|
||||
endif
|
||||
|
||||
:Create Team(id = randomUUID, name, tag, color, leaderId = playerId, visibility);
|
||||
note right: visibility defaults to PRIVATE\nif not specified
|
||||
:Add player as TeamMember(role = LEADER);
|
||||
:Persist via TeamRepository.save(team);
|
||||
:Reply "team created";
|
||||
|
||||
stop
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,69 @@
|
||||
@startuml team-create-sequence
|
||||
title CR-Core — Create Team via command framework (sequence diagram)
|
||||
|
||||
actor Player
|
||||
participant "BaseCommand\n(TeamCommand)" as Base
|
||||
participant "SubCommand\n(TeamCreateSub)" as Sub
|
||||
participant "TeamService" as Service
|
||||
participant "TeamRepository" as Repo
|
||||
participant "Team" as Team
|
||||
|
||||
Player -> Base : /team create <name> <tag> <color>
|
||||
activate Base
|
||||
|
||||
Base -> Base : route args[0]="create" → TeamCreateSub
|
||||
Base -> Base : check permission + playerOnly
|
||||
Base -> Sub : buildContext(sender, label, subArgs)
|
||||
activate Sub
|
||||
Sub -> Sub : parse name (STRING) / tag (STRING) / color (enumOf TeamColor)
|
||||
Sub --> Base : CommandContext
|
||||
deactivate Sub
|
||||
|
||||
Base -> Sub : execute(ctx)
|
||||
activate Sub
|
||||
|
||||
Sub -> Service : createTeam(name, tag, color, playerId)
|
||||
activate Service
|
||||
|
||||
Service -> Service : validateName(name)
|
||||
Service -> Repo : findByName(name)
|
||||
activate Repo
|
||||
Repo --> Service : Optional.empty()
|
||||
deactivate Repo
|
||||
|
||||
Service -> Service : validateTag(tag)
|
||||
Service -> Repo : findByTag(tag)
|
||||
activate Repo
|
||||
Repo --> Service : Optional.empty()
|
||||
deactivate Repo
|
||||
|
||||
Service -> Service : validateLeader(playerId)
|
||||
Service -> Repo : findByMember(playerId)
|
||||
activate Repo
|
||||
Repo --> Service : Optional.empty()
|
||||
deactivate Repo
|
||||
|
||||
Service -> Team : newTeam(UUID.randomUUID(), name, tag, color, playerId, PRIVATE)
|
||||
activate Team
|
||||
Team -> Team : add newMember(playerId, LEADER)
|
||||
Team --> Service : team
|
||||
deactivate Team
|
||||
|
||||
Service -> Service : onBeforeSave(team)
|
||||
Service -> Repo : save(team)
|
||||
activate Repo
|
||||
Repo --> Service : team
|
||||
deactivate Repo
|
||||
Service -> Service : onAfterCreate(team)
|
||||
|
||||
Service --> Sub : team
|
||||
deactivate Service
|
||||
|
||||
Sub --> Base : CommandResult.success("Team created")
|
||||
deactivate Sub
|
||||
|
||||
Base -> Base : handleResult → sender.sendMessage(green text)
|
||||
Base --> Player : "Team <name> created"
|
||||
deactivate Base
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,62 @@
|
||||
@startuml team-join-sequence
|
||||
title CR-Core — Player joins a public team (sequence diagram)
|
||||
|
||||
actor Player
|
||||
participant "BaseCommand\n(TeamCommand)" as Base
|
||||
participant "SubCommand\n(TeamJoinSub)" as Sub
|
||||
participant "TeamService" as Service
|
||||
participant "TeamRepository" as Repo
|
||||
participant "Team" as Team
|
||||
|
||||
Player -> Base : /team join <name>
|
||||
activate Base
|
||||
|
||||
Base -> Base : route args[0]="join" → TeamJoinSub
|
||||
Base -> Sub : execute(ctx)
|
||||
activate Sub
|
||||
|
||||
Sub -> Service : getTeamByName(name)
|
||||
activate Service
|
||||
Service -> Repo : findByName(name)
|
||||
activate Repo
|
||||
Repo --> Service : Optional<Team>
|
||||
deactivate Repo
|
||||
Service --> Sub : Optional<Team>
|
||||
deactivate Service
|
||||
|
||||
alt team not found
|
||||
Sub --> Base : CommandResult.failure("Team not found")
|
||||
else team found
|
||||
Sub -> Service : joinTeam(team.id, playerId)
|
||||
activate Service
|
||||
|
||||
Service -> Service : validateJoinable(team, playerId)
|
||||
alt team is PRIVATE
|
||||
Service --> Sub : throw TeamAccessException
|
||||
Sub --> Base : CommandResult.failure(ex.message)
|
||||
else player already in a team
|
||||
Service --> Sub : throw TeamAccessException
|
||||
Sub --> Base : CommandResult.failure(ex.message)
|
||||
else allowed
|
||||
Service -> Team : addMember(playerId)
|
||||
activate Team
|
||||
Team --> Service : member
|
||||
deactivate Team
|
||||
Service -> Repo : save(team)
|
||||
activate Repo
|
||||
Repo --> Service : team
|
||||
deactivate Repo
|
||||
Service -> Service : onMemberAdded(team, member)
|
||||
Service -> Service : onPlayerJoined(team, member)
|
||||
Service --> Sub : true
|
||||
Sub --> Base : CommandResult.success("Joined " + team.name)
|
||||
end
|
||||
deactivate Service
|
||||
end
|
||||
|
||||
deactivate Sub
|
||||
Base -> Base : handleResult → sender.sendMessage
|
||||
Base --> Player : reply
|
||||
deactivate Base
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,312 @@
|
||||
# Domaines fonctionnels
|
||||
|
||||
CR-Core est une librairie. Chaque domaine est autonome ; le plugin de jeu
|
||||
downstream pioche ce qu'il utilise.
|
||||
|
||||
---
|
||||
|
||||
## 1. Domaine Team
|
||||
|
||||
**Statut** : modèle + service implémenté (repository en mémoire). Overridable
|
||||
étape par étape via hooks et factories.
|
||||
|
||||
### Définition d'une équipe (`Team`)
|
||||
|
||||
| Attribut | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `UUID` | Identifiant interne unique, généré automatiquement. |
|
||||
| `name` | `String` | Nom lisible. Unique (case-insensitive). |
|
||||
| `tag` | `String` | Tag court (le « # »). Unique. Affiché entre `[# … ]`. |
|
||||
| `color` | `TeamColor` | Couleur associée (enum). |
|
||||
| `leaderId` | `UUID` | Identifiant du joueur **chef d'équipe**. |
|
||||
| `visibility` | `TeamVisibility` | `PUBLIC` (les joueurs peuvent rejoindre) ou `PRIVATE` (seul le chef ajoute). Défaut : `PRIVATE`. |
|
||||
| `members` | `Set<TeamMember>` | Ensemble des membres (inclut le chef). |
|
||||
|
||||
### Membre (`TeamMember`)
|
||||
|
||||
| Attribut | Type | Description |
|
||||
|---|---|---|
|
||||
| `playerId` | `UUID` | UUID du joueur Bukkit (= `id` de l'entité). |
|
||||
| `role` | `TeamRole` | `LEADER` ou `MEMBER`. |
|
||||
| `joinedAt` | `Instant` | Date d'entrée. |
|
||||
|
||||
### Enums
|
||||
|
||||
- **`TeamRole`** : `LEADER`, `MEMBER`.
|
||||
- **`TeamVisibility`** : `PUBLIC`, `PRIVATE`. Méthodes `isPublic()`, `isPrivate()`.
|
||||
- **`TeamColor`** : 16 valeurs, chacune expose `ChatColor`, `DyeColor`,
|
||||
`displayName`.
|
||||
|
||||
### Règles d'intégrité
|
||||
|
||||
1. Une équipe a **exactement un chef** à tout instant.
|
||||
2. Un joueur appartient à **au plus une équipe** (au sein du registre du
|
||||
plugin de jeu — chaque plugin a son propre registre).
|
||||
3. `name` et `tag` sont uniques (case-insensitive) dans le registre.
|
||||
4. Le chef ne peut pas être retiré sans `transferLeadership` préalable.
|
||||
5. Une équipe `PRIVATE` ne peut être rejointe que via `addMember` (action du
|
||||
chef) ; une équipe `PUBLIC` peut être rejointe via `joinTeam` (action du
|
||||
joueur lui-même).
|
||||
|
||||
### Opérations (`TeamService`)
|
||||
|
||||
| Opération | Description |
|
||||
|---|---|
|
||||
| `createTeam(name, tag, color, leaderId)` | Crée une équipe `PRIVATE` avec ce joueur comme chef. Échoue si nom/tag/joueur déjà pris. |
|
||||
| `createTeam(name, tag, color, leaderId, visibility)` | Surcharge : permet de créer directement en `PUBLIC` ou `PRIVATE`. |
|
||||
| `dissolveTeam(teamId)` | Supprime l'équipe. |
|
||||
| `addMember(teamId, playerId)` | **Action du chef** : ajoute un joueur comme `MEMBER` (marche en PUBLIC comme en PRIVATE). |
|
||||
| `removeMember(teamId, playerId)` | Retire un joueur (interdit sur le chef). |
|
||||
| `joinTeam(teamId, playerId)` | **Action du joueur** : auto-rejoindre une équipe `PUBLIC`. Lève `TeamAccessException` si la team est `PRIVATE` ou si le joueur est déjà dans une équipe. |
|
||||
| `transferLeadership(teamId, newLeaderId)` | Le nouveau chef doit déjà être membre. |
|
||||
| `setVisibility(teamId, visibility)` | Change la visibilité (typiquement appelée par le chef). |
|
||||
| `addScore(teamId, name, delta)` / `setScore` / `getScore` / `resetScore` / `resetAllScores` | Gestion des scores (voir section Scores). |
|
||||
| `getRankingByScore(name)` / `getGlobalRanking()` / `getTopRankingByScore(name, n)` / `getTopGlobalRanking(n)` | Classements (voir section Classements). |
|
||||
| `setSpawnPoint(teamId, loc)` / `clearSpawnPoint(teamId)` / `getSpawnPoint(teamId)` | Point de spawn (voir section Spawn). |
|
||||
| `getTeam` / `getTeamByName` / `getTeamByTag` / `getTeamOfPlayer` | Recherches. |
|
||||
| `getAllTeams()` | Toutes les équipes. |
|
||||
|
||||
### Scores
|
||||
|
||||
Chaque équipe porte une `Map<String, Integer>` de scores nommés. Le nom du
|
||||
score est libre (`"kills"`, `"objectives"`, `"global"`, …). Un jeu qui n'a
|
||||
besoin que d'un seul score peut utiliser un nom unique (typiquement
|
||||
`"global"`).
|
||||
|
||||
| Opération sur `Team` | Description |
|
||||
|---|---|
|
||||
| `getScore(name)` | Valeur courante (0 si jamais set). |
|
||||
| `hasScore(name)` | `true` si le score a été initialisé au moins une fois. |
|
||||
| `getScores()` | Map immuable de tous les scores. |
|
||||
| `getTotalScore()` | Somme de tous les scores (utilisée pour le classement global). |
|
||||
| `addScore(name, delta)` | Incrémente (ou décrémente avec un delta négatif), renvoie la nouvelle valeur. |
|
||||
| `setScore(name, value)` | Affecte une valeur absolue. |
|
||||
| `resetScore(name)` | Supprime un score (revient à 0). |
|
||||
| `resetAllScores()` | Vide tous les scores. |
|
||||
|
||||
Côté `TeamService` : mêmes opérations préfixées du `teamId` (`addScore(teamId,
|
||||
name, delta)` etc.). Hook `onScoreChanged(team, name, oldValue, newValue)`
|
||||
appelé uniquement quand la valeur change réellement.
|
||||
|
||||
### Classements (rankings)
|
||||
|
||||
Le service expose deux types de classements :
|
||||
|
||||
| Méthode | Description |
|
||||
|---|---|
|
||||
| `getRankingByScore(name)` | Classement par un score précis (descendant). |
|
||||
| `getGlobalRanking()` | Classement par la somme des scores de chaque équipe. |
|
||||
| `getTopRankingByScore(name, n)` | Top N par score. |
|
||||
| `getTopGlobalRanking(n)` | Top N global. |
|
||||
|
||||
Le résultat est une `List<TeamRanking>` (record Java 16) avec `rank` (1-based),
|
||||
`team` et `score`. Tiebreaker : ordre alphabétique sur le nom de l'équipe
|
||||
(insensible à la casse).
|
||||
|
||||
La méthode `protected rank(ToIntFunction<Team>)` est exposée pour permettre à
|
||||
une sous-classe de créer ses propres classements (ex. score pondéré, ratio
|
||||
kills/deaths, score borné). La factory `newRanking(rank, team, score)`
|
||||
permet de retourner un type custom étendant `TeamRanking` (les records étant
|
||||
final, on peut wrapper plutôt qu'étendre).
|
||||
|
||||
### Spawn point
|
||||
|
||||
Chaque équipe peut avoir un `Location` Bukkit comme point de spawn. Optionnel
|
||||
(défaut : pas de spawn défini).
|
||||
|
||||
| Opération sur `Team` | Description |
|
||||
|---|---|
|
||||
| `getSpawnPoint()` | `Optional<Location>` (clonée — modifier l'objet retourné n'affecte pas l'équipe). |
|
||||
| `hasSpawnPoint()` | `true` si un spawn a été défini. |
|
||||
| `setSpawnPoint(location)` | Définit le spawn (cloné à l'entrée). `null` accepté = clear. |
|
||||
| `clearSpawnPoint()` | Supprime le spawn. |
|
||||
|
||||
Côté `TeamService` : `setSpawnPoint(teamId, location)`,
|
||||
`clearSpawnPoint(teamId)`, `getSpawnPoint(teamId)`. Hook
|
||||
`onSpawnPointChanged(team, oldLocation, newLocation)`.
|
||||
|
||||
> **Persistance** : `Location` référence un `World` Bukkit, donc ce n'est pas
|
||||
> trivialement sérialisable. Pour l'instant on stocke en mémoire ; quand on
|
||||
> branchera une persistance fichier, on utilisera `Location.serialize()` /
|
||||
> `deserialize()` de l'API Bukkit (`ConfigurationSerializable`).
|
||||
|
||||
### Hooks d'override (sur `TeamServiceImpl`)
|
||||
|
||||
| Hook | Quand |
|
||||
|---|---|
|
||||
| `newTeam(id, name, tag, color, leaderId, visibility)` | Factory — instancier une sous-classe de `Team`. |
|
||||
| `newRanking(rank, team, score)` | Factory — wrapper / sous-classe de `TeamRanking`. |
|
||||
| `rank(scoreFn)` | Logique de tri du classement (override pour pondération, tiebreaker custom, etc.). |
|
||||
| `validateName(name)` | Avant création — règles custom sur le nom. |
|
||||
| `validateTag(tag)` | Avant création — règles custom sur le tag. |
|
||||
| `validateLeader(leaderId)` | Avant création — règles custom sur l'éligibilité du chef. |
|
||||
| `validateJoinable(team, playerId)` | Avant `joinTeam` — règles custom. |
|
||||
| `onBeforeSave(team)` | Juste avant le `repository.save`. |
|
||||
| `onAfterCreate(team)` | Juste après une création réussie. |
|
||||
| `onBeforeDissolve(team)` / `onAfterDissolve(team)` | Autour de la dissolution. |
|
||||
| `onMemberAdded(team, member)` | Après ajout d'un membre (via `addMember` OU `joinTeam`). |
|
||||
| `onMemberRemoved(team, playerId)` | Après retrait d'un membre. |
|
||||
| `onPlayerJoined(team, member)` | Spécifique à `joinTeam` (en plus de `onMemberAdded`). |
|
||||
| `onLeadershipTransferred(team, oldId, newId)` | Après transfert. |
|
||||
| `onVisibilityChanged(team, oldV, newV)` | Après changement de visibilité. |
|
||||
| `onScoreChanged(team, scoreName, oldV, newV)` | Après changement effectif d'un score. |
|
||||
| `onSpawnPointChanged(team, oldLoc, newLoc)` | Après changement du point de spawn. |
|
||||
|
||||
Côté `Team`, factory `newMember(playerId, role)` pour utiliser un `TeamMember`
|
||||
custom dans une sous-classe.
|
||||
|
||||
### Exceptions
|
||||
|
||||
- `TeamException` (base, `RuntimeException`).
|
||||
- `TeamAlreadyExistsException` : nom, tag ou joueur déjà pris.
|
||||
- `TeamNotFoundException` : équipe introuvable.
|
||||
- `TeamAccessException` : auto-join refusé (team `PRIVATE`, joueur déjà dans
|
||||
une équipe, ou règle custom dans `validateJoinable`).
|
||||
|
||||
### Persistance
|
||||
|
||||
`InMemoryTeamRepository` (Map<UUID, Team>) par défaut. Le contrat
|
||||
`TeamRepository extends Repository<Team>` permet de brancher YAML / SQLite /
|
||||
Postgres sans toucher au service.
|
||||
|
||||
### Diagrammes
|
||||
|
||||
- Classes : [team-class-diagram.puml](diagrams/team-class-diagram.puml)
|
||||
- Séquence création : [team-create-sequence.puml](diagrams/team-create-sequence.puml)
|
||||
- Séquence auto-join : [team-join-sequence.puml](diagrams/team-join-sequence.puml)
|
||||
- Activité création : [team-create-activity.puml](diagrams/team-create-activity.puml)
|
||||
|
||||
---
|
||||
|
||||
## 2. Profils joueurs et scores individuels
|
||||
|
||||
**Statut** : modèle + service implémenté (en mémoire). Symétrique au domaine
|
||||
Team pour tout ce qui touche aux scores et classements.
|
||||
|
||||
### Pourquoi un domaine séparé
|
||||
|
||||
Les scores joueur vivent en dehors de l'équipe : un joueur peut changer
|
||||
d'équipe, en quitter une, en rejoindre une autre — son profil et ses scores
|
||||
persistent. Le domaine `player` est indépendant du domaine `team` ; libre au
|
||||
plugin de jeu de les combiner (ex. score d'équipe = somme des scores des
|
||||
membres).
|
||||
|
||||
### `PlayerProfile`
|
||||
|
||||
| Attribut | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `UUID` | UUID Bukkit du joueur (= `id` de l'entité). |
|
||||
| `scores` | `Map<String, Integer>` | Scores nommés (`"kills"`, `"deaths"`, `"global"`, …). |
|
||||
|
||||
`PlayerProfile implements ScoreHolder` — la même interface que `Team`. Toutes
|
||||
les méthodes de scoring (`getScore`, `addScore`, `setScore`, `resetScore`,
|
||||
`resetAllScores`, `getTotalScore`, `getScores`, `hasScore`) sont identiques
|
||||
à celles de `Team`.
|
||||
|
||||
### `PlayerProfileService`
|
||||
|
||||
| Opération | Description |
|
||||
|---|---|
|
||||
| `getOrCreateProfile(playerId)` | Retourne le profil existant ou en crée un. Utilisé en interne par les méthodes de scoring (auto-création à la première écriture). |
|
||||
| `getProfile(playerId)` | `Optional<PlayerProfile>` sans création. |
|
||||
| `deleteProfile(playerId)` | Supprime un profil. |
|
||||
| `getAllProfiles()` | Tous les profils. |
|
||||
| `addScore` / `setScore` / `getScore` / `resetScore` / `resetAllScores` | Identiques au service Team mais par `playerId`. |
|
||||
| `getRankingByScore(name)` / `getGlobalRanking()` / `getTopRankingByScore(name, n)` / `getTopGlobalRanking(n)` | Classements de joueurs. |
|
||||
|
||||
### `PlayerRanking`
|
||||
|
||||
Record Java 16 : `record PlayerRanking(int rank, PlayerProfile profile, int score)`.
|
||||
Mêmes règles que `TeamRanking` (rank 1-based, tri descendant, tiebreaker par
|
||||
UUID pour rester déterministe).
|
||||
|
||||
### Hooks d'override (sur `PlayerProfileServiceImpl`)
|
||||
|
||||
| Hook | Quand |
|
||||
|---|---|
|
||||
| `newProfile(playerId)` | Factory — instancier une sous-classe de `PlayerProfile`. |
|
||||
| `newRanking(rank, profile, score)` | Factory — sous-classe de `PlayerRanking`. |
|
||||
| `rank(scoreFn)` | Logique de tri (override pour pondération, tiebreaker custom, etc.). |
|
||||
| `onProfileCreated(profile)` | Après création (lazy ou explicite). |
|
||||
| `onProfileDeleted(profile)` | Après suppression. |
|
||||
| `onScoreChanged(profile, name, oldV, newV)` | Après changement effectif d'un score. |
|
||||
|
||||
### Exceptions
|
||||
|
||||
- `PlayerException` (base, `RuntimeException`).
|
||||
- `PlayerProfileNotFoundException` : profil introuvable (peu utilisé côté
|
||||
service car la plupart des opérations auto-créent).
|
||||
|
||||
### Diagrammes
|
||||
|
||||
- Classes : [player-class-diagram.puml](diagrams/player-class-diagram.puml)
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **`Command`** (interface) — contrat partagé : `getName()`, `getAliases()`,
|
||||
`getPermission()`, `isPlayerOnly()`, `getDescription()`, `execute(ctx)`,
|
||||
`tabComplete(sender, argIndex, partial)`, `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.
|
||||
|
||||
### Types d'arguments (`ArgumentTypes`)
|
||||
|
||||
| Constante / factory | Type produit | Tab-complete |
|
||||
|---|---|---|
|
||||
| `STRING` | `String` | (aucun) |
|
||||
| `INTEGER` | `Integer` | (aucun) |
|
||||
| `DOUBLE` | `Double` | (aucun) |
|
||||
| `BOOLEAN` | `Boolean` | `true` / `false` |
|
||||
| `ONLINE_PLAYER` | `Player` | joueurs connectés |
|
||||
| `enumOf(Enum.class)` | l'enum | toutes les valeurs |
|
||||
| `choice("a", "b", …)` | `String` | les choix |
|
||||
|
||||
Un `ArgumentType<T>` custom = une classe qui implémente `parse(String): T` et
|
||||
optionnellement `suggestions(sender, partial): List<String>`.
|
||||
|
||||
### Résultats (`CommandResult`)
|
||||
|
||||
`SUCCESS`, `FAILURE`, `INVALID_USAGE`, `NO_PERMISSION`, `PLAYER_ONLY` — chacun
|
||||
avec un message optionnel. Factories statiques `CommandResult.success(...)`,
|
||||
`failure(...)`, `invalidUsage(...)`.
|
||||
|
||||
Rendus automatiquement par `BaseCommand.handleResult` avec un code couleur
|
||||
standard (vert succès, rouge erreur). Override `handleResult` pour personnaliser.
|
||||
|
||||
### Contexte (`CommandContext`)
|
||||
|
||||
Wrapper passé à `execute()` :
|
||||
|
||||
- `getSender()`, `isPlayer()`, `getPlayer()`, `requirePlayer()`
|
||||
- `get(name)` — récupère un argument parsé typé (`String name = ctx.get("name")`)
|
||||
- `getOptional(name)` / `has(name)`
|
||||
- `reply(message)` — raccourci `sender.sendMessage`
|
||||
|
||||
### Exemple complet
|
||||
|
||||
Voir [setup.md](setup.md#utilisation-depuis-un-plugin-de-jeu).
|
||||
|
||||
### Diagramme
|
||||
|
||||
- Classes : [command-class-diagram.puml](diagrams/command-class-diagram.puml)
|
||||
|
||||
---
|
||||
|
||||
## Backlog / idées de futurs domaines
|
||||
|
||||
_(à remplir — ex. score, profil joueur, gestion d'event/round, ...)_
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
# Setup technique
|
||||
|
||||
## Stack
|
||||
|
||||
- **Type** : librairie Java (`jar`) — pas un plugin Bukkit
|
||||
- **artifactId Maven** : `CR-Core`
|
||||
- **Build** : Maven
|
||||
- **Java** : 16
|
||||
- **API serveur (provided)** : Paper 1.16.5
|
||||
(`com.destroystokyo.paper:paper-api:1.16.5-R0.1-SNAPSHOT`)
|
||||
- **Package racine** : `fr.luc.crcore`
|
||||
|
||||
## Dépôts Maven
|
||||
|
||||
- `papermc` — https://repo.papermc.io/repository/maven-public/
|
||||
- `spigot-repo` — https://hub.spigotmc.org/nexus/content/repositories/snapshots/
|
||||
- `sonatype` — https://oss.sonatype.org/content/groups/public/
|
||||
|
||||
## Build & install local
|
||||
|
||||
```bash
|
||||
mvn clean install
|
||||
```
|
||||
|
||||
Cela 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
|
||||
|
||||
Dans le `pom.xml` du plugin de jeu :
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>fr.luc</groupId>
|
||||
<artifactId>CR-Core</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
> 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
|
||||
> `<relocation>` dans le `maven-shade-plugin` du plugin de jeu pour éviter les
|
||||
> conflits si plusieurs plugins shadent CR-Core sur le même serveur.
|
||||
|
||||
Côté code du plugin de jeu :
|
||||
|
||||
```java
|
||||
public final class MyGamePlugin extends JavaPlugin {
|
||||
|
||||
private TeamService teamService;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
// Instancie le service team (chaque plugin a son propre registre)
|
||||
this.teamService = new TeamServiceImpl(new InMemoryTeamRepository());
|
||||
|
||||
// Enregistre une commande basée sur le framework de CR-Core
|
||||
getCommand("team").setExecutor(new TeamCommand(teamService));
|
||||
getCommand("team").setTabCompleter(new TeamCommand(teamService));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exemple minimal de commande :
|
||||
|
||||
```java
|
||||
public class TeamCommand extends BaseCommand {
|
||||
|
||||
public TeamCommand(TeamService service) {
|
||||
super("team", "teams", "t");
|
||||
description("Manage teams");
|
||||
addSubCommand(new TeamCreateSub(service));
|
||||
addSubCommand(new TeamInfoSub(service));
|
||||
}
|
||||
}
|
||||
|
||||
public class TeamCreateSub extends SubCommand {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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 :
|
||||
|
||||
```java
|
||||
public class LoggingTeamService extends TeamServiceImpl {
|
||||
|
||||
public LoggingTeamService(TeamRepository repo) { super(repo); }
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Arborescence du projet
|
||||
|
||||
```
|
||||
CitesPlugin/ # dossier IntelliJ (renommer plus tard si voulu)
|
||||
├── pom.xml
|
||||
├── GEMINI.md
|
||||
├── docs/ # source de vérité
|
||||
│ ├── 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
|
||||
└── 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
|
||||
│ ├── CommandContext.java
|
||||
│ ├── CommandResult.java
|
||||
│ ├── CommandException.java
|
||||
│ ├── ArgumentType.java
|
||||
│ ├── ArgumentTypes.java # STRING, INTEGER, BOOLEAN, ONLINE_PLAYER, enumOf, choice
|
||||
│ └── ArgumentDef.java # package-private
|
||||
├── team/ # domaine 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
|
||||
│ ├── 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
|
||||
├── InMemoryPlayerProfileRepository.java
|
||||
├── PlayerProfileService.java # interface
|
||||
├── PlayerProfileServiceImpl.java # impl avec hooks overridables
|
||||
├── PlayerException.java
|
||||
└── PlayerProfileNotFoundException.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.
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>fr.luc</groupId>
|
||||
<artifactId>CR-Core</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>CR-Core</name>
|
||||
<description>Reusable core library for CR Minecraft game plugins (teams, commands, common abstractions).</description>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>16</maven.compiler.source>
|
||||
<maven.compiler.target>16</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>papermc</id>
|
||||
<url>https://repo.papermc.io/repository/maven-public/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>spigot-repo</id>
|
||||
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>sonatype</id>
|
||||
<url>https://oss.sonatype.org/content/groups/public/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.destroystokyo.paper</groupId>
|
||||
<artifactId>paper-api</artifactId>
|
||||
<version>1.16.5-R0.1-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${project.artifactId}-${project.version}</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<source>${maven.compiler.source}</source>
|
||||
<target>${maven.compiler.target}</target>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,133 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
import org.bukkit.command.CommandSender;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public abstract class AbstractCommand implements Command {
|
||||
|
||||
private final String name;
|
||||
private final List<String> aliases = new ArrayList<>();
|
||||
private final List<ArgumentDef> arguments = new ArrayList<>();
|
||||
private String permission;
|
||||
private boolean playerOnly;
|
||||
private String description = "";
|
||||
private String usage;
|
||||
|
||||
protected AbstractCommand(String name, String... aliases) {
|
||||
this.name = Objects.requireNonNull(name, "name").toLowerCase();
|
||||
for (String alias : aliases) {
|
||||
this.aliases.add(alias.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public final String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final List<String> 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;
|
||||
}
|
||||
|
||||
public final String getUsage() {
|
||||
return usage != null ? usage : buildDefaultUsage();
|
||||
}
|
||||
|
||||
protected final void addAlias(String... aliases) {
|
||||
for (String alias : aliases) {
|
||||
this.aliases.add(alias.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
protected final void permission(String permission) {
|
||||
this.permission = permission;
|
||||
}
|
||||
|
||||
protected final void playerOnly() {
|
||||
this.playerOnly = true;
|
||||
}
|
||||
|
||||
protected final void description(String description) {
|
||||
this.description = Objects.requireNonNullElse(description, "");
|
||||
}
|
||||
|
||||
protected final void usage(String usage) {
|
||||
this.usage = usage;
|
||||
}
|
||||
|
||||
protected final void argument(String name, ArgumentType<?> type) {
|
||||
arguments.add(new ArgumentDef(name, type, true));
|
||||
}
|
||||
|
||||
protected final void optionalArgument(String name, ArgumentType<?> type) {
|
||||
arguments.add(new ArgumentDef(name, type, false));
|
||||
}
|
||||
|
||||
public final int getRequiredArgumentCount() {
|
||||
return (int) arguments.stream().filter(ArgumentDef::isRequired).count();
|
||||
}
|
||||
|
||||
public final int getTotalArgumentCount() {
|
||||
return arguments.size();
|
||||
}
|
||||
|
||||
final List<ArgumentDef> getArgumentDefs() {
|
||||
return arguments;
|
||||
}
|
||||
|
||||
protected String buildDefaultUsage() {
|
||||
StringBuilder sb = new StringBuilder("/").append(name);
|
||||
for (ArgumentDef def : arguments) {
|
||||
sb.append(' ');
|
||||
sb.append(def.isRequired() ? '<' : '[');
|
||||
sb.append(def.getName());
|
||||
sb.append(def.isRequired() ? '>' : ']');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> tabComplete(CommandSender sender, int argIndex, String partial) {
|
||||
if (argIndex >= 0 && argIndex < arguments.size()) {
|
||||
return arguments.get(argIndex).getType().suggestions(sender, partial);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
protected CommandContext buildContext(CommandSender sender, String label, String[] subArgs) {
|
||||
Map<String, Object> parsed = new LinkedHashMap<>();
|
||||
int max = Math.min(subArgs.length, arguments.size());
|
||||
for (int i = 0; i < max; i++) {
|
||||
ArgumentDef def = arguments.get(i);
|
||||
try {
|
||||
Object value = def.getType().parse(subArgs[i]);
|
||||
parsed.put(def.getName(), value);
|
||||
} catch (CommandException ex) {
|
||||
throw new CommandException("Argument '" + def.getName() + "': " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
return new CommandContext(sender, label, subArgs, parsed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
final class ArgumentDef {
|
||||
|
||||
private final String name;
|
||||
private final ArgumentType<?> type;
|
||||
private final boolean required;
|
||||
|
||||
ArgumentDef(String name, ArgumentType<?> type, boolean required) {
|
||||
this.name = Objects.requireNonNull(name, "name");
|
||||
this.type = Objects.requireNonNull(type, "type");
|
||||
this.required = required;
|
||||
}
|
||||
|
||||
String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
ArgumentType<?> getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
boolean isRequired() {
|
||||
return required;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
import org.bukkit.command.CommandSender;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public interface ArgumentType<T> {
|
||||
|
||||
T parse(String input) throws CommandException;
|
||||
|
||||
default List<String> suggestions(CommandSender sender, String partial) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class ArgumentTypes {
|
||||
|
||||
private ArgumentTypes() {
|
||||
}
|
||||
|
||||
public static final ArgumentType<String> STRING = new ArgumentType<>() {
|
||||
@Override
|
||||
public String parse(String input) {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
public static final ArgumentType<Integer> INTEGER = new ArgumentType<>() {
|
||||
@Override
|
||||
public Integer parse(String input) {
|
||||
try {
|
||||
return Integer.parseInt(input);
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new CommandException("Invalid integer: " + input);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static final ArgumentType<Double> DOUBLE = new ArgumentType<>() {
|
||||
@Override
|
||||
public Double parse(String input) {
|
||||
try {
|
||||
return Double.parseDouble(input);
|
||||
} catch (NumberFormatException ex) {
|
||||
throw new CommandException("Invalid number: " + input);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static final ArgumentType<Boolean> BOOLEAN = new ArgumentType<>() {
|
||||
@Override
|
||||
public Boolean parse(String input) {
|
||||
return switch (input.toLowerCase()) {
|
||||
case "true", "yes", "y", "1", "on" -> true;
|
||||
case "false", "no", "n", "0", "off" -> false;
|
||||
default -> throw new CommandException("Invalid boolean: " + input);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestions(CommandSender sender, String partial) {
|
||||
return filter(List.of("true", "false"), partial);
|
||||
}
|
||||
};
|
||||
|
||||
public static final ArgumentType<Player> ONLINE_PLAYER = new ArgumentType<>() {
|
||||
@Override
|
||||
public Player parse(String input) {
|
||||
Player player = Bukkit.getPlayerExact(input);
|
||||
if (player == null) {
|
||||
throw new CommandException("Player not found: " + input);
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestions(CommandSender sender, String partial) {
|
||||
return filter(Bukkit.getOnlinePlayers().stream()
|
||||
.map(Player::getName)
|
||||
.collect(Collectors.toList()), partial);
|
||||
}
|
||||
};
|
||||
|
||||
public static <E extends Enum<E>> ArgumentType<E> enumOf(Class<E> type) {
|
||||
Objects.requireNonNull(type, "type");
|
||||
return new ArgumentType<>() {
|
||||
@Override
|
||||
public E parse(String input) {
|
||||
try {
|
||||
return Enum.valueOf(type, input.toUpperCase());
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new CommandException(
|
||||
"Invalid value for " + type.getSimpleName() + ": " + input);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestions(CommandSender sender, String partial) {
|
||||
return filter(Arrays.stream(type.getEnumConstants())
|
||||
.map(Enum::name)
|
||||
.collect(Collectors.toList()), partial);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static ArgumentType<String> choice(String... choices) {
|
||||
Objects.requireNonNull(choices, "choices");
|
||||
List<String> list = List.of(choices);
|
||||
return new ArgumentType<>() {
|
||||
@Override
|
||||
public String parse(String input) {
|
||||
if (!list.contains(input)) {
|
||||
throw new CommandException("Invalid choice: " + input + " (expected: " + list + ")");
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> suggestions(CommandSender sender, String partial) {
|
||||
return filter(list, partial);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static List<String> filter(List<String> source, String partial) {
|
||||
String lower = partial.toLowerCase();
|
||||
return source.stream()
|
||||
.filter(value -> value.toLowerCase().startsWith(lower))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
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;
|
||||
|
||||
public abstract class BaseCommand extends AbstractCommand
|
||||
implements CommandExecutor, TabCompleter {
|
||||
|
||||
private final Map<String, SubCommand> subCommandsByName = new LinkedHashMap<>();
|
||||
private final Map<String, SubCommand> subCommandsByAlias = new HashMap<>();
|
||||
|
||||
protected BaseCommand(String name, String... aliases) {
|
||||
super(name, aliases);
|
||||
}
|
||||
|
||||
protected final void addSubCommand(SubCommand sub) {
|
||||
Objects.requireNonNull(sub, "sub");
|
||||
if (subCommandsByName.containsKey(sub.getName())) {
|
||||
throw new IllegalStateException("Sub-command already registered: " + sub.getName());
|
||||
}
|
||||
subCommandsByName.put(sub.getName(), sub);
|
||||
for (String alias : sub.getAliases()) {
|
||||
subCommandsByAlias.put(alias, sub);
|
||||
}
|
||||
}
|
||||
|
||||
public final Collection<SubCommand> getSubCommands() {
|
||||
return Collections.unmodifiableCollection(subCommandsByName.values());
|
||||
}
|
||||
|
||||
public final Optional<SubCommand> findSubCommand(String label) {
|
||||
if (label == null) return Optional.empty();
|
||||
String lc = label.toLowerCase();
|
||||
SubCommand sub = subCommandsByName.get(lc);
|
||||
if (sub != null) return Optional.of(sub);
|
||||
return Optional.ofNullable(subCommandsByAlias.get(lc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when no sub-command matches the first argument. Override for custom
|
||||
* fallback behavior. Default: lists available sub-commands.
|
||||
*/
|
||||
protected CommandResult execute(CommandContext context) {
|
||||
if (subCommandsByName.isEmpty()) {
|
||||
return CommandResult.invalidUsage("No action specified.");
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(ChatColor.YELLOW + "Available sub-commands:");
|
||||
for (SubCommand sub : subCommandsByName.values()) {
|
||||
sb.append('\n').append(ChatColor.GRAY).append(" - ")
|
||||
.append(ChatColor.WHITE).append(sub.getName());
|
||||
if (!sub.getDescription().isEmpty()) {
|
||||
sb.append(ChatColor.GRAY).append(" — ").append(sub.getDescription());
|
||||
}
|
||||
}
|
||||
context.reply(sb.toString());
|
||||
return CommandResult.success();
|
||||
}
|
||||
|
||||
@Override
|
||||
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());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final List<String> 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<String> names = new ArrayList<>();
|
||||
for (SubCommand sub : subCommandsByName.values()) {
|
||||
if (sub.getPermission() != null && !sender.hasPermission(sub.getPermission())) {
|
||||
continue;
|
||||
}
|
||||
names.add(sub.getName());
|
||||
names.addAll(sub.getAliases());
|
||||
}
|
||||
return names.stream()
|
||||
.filter(n -> n.startsWith(partial))
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
Optional<SubCommand> subOpt = findSubCommand(args[0]);
|
||||
if (subOpt.isEmpty()) return Collections.emptyList();
|
||||
SubCommand sub = subOpt.get();
|
||||
if (sub.getPermission() != null && !sender.hasPermission(sub.getPermission())) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
int argIndex = args.length - 2;
|
||||
String partial = args[args.length - 1];
|
||||
return sub.tabComplete(sender, argIndex, partial);
|
||||
}
|
||||
|
||||
protected boolean checkAccess(CommandSender sender, Command target) {
|
||||
if (target.getPermission() != null && !sender.hasPermission(target.getPermission())) {
|
||||
sender.sendMessage(ChatColor.RED + "You don't have permission.");
|
||||
return false;
|
||||
}
|
||||
if (target.isPlayerOnly() && !(sender instanceof Player)) {
|
||||
sender.sendMessage(ChatColor.RED + "Only players can use this command.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected String buildUsageFor(String label, SubCommand sub) {
|
||||
StringBuilder sb = new StringBuilder("/").append(label).append(' ').append(sub.getName());
|
||||
for (ArgumentDef def : sub.getArgumentDefs()) {
|
||||
sb.append(' ');
|
||||
sb.append(def.isRequired() ? '<' : '[');
|
||||
sb.append(def.getName());
|
||||
sb.append(def.isRequired() ? '>' : ']');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
protected void handleResult(CommandSender sender, CommandResult result) {
|
||||
switch (result.getType()) {
|
||||
case SUCCESS -> {
|
||||
if (result.getMessage() != null) {
|
||||
sender.sendMessage(ChatColor.GREEN + result.getMessage());
|
||||
}
|
||||
}
|
||||
case FAILURE -> sender.sendMessage(ChatColor.RED +
|
||||
(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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
import org.bukkit.command.CommandSender;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface Command {
|
||||
|
||||
String getName();
|
||||
|
||||
List<String> getAliases();
|
||||
|
||||
String getPermission();
|
||||
|
||||
boolean isPlayerOnly();
|
||||
|
||||
String getDescription();
|
||||
|
||||
CommandResult execute(CommandContext context);
|
||||
|
||||
List<String> tabComplete(CommandSender sender, int argIndex, String partial);
|
||||
|
||||
default boolean matches(String label) {
|
||||
if (label == null) return false;
|
||||
String lc = label.toLowerCase();
|
||||
if (getName().equalsIgnoreCase(lc)) return true;
|
||||
for (String alias : getAliases()) {
|
||||
if (alias.equalsIgnoreCase(lc)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
public class CommandContext {
|
||||
|
||||
private final CommandSender sender;
|
||||
private final String label;
|
||||
private final String[] rawArgs;
|
||||
private final Map<String, Object> parsedArgs;
|
||||
|
||||
public CommandContext(CommandSender sender, String label, String[] rawArgs,
|
||||
Map<String, Object> parsedArgs) {
|
||||
this.sender = Objects.requireNonNull(sender, "sender");
|
||||
this.label = Objects.requireNonNull(label, "label");
|
||||
this.rawArgs = Objects.requireNonNull(rawArgs, "rawArgs");
|
||||
this.parsedArgs = Collections.unmodifiableMap(
|
||||
Objects.requireNonNull(parsedArgs, "parsedArgs"));
|
||||
}
|
||||
|
||||
public CommandSender getSender() {
|
||||
return sender;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
public String[] getRawArgs() {
|
||||
return rawArgs;
|
||||
}
|
||||
|
||||
public boolean isPlayer() {
|
||||
return sender instanceof Player;
|
||||
}
|
||||
|
||||
public Optional<Player> getPlayer() {
|
||||
return sender instanceof Player ? Optional.of((Player) sender) : Optional.empty();
|
||||
}
|
||||
|
||||
public Player requirePlayer() {
|
||||
if (!(sender instanceof Player player)) {
|
||||
throw new CommandException("This command can only be used by a player.");
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T get(String name) {
|
||||
Objects.requireNonNull(name, "name");
|
||||
Object value = parsedArgs.get(name);
|
||||
if (value == null) {
|
||||
throw new CommandException("Missing argument: " + name);
|
||||
}
|
||||
return (T) value;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> Optional<T> getOptional(String name) {
|
||||
return Optional.ofNullable((T) parsedArgs.get(name));
|
||||
}
|
||||
|
||||
public boolean has(String name) {
|
||||
return parsedArgs.containsKey(name);
|
||||
}
|
||||
|
||||
public void reply(String message) {
|
||||
sender.sendMessage(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
public class CommandException extends RuntimeException {
|
||||
|
||||
public CommandException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public CommandException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
public final class CommandResult {
|
||||
|
||||
public enum Type {
|
||||
SUCCESS,
|
||||
FAILURE,
|
||||
INVALID_USAGE,
|
||||
NO_PERMISSION,
|
||||
PLAYER_ONLY
|
||||
}
|
||||
|
||||
private final Type type;
|
||||
private final String message;
|
||||
|
||||
private CommandResult(Type type, String message) {
|
||||
this.type = type;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public boolean isSuccess() {
|
||||
return type == Type.SUCCESS;
|
||||
}
|
||||
|
||||
public static CommandResult success() {
|
||||
return new CommandResult(Type.SUCCESS, null);
|
||||
}
|
||||
|
||||
public static CommandResult success(String message) {
|
||||
return new CommandResult(Type.SUCCESS, message);
|
||||
}
|
||||
|
||||
public static CommandResult failure(String message) {
|
||||
return new CommandResult(Type.FAILURE, message);
|
||||
}
|
||||
|
||||
public static CommandResult invalidUsage() {
|
||||
return new CommandResult(Type.INVALID_USAGE, null);
|
||||
}
|
||||
|
||||
public static CommandResult invalidUsage(String message) {
|
||||
return new CommandResult(Type.INVALID_USAGE, message);
|
||||
}
|
||||
|
||||
public static CommandResult noPermission() {
|
||||
return new CommandResult(Type.NO_PERMISSION, null);
|
||||
}
|
||||
|
||||
public static CommandResult playerOnly() {
|
||||
return new CommandResult(Type.PLAYER_ONLY, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package fr.luc.crcore.command;
|
||||
|
||||
public abstract class SubCommand extends AbstractCommand {
|
||||
|
||||
protected SubCommand(String name, String... aliases) {
|
||||
super(name, aliases);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package fr.luc.crcore.common;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public abstract class AbstractEntity implements Identifiable {
|
||||
|
||||
private final UUID id;
|
||||
|
||||
protected AbstractEntity(UUID id) {
|
||||
this.id = Objects.requireNonNull(id, "id");
|
||||
}
|
||||
|
||||
@Override
|
||||
public final UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
if (other == null || getClass() != other.getClass()) return false;
|
||||
return id.equals(((AbstractEntity) other).id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final int hashCode() {
|
||||
return id.hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package fr.luc.crcore.common;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface Identifiable {
|
||||
|
||||
UUID getId();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package fr.luc.crcore.common;
|
||||
|
||||
public interface Named {
|
||||
|
||||
String getName();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package fr.luc.crcore.common;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface Repository<T extends Identifiable> {
|
||||
|
||||
T save(T entity);
|
||||
|
||||
Optional<T> findById(UUID id);
|
||||
|
||||
Collection<T> findAll();
|
||||
|
||||
boolean delete(UUID id);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package fr.luc.crcore.common;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface ScoreHolder {
|
||||
|
||||
int getScore(String scoreName);
|
||||
|
||||
boolean hasScore(String scoreName);
|
||||
|
||||
Map<String, Integer> getScores();
|
||||
|
||||
int getTotalScore();
|
||||
|
||||
int addScore(String scoreName, int delta);
|
||||
|
||||
int setScore(String scoreName, int value);
|
||||
|
||||
boolean resetScore(String scoreName);
|
||||
|
||||
void resetAllScores();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package fr.luc.crcore.player;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class InMemoryPlayerProfileRepository implements PlayerProfileRepository {
|
||||
|
||||
private final Map<UUID, PlayerProfile> profiles = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
public PlayerProfile save(PlayerProfile profile) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
profiles.put(profile.getId(), profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlayerProfile> findById(UUID id) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
return Optional.ofNullable(profiles.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<PlayerProfile> findAll() {
|
||||
return Collections.unmodifiableCollection(profiles.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(UUID id) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
return profiles.remove(id) != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package fr.luc.crcore.player;
|
||||
|
||||
public class PlayerException extends RuntimeException {
|
||||
|
||||
public PlayerException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public PlayerException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package fr.luc.crcore.player;
|
||||
|
||||
import fr.luc.crcore.common.AbstractEntity;
|
||||
import fr.luc.crcore.common.ScoreHolder;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public class PlayerProfile extends AbstractEntity implements ScoreHolder {
|
||||
|
||||
private final Map<String, Integer> scores;
|
||||
|
||||
public PlayerProfile(UUID playerId) {
|
||||
super(playerId);
|
||||
this.scores = new HashMap<>();
|
||||
}
|
||||
|
||||
public UUID getPlayerId() {
|
||||
return getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScore(String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
return scores.getOrDefault(scoreName, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasScore(String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
return scores.containsKey(scoreName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Integer> getScores() {
|
||||
return Collections.unmodifiableMap(scores);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTotalScore() {
|
||||
return scores.values().stream().mapToInt(Integer::intValue).sum();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int addScore(String scoreName, int delta) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
int newValue = getScore(scoreName) + delta;
|
||||
scores.put(scoreName, newValue);
|
||||
return newValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int setScore(String scoreName, int value) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
scores.put(scoreName, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean resetScore(String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
return scores.remove(scoreName) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetAllScores() {
|
||||
scores.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package fr.luc.crcore.player;
|
||||
|
||||
public class PlayerProfileNotFoundException extends PlayerException {
|
||||
|
||||
public PlayerProfileNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package fr.luc.crcore.player;
|
||||
|
||||
import fr.luc.crcore.common.Repository;
|
||||
|
||||
public interface PlayerProfileRepository extends Repository<PlayerProfile> {
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package fr.luc.crcore.player;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public interface PlayerProfileService {
|
||||
|
||||
PlayerProfile getOrCreateProfile(UUID playerId);
|
||||
|
||||
Optional<PlayerProfile> getProfile(UUID playerId);
|
||||
|
||||
boolean deleteProfile(UUID playerId);
|
||||
|
||||
Collection<PlayerProfile> getAllProfiles();
|
||||
|
||||
// ---- Scores ----
|
||||
|
||||
int addScore(UUID playerId, String scoreName, int delta);
|
||||
|
||||
int setScore(UUID playerId, String scoreName, int value);
|
||||
|
||||
int getScore(UUID playerId, String scoreName);
|
||||
|
||||
boolean resetScore(UUID playerId, String scoreName);
|
||||
|
||||
void resetAllScores(UUID playerId);
|
||||
|
||||
// ---- Rankings ----
|
||||
|
||||
List<PlayerRanking> getRankingByScore(String scoreName);
|
||||
|
||||
List<PlayerRanking> getGlobalRanking();
|
||||
|
||||
default List<PlayerRanking> getTopRankingByScore(String scoreName, int limit) {
|
||||
return getRankingByScore(scoreName).stream().limit(limit).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
default List<PlayerRanking> getTopGlobalRanking(int limit) {
|
||||
return getGlobalRanking().stream().limit(limit).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package fr.luc.crcore.player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.ToIntFunction;
|
||||
|
||||
public class PlayerProfileServiceImpl implements PlayerProfileService {
|
||||
|
||||
private final PlayerProfileRepository repository;
|
||||
|
||||
public PlayerProfileServiceImpl(PlayerProfileRepository repository) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
}
|
||||
|
||||
protected PlayerProfileRepository getRepository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
// ---- Lifecycle ----
|
||||
|
||||
@Override
|
||||
public PlayerProfile getOrCreateProfile(UUID playerId) {
|
||||
Objects.requireNonNull(playerId, "playerId");
|
||||
Optional<PlayerProfile> existing = repository.findById(playerId);
|
||||
if (existing.isPresent()) return existing.get();
|
||||
PlayerProfile profile = newProfile(playerId);
|
||||
PlayerProfile saved = repository.save(profile);
|
||||
onProfileCreated(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlayerProfile> getProfile(UUID playerId) {
|
||||
return repository.findById(playerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteProfile(UUID playerId) {
|
||||
Objects.requireNonNull(playerId, "playerId");
|
||||
Optional<PlayerProfile> profileOpt = repository.findById(playerId);
|
||||
if (profileOpt.isEmpty()) return false;
|
||||
boolean deleted = repository.delete(playerId);
|
||||
if (deleted) onProfileDeleted(profileOpt.get());
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<PlayerProfile> getAllProfiles() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
// ---- Scores ----
|
||||
|
||||
@Override
|
||||
public int addScore(UUID playerId, String scoreName, int delta) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
PlayerProfile profile = getOrCreateProfile(playerId);
|
||||
int oldValue = profile.getScore(scoreName);
|
||||
int newValue = profile.addScore(scoreName, delta);
|
||||
repository.save(profile);
|
||||
if (oldValue != newValue) {
|
||||
onScoreChanged(profile, scoreName, oldValue, newValue);
|
||||
}
|
||||
return newValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int setScore(UUID playerId, String scoreName, int value) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
PlayerProfile profile = getOrCreateProfile(playerId);
|
||||
int oldValue = profile.getScore(scoreName);
|
||||
profile.setScore(scoreName, value);
|
||||
repository.save(profile);
|
||||
if (oldValue != value) {
|
||||
onScoreChanged(profile, scoreName, oldValue, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScore(UUID playerId, String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
return repository.findById(playerId)
|
||||
.map(profile -> profile.getScore(scoreName))
|
||||
.orElse(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean resetScore(UUID playerId, String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
Optional<PlayerProfile> profileOpt = repository.findById(playerId);
|
||||
if (profileOpt.isEmpty()) return false;
|
||||
PlayerProfile profile = profileOpt.get();
|
||||
int oldValue = profile.getScore(scoreName);
|
||||
boolean removed = profile.resetScore(scoreName);
|
||||
if (removed) {
|
||||
repository.save(profile);
|
||||
onScoreChanged(profile, scoreName, oldValue, 0);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetAllScores(UUID playerId) {
|
||||
Optional<PlayerProfile> profileOpt = repository.findById(playerId);
|
||||
if (profileOpt.isEmpty()) return;
|
||||
PlayerProfile profile = profileOpt.get();
|
||||
Map<String, Integer> snapshot = new LinkedHashMap<>(profile.getScores());
|
||||
if (snapshot.isEmpty()) return;
|
||||
profile.resetAllScores();
|
||||
repository.save(profile);
|
||||
snapshot.forEach((scoreName, oldValue) ->
|
||||
onScoreChanged(profile, scoreName, oldValue, 0));
|
||||
}
|
||||
|
||||
// ---- Rankings ----
|
||||
|
||||
@Override
|
||||
public List<PlayerRanking> getRankingByScore(String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
return rank(profile -> profile.getScore(scoreName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PlayerRanking> getGlobalRanking() {
|
||||
return rank(PlayerProfile::getTotalScore);
|
||||
}
|
||||
|
||||
protected List<PlayerRanking> rank(ToIntFunction<PlayerProfile> scoreFn) {
|
||||
Collection<PlayerProfile> all = repository.findAll();
|
||||
List<PlayerProfile> sorted = new ArrayList<>(all);
|
||||
sorted.sort(Comparator
|
||||
.comparingInt(scoreFn).reversed()
|
||||
.thenComparing(PlayerProfile::getId));
|
||||
List<PlayerRanking> result = new ArrayList<>(sorted.size());
|
||||
int currentRank = 1;
|
||||
for (PlayerProfile profile : sorted) {
|
||||
result.add(newRanking(currentRank++, profile, scoreFn.applyAsInt(profile)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Override hooks ----
|
||||
|
||||
protected PlayerProfile newProfile(UUID playerId) {
|
||||
return new PlayerProfile(playerId);
|
||||
}
|
||||
|
||||
protected PlayerRanking newRanking(int rank, PlayerProfile profile, int score) {
|
||||
return new PlayerRanking(rank, profile, score);
|
||||
}
|
||||
|
||||
protected void onProfileCreated(PlayerProfile profile) {
|
||||
}
|
||||
|
||||
protected void onProfileDeleted(PlayerProfile profile) {
|
||||
}
|
||||
|
||||
protected void onScoreChanged(PlayerProfile profile, String scoreName,
|
||||
int oldValue, int newValue) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package fr.luc.crcore.player;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record PlayerRanking(int rank, PlayerProfile profile, int score) {
|
||||
|
||||
public PlayerRanking {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
if (rank < 1) {
|
||||
throw new IllegalArgumentException("rank must be >= 1, got " + rank);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class InMemoryTeamRepository implements TeamRepository {
|
||||
|
||||
private final Map<UUID, Team> teams = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
public Team save(Team team) {
|
||||
Objects.requireNonNull(team, "team");
|
||||
teams.put(team.getId(), team);
|
||||
return team;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Team> findById(UUID id) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
return Optional.ofNullable(teams.get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Team> findAll() {
|
||||
return Collections.unmodifiableCollection(teams.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(UUID id) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
return teams.remove(id) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Team> findByName(String name) {
|
||||
Objects.requireNonNull(name, "name");
|
||||
return teams.values().stream()
|
||||
.filter(team -> team.getName().equalsIgnoreCase(name))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Team> findByTag(String tag) {
|
||||
Objects.requireNonNull(tag, "tag");
|
||||
return teams.values().stream()
|
||||
.filter(team -> team.getTag().equalsIgnoreCase(tag))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Team> findByMember(UUID playerId) {
|
||||
Objects.requireNonNull(playerId, "playerId");
|
||||
return teams.values().stream()
|
||||
.filter(team -> team.hasMember(playerId))
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
import fr.luc.crcore.common.AbstractEntity;
|
||||
import fr.luc.crcore.common.Named;
|
||||
import fr.luc.crcore.common.ScoreHolder;
|
||||
import org.bukkit.Location;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public class Team extends AbstractEntity implements Named, ScoreHolder {
|
||||
|
||||
private final String name;
|
||||
private final String tag;
|
||||
private final TeamColor color;
|
||||
private final Set<TeamMember> members;
|
||||
private final Map<String, Integer> scores;
|
||||
private UUID leaderId;
|
||||
private TeamVisibility visibility;
|
||||
private Location spawnPoint;
|
||||
|
||||
public Team(UUID id, String name, String tag, TeamColor color, UUID leaderId) {
|
||||
this(id, name, tag, color, leaderId, TeamVisibility.PRIVATE);
|
||||
}
|
||||
|
||||
public Team(UUID id, String name, String tag, TeamColor color, UUID leaderId,
|
||||
TeamVisibility visibility) {
|
||||
super(id);
|
||||
this.name = Objects.requireNonNull(name, "name");
|
||||
this.tag = Objects.requireNonNull(tag, "tag");
|
||||
this.color = Objects.requireNonNull(color, "color");
|
||||
this.leaderId = Objects.requireNonNull(leaderId, "leaderId");
|
||||
this.visibility = Objects.requireNonNull(visibility, "visibility");
|
||||
this.members = new HashSet<>();
|
||||
this.scores = new HashMap<>();
|
||||
this.members.add(newMember(leaderId, TeamRole.LEADER));
|
||||
}
|
||||
|
||||
/** Override to instantiate a custom TeamMember subclass. */
|
||||
protected TeamMember newMember(UUID playerId, TeamRole role) {
|
||||
return new TeamMember(playerId, role);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getTag() {
|
||||
return tag;
|
||||
}
|
||||
|
||||
public TeamColor getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public UUID getLeaderId() {
|
||||
return leaderId;
|
||||
}
|
||||
|
||||
public TeamVisibility getVisibility() {
|
||||
return visibility;
|
||||
}
|
||||
|
||||
public void setVisibility(TeamVisibility visibility) {
|
||||
this.visibility = Objects.requireNonNull(visibility, "visibility");
|
||||
}
|
||||
|
||||
public boolean isPublic() {
|
||||
return visibility.isPublic();
|
||||
}
|
||||
|
||||
public TeamMember getLeader() {
|
||||
return getMember(leaderId).orElseThrow(
|
||||
() -> new IllegalStateException("Team has no leader: " + getId()));
|
||||
}
|
||||
|
||||
public Set<TeamMember> getMembers() {
|
||||
return Collections.unmodifiableSet(members);
|
||||
}
|
||||
|
||||
public Optional<TeamMember> getMember(UUID playerId) {
|
||||
Objects.requireNonNull(playerId, "playerId");
|
||||
return members.stream()
|
||||
.filter(member -> member.getPlayerId().equals(playerId))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
public boolean hasMember(UUID playerId) {
|
||||
return getMember(playerId).isPresent();
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return members.size();
|
||||
}
|
||||
|
||||
public TeamMember addMember(UUID playerId) {
|
||||
Objects.requireNonNull(playerId, "playerId");
|
||||
Optional<TeamMember> existing = getMember(playerId);
|
||||
if (existing.isPresent()) {
|
||||
return existing.get();
|
||||
}
|
||||
TeamMember member = newMember(playerId, TeamRole.MEMBER);
|
||||
members.add(member);
|
||||
return member;
|
||||
}
|
||||
|
||||
public boolean removeMember(UUID playerId) {
|
||||
Objects.requireNonNull(playerId, "playerId");
|
||||
if (playerId.equals(leaderId)) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot remove the leader; transfer leadership first.");
|
||||
}
|
||||
return members.removeIf(member -> member.getPlayerId().equals(playerId));
|
||||
}
|
||||
|
||||
public void transferLeadership(UUID newLeaderId) {
|
||||
Objects.requireNonNull(newLeaderId, "newLeaderId");
|
||||
if (newLeaderId.equals(leaderId)) {
|
||||
return;
|
||||
}
|
||||
TeamMember newLeader = getMember(newLeaderId).orElseThrow(
|
||||
() -> new IllegalArgumentException(
|
||||
"New leader must already be a member of the team."));
|
||||
TeamMember oldLeader = getLeader();
|
||||
members.remove(oldLeader);
|
||||
members.remove(newLeader);
|
||||
members.add(oldLeader.withRole(TeamRole.MEMBER));
|
||||
members.add(newLeader.withRole(TeamRole.LEADER));
|
||||
this.leaderId = newLeaderId;
|
||||
}
|
||||
|
||||
// ---- Scores ----
|
||||
|
||||
public int getScore(String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
return scores.getOrDefault(scoreName, 0);
|
||||
}
|
||||
|
||||
public boolean hasScore(String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
return scores.containsKey(scoreName);
|
||||
}
|
||||
|
||||
public Map<String, Integer> getScores() {
|
||||
return Collections.unmodifiableMap(scores);
|
||||
}
|
||||
|
||||
public int getTotalScore() {
|
||||
return scores.values().stream().mapToInt(Integer::intValue).sum();
|
||||
}
|
||||
|
||||
public int addScore(String scoreName, int delta) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
int newValue = getScore(scoreName) + delta;
|
||||
scores.put(scoreName, newValue);
|
||||
return newValue;
|
||||
}
|
||||
|
||||
public int setScore(String scoreName, int value) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
scores.put(scoreName, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
public boolean resetScore(String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
return scores.remove(scoreName) != null;
|
||||
}
|
||||
|
||||
public void resetAllScores() {
|
||||
scores.clear();
|
||||
}
|
||||
|
||||
// ---- Spawn point ----
|
||||
|
||||
public Optional<Location> getSpawnPoint() {
|
||||
return spawnPoint == null ? Optional.empty() : Optional.of(spawnPoint.clone());
|
||||
}
|
||||
|
||||
public boolean hasSpawnPoint() {
|
||||
return spawnPoint != null;
|
||||
}
|
||||
|
||||
public void setSpawnPoint(Location location) {
|
||||
this.spawnPoint = location == null ? null : location.clone();
|
||||
}
|
||||
|
||||
public void clearSpawnPoint() {
|
||||
this.spawnPoint = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
public class TeamAccessException extends TeamException {
|
||||
|
||||
public TeamAccessException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
public class TeamAlreadyExistsException extends TeamException {
|
||||
|
||||
public TeamAlreadyExistsException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.DyeColor;
|
||||
|
||||
public enum TeamColor {
|
||||
|
||||
RED(ChatColor.RED, DyeColor.RED, "Red"),
|
||||
BLUE(ChatColor.BLUE, DyeColor.BLUE, "Blue"),
|
||||
GREEN(ChatColor.GREEN, DyeColor.LIME, "Green"),
|
||||
YELLOW(ChatColor.YELLOW, DyeColor.YELLOW, "Yellow"),
|
||||
AQUA(ChatColor.AQUA, DyeColor.LIGHT_BLUE, "Aqua"),
|
||||
LIGHT_PURPLE(ChatColor.LIGHT_PURPLE, DyeColor.MAGENTA, "Pink"),
|
||||
GOLD(ChatColor.GOLD, DyeColor.ORANGE, "Gold"),
|
||||
WHITE(ChatColor.WHITE, DyeColor.WHITE, "White"),
|
||||
BLACK(ChatColor.BLACK, DyeColor.BLACK, "Black"),
|
||||
DARK_BLUE(ChatColor.DARK_BLUE, DyeColor.BLUE, "Dark Blue"),
|
||||
DARK_GREEN(ChatColor.DARK_GREEN, DyeColor.GREEN, "Dark Green"),
|
||||
DARK_AQUA(ChatColor.DARK_AQUA, DyeColor.CYAN, "Dark Aqua"),
|
||||
DARK_RED(ChatColor.DARK_RED, DyeColor.RED, "Dark Red"),
|
||||
DARK_PURPLE(ChatColor.DARK_PURPLE, DyeColor.PURPLE, "Purple"),
|
||||
DARK_GRAY(ChatColor.DARK_GRAY, DyeColor.GRAY, "Dark Gray"),
|
||||
GRAY(ChatColor.GRAY, DyeColor.LIGHT_GRAY, "Gray");
|
||||
|
||||
private final ChatColor chatColor;
|
||||
private final DyeColor dyeColor;
|
||||
private final String displayName;
|
||||
|
||||
TeamColor(ChatColor chatColor, DyeColor dyeColor, String displayName) {
|
||||
this.chatColor = chatColor;
|
||||
this.dyeColor = dyeColor;
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public ChatColor getChatColor() {
|
||||
return chatColor;
|
||||
}
|
||||
|
||||
public DyeColor getDyeColor() {
|
||||
return dyeColor;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
public class TeamException extends RuntimeException {
|
||||
|
||||
public TeamException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public TeamException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
import fr.luc.crcore.common.AbstractEntity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public class TeamMember extends AbstractEntity {
|
||||
|
||||
private final TeamRole role;
|
||||
private final Instant joinedAt;
|
||||
|
||||
public TeamMember(UUID playerId, TeamRole role) {
|
||||
this(playerId, role, Instant.now());
|
||||
}
|
||||
|
||||
public TeamMember(UUID playerId, TeamRole role, Instant joinedAt) {
|
||||
super(playerId);
|
||||
this.role = Objects.requireNonNull(role, "role");
|
||||
this.joinedAt = Objects.requireNonNull(joinedAt, "joinedAt");
|
||||
}
|
||||
|
||||
public UUID getPlayerId() {
|
||||
return getId();
|
||||
}
|
||||
|
||||
public TeamRole getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public Instant getJoinedAt() {
|
||||
return joinedAt;
|
||||
}
|
||||
|
||||
public boolean isLeader() {
|
||||
return role.isLeader();
|
||||
}
|
||||
|
||||
public TeamMember withRole(TeamRole newRole) {
|
||||
Objects.requireNonNull(newRole, "newRole");
|
||||
if (newRole == this.role) {
|
||||
return this;
|
||||
}
|
||||
return new TeamMember(getPlayerId(), newRole, joinedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
public class TeamNotFoundException extends TeamException {
|
||||
|
||||
public TeamNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record TeamRanking(int rank, Team team, int score) {
|
||||
|
||||
public TeamRanking {
|
||||
Objects.requireNonNull(team, "team");
|
||||
if (rank < 1) {
|
||||
throw new IllegalArgumentException("rank must be >= 1, got " + rank);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
import fr.luc.crcore.common.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TeamRepository extends Repository<Team> {
|
||||
|
||||
Optional<Team> findByName(String name);
|
||||
|
||||
Optional<Team> findByTag(String tag);
|
||||
|
||||
Optional<Team> findByMember(UUID playerId);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
public enum TeamRole {
|
||||
|
||||
LEADER,
|
||||
MEMBER;
|
||||
|
||||
public boolean isLeader() {
|
||||
return this == LEADER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
import org.bukkit.Location;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public interface TeamService {
|
||||
|
||||
// ---- Lifecycle ----
|
||||
|
||||
Team createTeam(String name, String tag, TeamColor color, UUID leaderId);
|
||||
|
||||
Team createTeam(String name, String tag, TeamColor color, UUID leaderId,
|
||||
TeamVisibility visibility);
|
||||
|
||||
boolean dissolveTeam(UUID teamId);
|
||||
|
||||
// ---- Membership ----
|
||||
|
||||
boolean addMember(UUID teamId, UUID playerId);
|
||||
|
||||
boolean removeMember(UUID teamId, UUID playerId);
|
||||
|
||||
boolean joinTeam(UUID teamId, UUID playerId);
|
||||
|
||||
boolean transferLeadership(UUID teamId, UUID newLeaderId);
|
||||
|
||||
void setVisibility(UUID teamId, TeamVisibility visibility);
|
||||
|
||||
// ---- Scores ----
|
||||
|
||||
int addScore(UUID teamId, String scoreName, int delta);
|
||||
|
||||
int setScore(UUID teamId, String scoreName, int value);
|
||||
|
||||
int getScore(UUID teamId, String scoreName);
|
||||
|
||||
boolean resetScore(UUID teamId, String scoreName);
|
||||
|
||||
void resetAllScores(UUID teamId);
|
||||
|
||||
// ---- Rankings ----
|
||||
|
||||
List<TeamRanking> getRankingByScore(String scoreName);
|
||||
|
||||
List<TeamRanking> getGlobalRanking();
|
||||
|
||||
default List<TeamRanking> getTopRankingByScore(String scoreName, int limit) {
|
||||
return getRankingByScore(scoreName).stream().limit(limit).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
default List<TeamRanking> getTopGlobalRanking(int limit) {
|
||||
return getGlobalRanking().stream().limit(limit).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ---- Spawn point ----
|
||||
|
||||
void setSpawnPoint(UUID teamId, Location location);
|
||||
|
||||
void clearSpawnPoint(UUID teamId);
|
||||
|
||||
Optional<Location> getSpawnPoint(UUID teamId);
|
||||
|
||||
// ---- Queries ----
|
||||
|
||||
Optional<Team> getTeam(UUID teamId);
|
||||
|
||||
Optional<Team> getTeamByName(String name);
|
||||
|
||||
Optional<Team> getTeamByTag(String tag);
|
||||
|
||||
Optional<Team> getTeamOfPlayer(UUID playerId);
|
||||
|
||||
Collection<Team> getAllTeams();
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
import org.bukkit.Location;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.ToIntFunction;
|
||||
|
||||
public class TeamServiceImpl implements TeamService {
|
||||
|
||||
private final TeamRepository repository;
|
||||
|
||||
public TeamServiceImpl(TeamRepository repository) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
}
|
||||
|
||||
protected TeamRepository getRepository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
// ---- Lifecycle ----
|
||||
|
||||
@Override
|
||||
public Team createTeam(String name, String tag, TeamColor color, UUID leaderId) {
|
||||
return createTeam(name, tag, color, leaderId, TeamVisibility.PRIVATE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Team createTeam(String name, String tag, TeamColor color, UUID leaderId,
|
||||
TeamVisibility visibility) {
|
||||
validateName(name);
|
||||
validateTag(tag);
|
||||
validateLeader(leaderId);
|
||||
Objects.requireNonNull(visibility, "visibility");
|
||||
|
||||
Team team = newTeam(UUID.randomUUID(), name, tag, color, leaderId, visibility);
|
||||
onBeforeSave(team);
|
||||
Team saved = repository.save(team);
|
||||
onAfterCreate(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dissolveTeam(UUID teamId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
Optional<Team> teamOpt = repository.findById(teamId);
|
||||
if (teamOpt.isEmpty()) return false;
|
||||
Team team = teamOpt.get();
|
||||
onBeforeDissolve(team);
|
||||
boolean deleted = repository.delete(teamId);
|
||||
if (deleted) onAfterDissolve(team);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ---- Membership ----
|
||||
|
||||
@Override
|
||||
public boolean addMember(UUID teamId, UUID playerId) {
|
||||
Objects.requireNonNull(playerId, "playerId");
|
||||
Team team = requireTeam(teamId);
|
||||
if (repository.findByMember(playerId).isPresent()) {
|
||||
return false;
|
||||
}
|
||||
TeamMember member = team.addMember(playerId);
|
||||
repository.save(team);
|
||||
onMemberAdded(team, member);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean removeMember(UUID teamId, UUID playerId) {
|
||||
Objects.requireNonNull(playerId, "playerId");
|
||||
Team team = requireTeam(teamId);
|
||||
boolean removed = team.removeMember(playerId);
|
||||
if (removed) {
|
||||
repository.save(team);
|
||||
onMemberRemoved(team, playerId);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean joinTeam(UUID teamId, UUID playerId) {
|
||||
Objects.requireNonNull(playerId, "playerId");
|
||||
Team team = requireTeam(teamId);
|
||||
validateJoinable(team, playerId);
|
||||
TeamMember member = team.addMember(playerId);
|
||||
repository.save(team);
|
||||
onMemberAdded(team, member);
|
||||
onPlayerJoined(team, member);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean transferLeadership(UUID teamId, UUID newLeaderId) {
|
||||
Objects.requireNonNull(newLeaderId, "newLeaderId");
|
||||
Team team = requireTeam(teamId);
|
||||
UUID oldLeaderId = team.getLeaderId();
|
||||
team.transferLeadership(newLeaderId);
|
||||
repository.save(team);
|
||||
onLeadershipTransferred(team, oldLeaderId, newLeaderId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setVisibility(UUID teamId, TeamVisibility visibility) {
|
||||
Objects.requireNonNull(visibility, "visibility");
|
||||
Team team = requireTeam(teamId);
|
||||
TeamVisibility old = team.getVisibility();
|
||||
if (old == visibility) return;
|
||||
team.setVisibility(visibility);
|
||||
repository.save(team);
|
||||
onVisibilityChanged(team, old, visibility);
|
||||
}
|
||||
|
||||
// ---- Scores ----
|
||||
|
||||
@Override
|
||||
public int addScore(UUID teamId, String scoreName, int delta) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
Team team = requireTeam(teamId);
|
||||
int oldValue = team.getScore(scoreName);
|
||||
int newValue = team.addScore(scoreName, delta);
|
||||
repository.save(team);
|
||||
if (oldValue != newValue) {
|
||||
onScoreChanged(team, scoreName, oldValue, newValue);
|
||||
}
|
||||
return newValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int setScore(UUID teamId, String scoreName, int value) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
Team team = requireTeam(teamId);
|
||||
int oldValue = team.getScore(scoreName);
|
||||
team.setScore(scoreName, value);
|
||||
repository.save(team);
|
||||
if (oldValue != value) {
|
||||
onScoreChanged(team, scoreName, oldValue, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScore(UUID teamId, String scoreName) {
|
||||
return requireTeam(teamId).getScore(scoreName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean resetScore(UUID teamId, String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
Team team = requireTeam(teamId);
|
||||
int oldValue = team.getScore(scoreName);
|
||||
boolean removed = team.resetScore(scoreName);
|
||||
if (removed) {
|
||||
repository.save(team);
|
||||
onScoreChanged(team, scoreName, oldValue, 0);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resetAllScores(UUID teamId) {
|
||||
Team team = requireTeam(teamId);
|
||||
Map<String, Integer> snapshot = new LinkedHashMap<>(team.getScores());
|
||||
if (snapshot.isEmpty()) return;
|
||||
team.resetAllScores();
|
||||
repository.save(team);
|
||||
snapshot.forEach((scoreName, oldValue) ->
|
||||
onScoreChanged(team, scoreName, oldValue, 0));
|
||||
}
|
||||
|
||||
// ---- Rankings ----
|
||||
|
||||
@Override
|
||||
public List<TeamRanking> getRankingByScore(String scoreName) {
|
||||
Objects.requireNonNull(scoreName, "scoreName");
|
||||
return rank(team -> team.getScore(scoreName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TeamRanking> getGlobalRanking() {
|
||||
return rank(Team::getTotalScore);
|
||||
}
|
||||
|
||||
protected List<TeamRanking> rank(ToIntFunction<Team> scoreFn) {
|
||||
Collection<Team> all = repository.findAll();
|
||||
List<Team> sorted = new ArrayList<>(all);
|
||||
sorted.sort(Comparator
|
||||
.comparingInt(scoreFn).reversed()
|
||||
.thenComparing(Team::getName, String.CASE_INSENSITIVE_ORDER));
|
||||
List<TeamRanking> result = new ArrayList<>(sorted.size());
|
||||
int currentRank = 1;
|
||||
for (Team team : sorted) {
|
||||
result.add(newRanking(currentRank++, team, scoreFn.applyAsInt(team)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
protected TeamRanking newRanking(int rank, Team team, int score) {
|
||||
return new TeamRanking(rank, team, score);
|
||||
}
|
||||
|
||||
// ---- Spawn point ----
|
||||
|
||||
@Override
|
||||
public void setSpawnPoint(UUID teamId, Location location) {
|
||||
Team team = requireTeam(teamId);
|
||||
Location old = team.getSpawnPoint().orElse(null);
|
||||
team.setSpawnPoint(location);
|
||||
repository.save(team);
|
||||
onSpawnPointChanged(team, old, location);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearSpawnPoint(UUID teamId) {
|
||||
setSpawnPoint(teamId, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Location> getSpawnPoint(UUID teamId) {
|
||||
return requireTeam(teamId).getSpawnPoint();
|
||||
}
|
||||
|
||||
// ---- Queries ----
|
||||
|
||||
@Override
|
||||
public Optional<Team> getTeam(UUID teamId) {
|
||||
return repository.findById(teamId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Team> getTeamByName(String name) {
|
||||
return repository.findByName(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Team> getTeamByTag(String tag) {
|
||||
return repository.findByTag(tag);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Team> getTeamOfPlayer(UUID playerId) {
|
||||
return repository.findByMember(playerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Team> getAllTeams() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
// ---- Override hooks ----
|
||||
|
||||
protected Team newTeam(UUID id, String name, String tag, TeamColor color, UUID leaderId,
|
||||
TeamVisibility visibility) {
|
||||
return new Team(id, name, tag, color, leaderId, visibility);
|
||||
}
|
||||
|
||||
protected void validateName(String name) {
|
||||
Objects.requireNonNull(name, "name");
|
||||
repository.findByName(name).ifPresent(existing -> {
|
||||
throw new TeamAlreadyExistsException("Team name already in use: " + name);
|
||||
});
|
||||
}
|
||||
|
||||
protected void validateTag(String tag) {
|
||||
Objects.requireNonNull(tag, "tag");
|
||||
repository.findByTag(tag).ifPresent(existing -> {
|
||||
throw new TeamAlreadyExistsException("Team tag already in use: " + tag);
|
||||
});
|
||||
}
|
||||
|
||||
protected void validateLeader(UUID leaderId) {
|
||||
Objects.requireNonNull(leaderId, "leaderId");
|
||||
repository.findByMember(leaderId).ifPresent(existing -> {
|
||||
throw new TeamAlreadyExistsException(
|
||||
"Player already belongs to team: " + existing.getName());
|
||||
});
|
||||
}
|
||||
|
||||
protected void validateJoinable(Team team, UUID playerId) {
|
||||
if (!team.isPublic()) {
|
||||
throw new TeamAccessException(
|
||||
"Team " + team.getName() + " is private; ask the leader for an invite.");
|
||||
}
|
||||
repository.findByMember(playerId).ifPresent(existing -> {
|
||||
throw new TeamAccessException(
|
||||
"You already belong to team: " + existing.getName());
|
||||
});
|
||||
}
|
||||
|
||||
protected void onBeforeSave(Team team) {
|
||||
}
|
||||
|
||||
protected void onAfterCreate(Team team) {
|
||||
}
|
||||
|
||||
protected void onBeforeDissolve(Team team) {
|
||||
}
|
||||
|
||||
protected void onAfterDissolve(Team team) {
|
||||
}
|
||||
|
||||
protected void onMemberAdded(Team team, TeamMember member) {
|
||||
}
|
||||
|
||||
protected void onMemberRemoved(Team team, UUID playerId) {
|
||||
}
|
||||
|
||||
protected void onPlayerJoined(Team team, TeamMember member) {
|
||||
}
|
||||
|
||||
protected void onLeadershipTransferred(Team team, UUID oldLeaderId, UUID newLeaderId) {
|
||||
}
|
||||
|
||||
protected void onVisibilityChanged(Team team, TeamVisibility oldValue, TeamVisibility newValue) {
|
||||
}
|
||||
|
||||
protected void onScoreChanged(Team team, String scoreName, int oldValue, int newValue) {
|
||||
}
|
||||
|
||||
protected void onSpawnPointChanged(Team team, Location oldLocation, Location newLocation) {
|
||||
}
|
||||
|
||||
protected Team requireTeam(UUID teamId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
return repository.findById(teamId).orElseThrow(
|
||||
() -> new TeamNotFoundException("No team with id: " + teamId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package fr.luc.crcore.team;
|
||||
|
||||
public enum TeamVisibility {
|
||||
|
||||
PUBLIC,
|
||||
PRIVATE;
|
||||
|
||||
public boolean isPublic() {
|
||||
return this == PUBLIC;
|
||||
}
|
||||
|
||||
public boolean isPrivate() {
|
||||
return this == PRIVATE;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user