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:
Antone Barbaud
2026-06-08 17:17:56 +02:00
commit ffc77c4213
53 changed files with 3642 additions and 0 deletions
+48
View File
@@ -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
+10
View File
@@ -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
+7
View File
@@ -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>
+14
View File
@@ -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>
+59
View File
@@ -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.
+58
View File
@@ -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.
+234
View File
@@ -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 816. 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>`
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.
+132
View File
@@ -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
+110
View File
@@ -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
+239
View File
@@ -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
+40
View File
@@ -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
+69
View File
@@ -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
+62
View File
@@ -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
+312
View File
@@ -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
View File
@@ -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.
+60
View File
@@ -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();
}
}
+198
View File
@@ -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;
}
}