commit ffc77c42138a575b32607ff28cc0cea2da6090fd Author: Antone Barbaud Date: Mon Jun 8 17:17:56 2026 +0200 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. 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 + 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62343e7 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0c04b52 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..d1361ce --- /dev/null +++ b/GEMINI.md @@ -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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..012d56f --- /dev/null +++ b/docs/README.md @@ -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`) ; +- 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. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..c160890 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,234 @@ +# Journal des décisions + +Format léger : une décision = un titre + contexte + choix + raison. + +## 2026-06-08 — Cible Minecraft 1.16.5 / Paper + +- **Choix** : utiliser l'API Paper 1.16.5 (`paper-api`) plutôt que Spigot. +- **Raison** : Paper est un sur-ensemble de Spigot, expose plus d'API utiles + pour les évènements, et reste compatible avec un serveur Spigot 1.16.5. +- **Conséquence** : un serveur Paper 1.16.5 est recommandé pour le test. + +## 2026-06-08 — Java 16 + +- **Choix** : `maven.compiler.source/target = 16`. +- **Raison** : Paper 1.16.5 tourne avec un JDK 8–16. Java 16 donne accès aux + records, pattern matching simple, etc., tout en restant exécutable sur un + serveur 1.16.5. + +## 2026-06-08 — `docs/` = source de vérité + +- **Choix** : toutes les décisions, règles de gameplay, commandes et idées + doivent être notées dans `docs/` avant ou pendant l'implémentation. +- **Raison** : éviter la dérive entre intention et code, garder une trace + partageable des échanges. + +## 2026-06-08 — Code en anglais standard + +- **Choix** : tout le code (classes, méthodes, attributs, variables) est écrit + en anglais. La doc utilisateur (`docs/`, messages joueurs) reste en français. +- **Raison** : conventions standards du monde Java/Bukkit, lisibilité par tout + développeur, cohérence avec l'API Paper. + +## 2026-06-08 — Architecture en couches pour le domaine + +- **Choix** : séparer chaque domaine fonctionnel (ex. `team`) en : + - **Interfaces** d'abstraction (ex. `Identifiable`, `Named`, `Repository`, + `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) 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` 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` 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) plutôt qu'un score unique + +- **Choix** : chaque équipe porte un `Map` 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)` 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` où + la `Location` est **clonée** ; idem à l'entrée dans `setSpawnPoint`. + `Location` étant mutable côté Bukkit, ça évite que du code externe modifie + accidentellement le spawn en faisant `team.getSpawnPoint().get().setX(...)`. +- **Persistance différée** : `Location` n'est pas trivialement sérialisable + (référence au `World`). On utilisera `ConfigurationSerializable` quand on + branchera un repo fichier ; pour l'instant, le `InMemoryTeamRepository` + s'en moque. +- **Pas de téléport intégré** : le noyau ne fournit pas `teleportToSpawn(...)`. + C'est au plugin de jeu d'enchaîner `player.teleport(team.getSpawnPoint())` + s'il veut. La lib reste purement "data + règles". + +## 2026-06-08 — Scores joueurs : domaine `player` indépendant du domaine `team` + +- **Choix** : ajout d'un domaine `fr.luc.crcore.player` complet et parallèle au + domaine `team` : `PlayerProfile` (entité identifiée par l'UUID Bukkit), + `PlayerProfileService`, `PlayerProfileRepository`, + `InMemoryPlayerProfileRepository`, `PlayerRanking` (record), + `PlayerException` / `PlayerProfileNotFoundException`. +- **Pourquoi pas sur `TeamMember`** : `TeamMember` est immuable (transitions de + rôle via `withRole`), et un joueur peut changer/quitter une équipe — son + profil doit persister. Mettre les scores sur `TeamMember` aurait couplé la + durée de vie du score à l'appartenance à l'équipe. +- **Auto-création** : `addScore` / `setScore` créent le profil automatiquement + s'il n'existe pas (`getOrCreateProfile(playerId)`). Pas besoin d'appeler + explicitement un `register(playerId)` avant de tracker un score. +- **Symétrie** : les noms de méthodes, hooks et factories reflètent + exactement le domaine team (`newProfile`/`newTeam`, + `newRanking`/`newRanking`, `rank(scoreFn)`, `onScoreChanged`, + `getRankingByScore`, `getGlobalRanking`, etc.). + +## 2026-06-08 — Interface `ScoreHolder` mutualisée + +- **Choix** : extraction d'une interface `fr.luc.crcore.common.ScoreHolder` + qui déclare le contrat de scoring (`getScore`, `addScore`, `setScore`, + `resetScore`, `getTotalScore`, etc.). `Team` et `PlayerProfile` + l'implémentent. +- **Raison** : documenter le contrat et permettre du code générique côté + plugin de jeu (ex. `ScoreHolder.getTotalScore()` traité uniformément pour + l'affichage). Pas de classe abstraite partagée pour éviter le couplage + serré (Team et PlayerProfile ont des cycles de vie très différents) ; on + reste sur deux implémentations indépendantes mais avec un contrat commun. +- **Pas de `Scoreboard` aggregate** : envisagé un composant `Scoreboard` + composé dans Team et PlayerProfile, mais ça aurait imposé une indirection + pour ~8 méthodes simples. Choix actuel : duplication contrôlée des + implémentations (Map + getters/setters), interface commune pour le + contrat. diff --git a/docs/diagrams/command-class-diagram.puml b/docs/diagrams/command-class-diagram.puml new file mode 100644 index 0000000..69ec552 --- /dev/null +++ b/docs/diagrams/command-class-diagram.puml @@ -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 + + getPermission(): String + + isPlayerOnly(): boolean + + getDescription(): String + + execute(ctx: CommandContext): CommandResult + + tabComplete(sender, argIndex, partial): List + + matches(label: String): boolean + } + + abstract class AbstractCommand { + - name: String + - aliases: List + - permission: String + - playerOnly: boolean + - description: String + - usage: String + - arguments: List + # addAlias(...): void + # permission(p): void + # playerOnly(): void + # description(d): void + # usage(u): void + # argument(name, type): void + # optionalArgument(name, type): void + # buildContext(sender, label, subArgs): CommandContext + + getRequiredArgumentCount(): int + + getTotalArgumentCount(): int + + getUsage(): String + } + + abstract class BaseCommand { + - subCommandsByName: Map + - subCommandsByAlias: Map + # addSubCommand(sub: SubCommand): void + + findSubCommand(label: String): Optional + + getSubCommands(): Collection + # execute(ctx): CommandResult + + onCommand(sender, cmd, label, args): boolean + + onTabComplete(sender, cmd, alias, args): List + # checkAccess(sender, target): boolean + # handleResult(sender, result): void + } + + abstract class SubCommand { + + {abstract} execute(ctx: CommandContext): CommandResult + } + + class CommandContext { + - sender: CommandSender + - label: String + - rawArgs: String[] + - parsedArgs: Map + + getSender(): CommandSender + + isPlayer(): boolean + + getPlayer(): Optional + + requirePlayer(): Player + + get(name: String): T + + getOptional(name): Optional + + 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" as ArgumentType { + + parse(input: String): T + + suggestions(sender, partial): List + } + + class ArgumentTypes << (S, #FFC107) static >> { + + {static} STRING: ArgumentType + + {static} INTEGER: ArgumentType + + {static} DOUBLE: ArgumentType + + {static} BOOLEAN: ArgumentType + + {static} ONLINE_PLAYER: ArgumentType + + {static} enumOf(type): ArgumentType + + {static} choice(choices): ArgumentType + } + + class 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 diff --git a/docs/diagrams/player-class-diagram.puml b/docs/diagrams/player-class-diagram.puml new file mode 100644 index 0000000..1f6da4f --- /dev/null +++ b/docs/diagrams/player-class-diagram.puml @@ -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 + + getTotalScore(): int + + addScore(name, delta): int + + setScore(name, value): int + + resetScore(name): boolean + + resetAllScores(): void + } + + abstract class AbstractEntity { + - id: UUID + + getId(): UUID + } + + interface "Repository" as Repository { + + save(entity: T): T + + findById(id): Optional + + findAll(): Collection + + delete(id): boolean + } + + AbstractEntity ..|> Identifiable +} + +' === Player domain === + +package "fr.luc.crcore.player" { + + class PlayerProfile { + - scores: Map + + PlayerProfile(playerId: UUID) + + getPlayerId(): UUID + } + + class PlayerRanking <> { + + rank: int + + profile: PlayerProfile + + score: int + } + + interface PlayerProfileRepository + + class InMemoryPlayerProfileRepository { + - profiles: Map + } + + interface PlayerProfileService { + + getOrCreateProfile(playerId): PlayerProfile + + getProfile(playerId): Optional + + deleteProfile(playerId): boolean + + getAllProfiles(): Collection + -- + + addScore(playerId, name, delta): int + + setScore(playerId, name, value): int + + getScore(playerId, name): int + + resetScore(playerId, name): boolean + + resetAllScores(playerId): void + -- + + getRankingByScore(name): List + + getGlobalRanking(): List + + getTopRankingByScore(name, limit): List + + getTopGlobalRanking(limit): List + } + + class PlayerProfileServiceImpl { + - repository: PlayerProfileRepository + -- + # newProfile(playerId): PlayerProfile + # newRanking(rank, profile, score): PlayerRanking + # rank(scoreFn): List + -- + # 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 diff --git a/docs/diagrams/team-class-diagram.puml b/docs/diagrams/team-class-diagram.puml new file mode 100644 index 0000000..5b20f02 --- /dev/null +++ b/docs/diagrams/team-class-diagram.puml @@ -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 + + 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" as Repository { + + save(entity: T): T + + findById(id: UUID): Optional + + findAll(): Collection + + 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 + - scores: Map + - spawnPoint: Location + -- + + getName(): String + + getTag(): String + + getColor(): TeamColor + + getLeaderId(): UUID + + getLeader(): TeamMember + + getVisibility(): TeamVisibility + + setVisibility(v): void + + isPublic(): boolean + + getMembers(): Set + + getMember(playerId): Optional + + hasMember(playerId): boolean + + size(): int + + addMember(playerId): TeamMember + + removeMember(playerId): boolean + + transferLeadership(newLeaderId): void + -- + + getScore(name): int + + hasScore(name): boolean + + getScores(): Map + + getTotalScore(): int + + addScore(name, delta): int + + setScore(name, value): int + + resetScore(name): boolean + + resetAllScores(): void + -- + + getSpawnPoint(): Optional + + hasSpawnPoint(): boolean + + setSpawnPoint(loc): void + + clearSpawnPoint(): void + -- + # newMember(playerId, role): TeamMember + } + + class TeamRanking <> { + + rank: int + + team: Team + + score: int + } + + interface TeamRepository { + + findByName(name: String): Optional + + findByTag(tag: String): Optional + + findByMember(playerId: UUID): Optional + } + + class InMemoryTeamRepository { + - teams: Map + } + + 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 + + getGlobalRanking(): List + + getTopRankingByScore(name, limit): List + + getTopGlobalRanking(limit): List + -- + + setSpawnPoint(teamId, loc): void + + clearSpawnPoint(teamId): void + + getSpawnPoint(teamId): Optional + -- + + getTeam / getTeamByName / getTeamByTag / getTeamOfPlayer + + getAllTeams(): Collection + } + + class TeamServiceImpl { + - repository: TeamRepository + -- + # newTeam(...): Team + # newRanking(rank, team, score): TeamRanking + # rank(scoreFn): List + -- + # 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 diff --git a/docs/diagrams/team-create-activity.puml b/docs/diagrams/team-create-activity.puml new file mode 100644 index 0000000..057d053 --- /dev/null +++ b/docs/diagrams/team-create-activity.puml @@ -0,0 +1,40 @@ +@startuml team-create-activity +title CR-Core — Create Team (activity diagram) + +start + +:Player runs /team create [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 diff --git a/docs/diagrams/team-create-sequence.puml b/docs/diagrams/team-create-sequence.puml new file mode 100644 index 0000000..37b156a --- /dev/null +++ b/docs/diagrams/team-create-sequence.puml @@ -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 +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 created" +deactivate Base + +@enduml diff --git a/docs/diagrams/team-join-sequence.puml b/docs/diagrams/team-join-sequence.puml new file mode 100644 index 0000000..0d2e273 --- /dev/null +++ b/docs/diagrams/team-join-sequence.puml @@ -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 +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 +deactivate Repo +Service --> Sub : Optional +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 diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..b629d7c --- /dev/null +++ b/docs/features.md @@ -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` | 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` 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` (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)` 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` (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) par défaut. Le contrat +`TeamRepository extends Repository` 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` | 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` 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` custom = une classe qui implémente `parse(String): T` et +optionnellement `suggestions(sender, partial): List`. + +### 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, ...)_ diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..e92056e --- /dev/null +++ b/docs/setup.md @@ -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 + + fr.luc + CR-Core + 1.0-SNAPSHOT + compile + +``` + +> Scope `compile` (et non `provided`) **si** le plugin de jeu shade CR-Core dans +> son propre jar (recommandé pour de la pure librairie). Pensez à utiliser un +> `` dans le `maven-shade-plugin` du plugin de jeu pour éviter les +> conflits si plusieurs plugins shadent CR-Core sur le même serveur. + +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. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..956acb2 --- /dev/null +++ b/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + fr.luc + CR-Core + 1.0-SNAPSHOT + jar + + CR-Core + Reusable core library for CR Minecraft game plugins (teams, commands, common abstractions). + + + 16 + 16 + UTF-8 + + + + + 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/ + + + + + + com.destroystokyo.paper + paper-api + 1.16.5-R0.1-SNAPSHOT + provided + + + + + ${project.artifactId}-${project.version} + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${maven.compiler.source} + ${maven.compiler.target} + + + + + + diff --git a/src/main/java/fr/luc/crcore/command/AbstractCommand.java b/src/main/java/fr/luc/crcore/command/AbstractCommand.java new file mode 100644 index 0000000..b49e6e3 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/AbstractCommand.java @@ -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 aliases = new ArrayList<>(); + private final List 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 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 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 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 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); + } +} diff --git a/src/main/java/fr/luc/crcore/command/ArgumentDef.java b/src/main/java/fr/luc/crcore/command/ArgumentDef.java new file mode 100644 index 0000000..1bf492a --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/ArgumentDef.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/command/ArgumentType.java b/src/main/java/fr/luc/crcore/command/ArgumentType.java new file mode 100644 index 0000000..d2c9661 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/ArgumentType.java @@ -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 parse(String input) throws CommandException; + + default List suggestions(CommandSender sender, String partial) { + return Collections.emptyList(); + } +} diff --git a/src/main/java/fr/luc/crcore/command/ArgumentTypes.java b/src/main/java/fr/luc/crcore/command/ArgumentTypes.java new file mode 100644 index 0000000..965e650 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/ArgumentTypes.java @@ -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 = new ArgumentType<>() { + @Override + public String parse(String input) { + return input; + } + }; + + public static final ArgumentType 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 = 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 = 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 suggestions(CommandSender sender, String partial) { + return filter(List.of("true", "false"), partial); + } + }; + + public static final ArgumentType 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 suggestions(CommandSender sender, String partial) { + return filter(Bukkit.getOnlinePlayers().stream() + .map(Player::getName) + .collect(Collectors.toList()), partial); + } + }; + + public static > ArgumentType enumOf(Class 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 suggestions(CommandSender sender, String partial) { + return filter(Arrays.stream(type.getEnumConstants()) + .map(Enum::name) + .collect(Collectors.toList()), partial); + } + }; + } + + public static ArgumentType choice(String... choices) { + Objects.requireNonNull(choices, "choices"); + List 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 suggestions(CommandSender sender, String partial) { + return filter(list, partial); + } + }; + } + + private static List filter(List source, String partial) { + String lower = partial.toLowerCase(); + return source.stream() + .filter(value -> value.toLowerCase().startsWith(lower)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/fr/luc/crcore/command/BaseCommand.java b/src/main/java/fr/luc/crcore/command/BaseCommand.java new file mode 100644 index 0000000..4eb1e00 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/BaseCommand.java @@ -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 subCommandsByName = new LinkedHashMap<>(); + private final Map subCommandsByAlias = new HashMap<>(); + + protected BaseCommand(String name, String... aliases) { + super(name, aliases); + } + + protected final void addSubCommand(SubCommand sub) { + Objects.requireNonNull(sub, "sub"); + if (subCommandsByName.containsKey(sub.getName())) { + throw new IllegalStateException("Sub-command already registered: " + sub.getName()); + } + subCommandsByName.put(sub.getName(), sub); + for (String alias : sub.getAliases()) { + subCommandsByAlias.put(alias, sub); + } + } + + public final Collection getSubCommands() { + return Collections.unmodifiableCollection(subCommandsByName.values()); + } + + public final Optional findSubCommand(String label) { + if (label == null) return Optional.empty(); + String lc = label.toLowerCase(); + SubCommand sub = subCommandsByName.get(lc); + if (sub != null) return Optional.of(sub); + return Optional.ofNullable(subCommandsByAlias.get(lc)); + } + + /** + * Called when no sub-command matches the first argument. Override for custom + * fallback behavior. Default: lists available sub-commands. + */ + protected CommandResult execute(CommandContext context) { + if (subCommandsByName.isEmpty()) { + return CommandResult.invalidUsage("No action specified."); + } + StringBuilder sb = new StringBuilder(ChatColor.YELLOW + "Available sub-commands:"); + for (SubCommand sub : subCommandsByName.values()) { + sb.append('\n').append(ChatColor.GRAY).append(" - ") + .append(ChatColor.WHITE).append(sub.getName()); + if (!sub.getDescription().isEmpty()) { + sb.append(ChatColor.GRAY).append(" — ").append(sub.getDescription()); + } + } + context.reply(sb.toString()); + return CommandResult.success(); + } + + @Override + public final boolean onCommand(CommandSender sender, org.bukkit.command.Command command, + String label, String[] args) { + if (!checkAccess(sender, this)) { + return true; + } + + if (args.length == 0 || findSubCommand(args[0]).isEmpty()) { + CommandContext ctx = new CommandContext(sender, label, args, Collections.emptyMap()); + try { + handleResult(sender, execute(ctx)); + } catch (CommandException ex) { + sender.sendMessage(ChatColor.RED + ex.getMessage()); + } + return true; + } + + SubCommand sub = findSubCommand(args[0]).orElseThrow(); + if (!checkAccess(sender, sub)) { + return true; + } + + String[] subArgs = Arrays.copyOfRange(args, 1, args.length); + if (subArgs.length < sub.getRequiredArgumentCount()) { + sender.sendMessage(ChatColor.RED + "Usage: " + buildUsageFor(label, sub)); + return true; + } + + CommandContext ctx; + try { + ctx = sub.buildContext(sender, label, subArgs); + } catch (CommandException ex) { + sender.sendMessage(ChatColor.RED + ex.getMessage()); + return true; + } + + try { + handleResult(sender, sub.execute(ctx)); + } catch (CommandException ex) { + sender.sendMessage(ChatColor.RED + ex.getMessage()); + } + return true; + } + + @Override + public final List onTabComplete(CommandSender sender, org.bukkit.command.Command command, + String alias, String[] args) { + if (args.length == 0) return Collections.emptyList(); + + if (args.length == 1) { + String partial = args[0].toLowerCase(); + List names = new ArrayList<>(); + for (SubCommand sub : subCommandsByName.values()) { + if (sub.getPermission() != null && !sender.hasPermission(sub.getPermission())) { + continue; + } + names.add(sub.getName()); + names.addAll(sub.getAliases()); + } + return names.stream() + .filter(n -> n.startsWith(partial)) + .distinct() + .collect(Collectors.toList()); + } + + Optional subOpt = findSubCommand(args[0]); + if (subOpt.isEmpty()) return Collections.emptyList(); + SubCommand sub = subOpt.get(); + if (sub.getPermission() != null && !sender.hasPermission(sub.getPermission())) { + return Collections.emptyList(); + } + int argIndex = args.length - 2; + String partial = args[args.length - 1]; + return sub.tabComplete(sender, argIndex, partial); + } + + protected boolean checkAccess(CommandSender sender, Command target) { + if (target.getPermission() != null && !sender.hasPermission(target.getPermission())) { + sender.sendMessage(ChatColor.RED + "You don't have permission."); + return false; + } + if (target.isPlayerOnly() && !(sender instanceof Player)) { + sender.sendMessage(ChatColor.RED + "Only players can use this command."); + return false; + } + return true; + } + + protected String buildUsageFor(String label, SubCommand sub) { + StringBuilder sb = new StringBuilder("/").append(label).append(' ').append(sub.getName()); + for (ArgumentDef def : sub.getArgumentDefs()) { + sb.append(' '); + sb.append(def.isRequired() ? '<' : '['); + sb.append(def.getName()); + sb.append(def.isRequired() ? '>' : ']'); + } + return sb.toString(); + } + + 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."); + } + } +} diff --git a/src/main/java/fr/luc/crcore/command/Command.java b/src/main/java/fr/luc/crcore/command/Command.java new file mode 100644 index 0000000..b934803 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/Command.java @@ -0,0 +1,32 @@ +package fr.luc.crcore.command; + +import org.bukkit.command.CommandSender; + +import java.util.List; + +public interface Command { + + String getName(); + + List getAliases(); + + String getPermission(); + + boolean isPlayerOnly(); + + String getDescription(); + + CommandResult execute(CommandContext context); + + List 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; + } +} diff --git a/src/main/java/fr/luc/crcore/command/CommandContext.java b/src/main/java/fr/luc/crcore/command/CommandContext.java new file mode 100644 index 0000000..7ac7dfa --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/CommandContext.java @@ -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 parsedArgs; + + public CommandContext(CommandSender sender, String label, String[] rawArgs, + Map 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 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 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 Optional 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); + } +} diff --git a/src/main/java/fr/luc/crcore/command/CommandException.java b/src/main/java/fr/luc/crcore/command/CommandException.java new file mode 100644 index 0000000..e25e369 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/CommandException.java @@ -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); + } +} diff --git a/src/main/java/fr/luc/crcore/command/CommandResult.java b/src/main/java/fr/luc/crcore/command/CommandResult.java new file mode 100644 index 0000000..7455fc5 --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/CommandResult.java @@ -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); + } +} diff --git a/src/main/java/fr/luc/crcore/command/SubCommand.java b/src/main/java/fr/luc/crcore/command/SubCommand.java new file mode 100644 index 0000000..4e9abaf --- /dev/null +++ b/src/main/java/fr/luc/crcore/command/SubCommand.java @@ -0,0 +1,8 @@ +package fr.luc.crcore.command; + +public abstract class SubCommand extends AbstractCommand { + + protected SubCommand(String name, String... aliases) { + super(name, aliases); + } +} diff --git a/src/main/java/fr/luc/crcore/common/AbstractEntity.java b/src/main/java/fr/luc/crcore/common/AbstractEntity.java new file mode 100644 index 0000000..174848f --- /dev/null +++ b/src/main/java/fr/luc/crcore/common/AbstractEntity.java @@ -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(); + } +} diff --git a/src/main/java/fr/luc/crcore/common/Identifiable.java b/src/main/java/fr/luc/crcore/common/Identifiable.java new file mode 100644 index 0000000..dffee31 --- /dev/null +++ b/src/main/java/fr/luc/crcore/common/Identifiable.java @@ -0,0 +1,8 @@ +package fr.luc.crcore.common; + +import java.util.UUID; + +public interface Identifiable { + + UUID getId(); +} diff --git a/src/main/java/fr/luc/crcore/common/Named.java b/src/main/java/fr/luc/crcore/common/Named.java new file mode 100644 index 0000000..40c1840 --- /dev/null +++ b/src/main/java/fr/luc/crcore/common/Named.java @@ -0,0 +1,6 @@ +package fr.luc.crcore.common; + +public interface Named { + + String getName(); +} diff --git a/src/main/java/fr/luc/crcore/common/Repository.java b/src/main/java/fr/luc/crcore/common/Repository.java new file mode 100644 index 0000000..e860f1d --- /dev/null +++ b/src/main/java/fr/luc/crcore/common/Repository.java @@ -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 save(T entity); + + Optional findById(UUID id); + + Collection findAll(); + + boolean delete(UUID id); +} diff --git a/src/main/java/fr/luc/crcore/common/ScoreHolder.java b/src/main/java/fr/luc/crcore/common/ScoreHolder.java new file mode 100644 index 0000000..6559d03 --- /dev/null +++ b/src/main/java/fr/luc/crcore/common/ScoreHolder.java @@ -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 getScores(); + + int getTotalScore(); + + int addScore(String scoreName, int delta); + + int setScore(String scoreName, int value); + + boolean resetScore(String scoreName); + + void resetAllScores(); +} diff --git a/src/main/java/fr/luc/crcore/player/InMemoryPlayerProfileRepository.java b/src/main/java/fr/luc/crcore/player/InMemoryPlayerProfileRepository.java new file mode 100644 index 0000000..ee2ea4d --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/InMemoryPlayerProfileRepository.java @@ -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 profiles = new LinkedHashMap<>(); + + @Override + public PlayerProfile save(PlayerProfile profile) { + Objects.requireNonNull(profile, "profile"); + profiles.put(profile.getId(), profile); + return profile; + } + + @Override + public Optional findById(UUID id) { + Objects.requireNonNull(id, "id"); + return Optional.ofNullable(profiles.get(id)); + } + + @Override + public Collection findAll() { + return Collections.unmodifiableCollection(profiles.values()); + } + + @Override + public boolean delete(UUID id) { + Objects.requireNonNull(id, "id"); + return profiles.remove(id) != null; + } +} diff --git a/src/main/java/fr/luc/crcore/player/PlayerException.java b/src/main/java/fr/luc/crcore/player/PlayerException.java new file mode 100644 index 0000000..432d531 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/PlayerException.java @@ -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); + } +} diff --git a/src/main/java/fr/luc/crcore/player/PlayerProfile.java b/src/main/java/fr/luc/crcore/player/PlayerProfile.java new file mode 100644 index 0000000..6790020 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/PlayerProfile.java @@ -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 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 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(); + } +} diff --git a/src/main/java/fr/luc/crcore/player/PlayerProfileNotFoundException.java b/src/main/java/fr/luc/crcore/player/PlayerProfileNotFoundException.java new file mode 100644 index 0000000..e823822 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/PlayerProfileNotFoundException.java @@ -0,0 +1,8 @@ +package fr.luc.crcore.player; + +public class PlayerProfileNotFoundException extends PlayerException { + + public PlayerProfileNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/fr/luc/crcore/player/PlayerProfileRepository.java b/src/main/java/fr/luc/crcore/player/PlayerProfileRepository.java new file mode 100644 index 0000000..8b708b9 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/PlayerProfileRepository.java @@ -0,0 +1,6 @@ +package fr.luc.crcore.player; + +import fr.luc.crcore.common.Repository; + +public interface PlayerProfileRepository extends Repository { +} diff --git a/src/main/java/fr/luc/crcore/player/PlayerProfileService.java b/src/main/java/fr/luc/crcore/player/PlayerProfileService.java new file mode 100644 index 0000000..f5b4f38 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/PlayerProfileService.java @@ -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 getProfile(UUID playerId); + + boolean deleteProfile(UUID playerId); + + Collection 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 getRankingByScore(String scoreName); + + List getGlobalRanking(); + + default List getTopRankingByScore(String scoreName, int limit) { + return getRankingByScore(scoreName).stream().limit(limit).collect(Collectors.toList()); + } + + default List getTopGlobalRanking(int limit) { + return getGlobalRanking().stream().limit(limit).collect(Collectors.toList()); + } +} diff --git a/src/main/java/fr/luc/crcore/player/PlayerProfileServiceImpl.java b/src/main/java/fr/luc/crcore/player/PlayerProfileServiceImpl.java new file mode 100644 index 0000000..9423090 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/PlayerProfileServiceImpl.java @@ -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 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 getProfile(UUID playerId) { + return repository.findById(playerId); + } + + @Override + public boolean deleteProfile(UUID playerId) { + Objects.requireNonNull(playerId, "playerId"); + Optional profileOpt = repository.findById(playerId); + if (profileOpt.isEmpty()) return false; + boolean deleted = repository.delete(playerId); + if (deleted) onProfileDeleted(profileOpt.get()); + return deleted; + } + + @Override + public Collection 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 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 profileOpt = repository.findById(playerId); + if (profileOpt.isEmpty()) return; + PlayerProfile profile = profileOpt.get(); + Map 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 getRankingByScore(String scoreName) { + Objects.requireNonNull(scoreName, "scoreName"); + return rank(profile -> profile.getScore(scoreName)); + } + + @Override + public List getGlobalRanking() { + return rank(PlayerProfile::getTotalScore); + } + + protected List rank(ToIntFunction scoreFn) { + Collection all = repository.findAll(); + List sorted = new ArrayList<>(all); + sorted.sort(Comparator + .comparingInt(scoreFn).reversed() + .thenComparing(PlayerProfile::getId)); + List 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) { + } +} diff --git a/src/main/java/fr/luc/crcore/player/PlayerRanking.java b/src/main/java/fr/luc/crcore/player/PlayerRanking.java new file mode 100644 index 0000000..01e0e23 --- /dev/null +++ b/src/main/java/fr/luc/crcore/player/PlayerRanking.java @@ -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); + } + } +} diff --git a/src/main/java/fr/luc/crcore/team/InMemoryTeamRepository.java b/src/main/java/fr/luc/crcore/team/InMemoryTeamRepository.java new file mode 100644 index 0000000..84dd540 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/InMemoryTeamRepository.java @@ -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 teams = new LinkedHashMap<>(); + + @Override + public Team save(Team team) { + Objects.requireNonNull(team, "team"); + teams.put(team.getId(), team); + return team; + } + + @Override + public Optional findById(UUID id) { + Objects.requireNonNull(id, "id"); + return Optional.ofNullable(teams.get(id)); + } + + @Override + public Collection findAll() { + return Collections.unmodifiableCollection(teams.values()); + } + + @Override + public boolean delete(UUID id) { + Objects.requireNonNull(id, "id"); + return teams.remove(id) != null; + } + + @Override + public Optional findByName(String name) { + Objects.requireNonNull(name, "name"); + return teams.values().stream() + .filter(team -> team.getName().equalsIgnoreCase(name)) + .findFirst(); + } + + @Override + public Optional findByTag(String tag) { + Objects.requireNonNull(tag, "tag"); + return teams.values().stream() + .filter(team -> team.getTag().equalsIgnoreCase(tag)) + .findFirst(); + } + + @Override + public Optional findByMember(UUID playerId) { + Objects.requireNonNull(playerId, "playerId"); + return teams.values().stream() + .filter(team -> team.hasMember(playerId)) + .findFirst(); + } +} diff --git a/src/main/java/fr/luc/crcore/team/Team.java b/src/main/java/fr/luc/crcore/team/Team.java new file mode 100644 index 0000000..09f5af1 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/Team.java @@ -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 members; + private final Map 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 getMembers() { + return Collections.unmodifiableSet(members); + } + + public Optional 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 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 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 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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamAccessException.java b/src/main/java/fr/luc/crcore/team/TeamAccessException.java new file mode 100644 index 0000000..c9fc0cf --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamAccessException.java @@ -0,0 +1,8 @@ +package fr.luc.crcore.team; + +public class TeamAccessException extends TeamException { + + public TeamAccessException(String message) { + super(message); + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamAlreadyExistsException.java b/src/main/java/fr/luc/crcore/team/TeamAlreadyExistsException.java new file mode 100644 index 0000000..69462a9 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamAlreadyExistsException.java @@ -0,0 +1,8 @@ +package fr.luc.crcore.team; + +public class TeamAlreadyExistsException extends TeamException { + + public TeamAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamColor.java b/src/main/java/fr/luc/crcore/team/TeamColor.java new file mode 100644 index 0000000..769536a --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamColor.java @@ -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; + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamException.java b/src/main/java/fr/luc/crcore/team/TeamException.java new file mode 100644 index 0000000..10945c4 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamException.java @@ -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); + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamMember.java b/src/main/java/fr/luc/crcore/team/TeamMember.java new file mode 100644 index 0000000..e19c606 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamMember.java @@ -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); + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamNotFoundException.java b/src/main/java/fr/luc/crcore/team/TeamNotFoundException.java new file mode 100644 index 0000000..7dfe02f --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamNotFoundException.java @@ -0,0 +1,8 @@ +package fr.luc.crcore.team; + +public class TeamNotFoundException extends TeamException { + + public TeamNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamRanking.java b/src/main/java/fr/luc/crcore/team/TeamRanking.java new file mode 100644 index 0000000..85a2884 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamRanking.java @@ -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); + } + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamRepository.java b/src/main/java/fr/luc/crcore/team/TeamRepository.java new file mode 100644 index 0000000..9459d12 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamRepository.java @@ -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 { + + Optional findByName(String name); + + Optional findByTag(String tag); + + Optional findByMember(UUID playerId); +} diff --git a/src/main/java/fr/luc/crcore/team/TeamRole.java b/src/main/java/fr/luc/crcore/team/TeamRole.java new file mode 100644 index 0000000..4e5857f --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamRole.java @@ -0,0 +1,11 @@ +package fr.luc.crcore.team; + +public enum TeamRole { + + LEADER, + MEMBER; + + public boolean isLeader() { + return this == LEADER; + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamService.java b/src/main/java/fr/luc/crcore/team/TeamService.java new file mode 100644 index 0000000..368ee50 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamService.java @@ -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 getRankingByScore(String scoreName); + + List getGlobalRanking(); + + default List getTopRankingByScore(String scoreName, int limit) { + return getRankingByScore(scoreName).stream().limit(limit).collect(Collectors.toList()); + } + + default List getTopGlobalRanking(int limit) { + return getGlobalRanking().stream().limit(limit).collect(Collectors.toList()); + } + + // ---- Spawn point ---- + + void setSpawnPoint(UUID teamId, Location location); + + void clearSpawnPoint(UUID teamId); + + Optional getSpawnPoint(UUID teamId); + + // ---- Queries ---- + + Optional getTeam(UUID teamId); + + Optional getTeamByName(String name); + + Optional getTeamByTag(String tag); + + Optional getTeamOfPlayer(UUID playerId); + + Collection getAllTeams(); +} diff --git a/src/main/java/fr/luc/crcore/team/TeamServiceImpl.java b/src/main/java/fr/luc/crcore/team/TeamServiceImpl.java new file mode 100644 index 0000000..e1032e3 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamServiceImpl.java @@ -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 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 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 getRankingByScore(String scoreName) { + Objects.requireNonNull(scoreName, "scoreName"); + return rank(team -> team.getScore(scoreName)); + } + + @Override + public List getGlobalRanking() { + return rank(Team::getTotalScore); + } + + protected List rank(ToIntFunction scoreFn) { + Collection all = repository.findAll(); + List sorted = new ArrayList<>(all); + sorted.sort(Comparator + .comparingInt(scoreFn).reversed() + .thenComparing(Team::getName, String.CASE_INSENSITIVE_ORDER)); + List 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 getSpawnPoint(UUID teamId) { + return requireTeam(teamId).getSpawnPoint(); + } + + // ---- Queries ---- + + @Override + public Optional getTeam(UUID teamId) { + return repository.findById(teamId); + } + + @Override + public Optional getTeamByName(String name) { + return repository.findByName(name); + } + + @Override + public Optional getTeamByTag(String tag) { + return repository.findByTag(tag); + } + + @Override + public Optional getTeamOfPlayer(UUID playerId) { + return repository.findByMember(playerId); + } + + @Override + public Collection 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)); + } +} diff --git a/src/main/java/fr/luc/crcore/team/TeamVisibility.java b/src/main/java/fr/luc/crcore/team/TeamVisibility.java new file mode 100644 index 0000000..607d572 --- /dev/null +++ b/src/main/java/fr/luc/crcore/team/TeamVisibility.java @@ -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; + } +}