Add JSON configuration support and new team management commands

Introduced JSON-based configuration handling for teams using Jackson and validation utilities. Added commands for reloading configurations and creating teams with support for saving and loading team data. Refactored related classes to integrate with the new system.
This commit is contained in:
Teriuihi 2025-01-24 20:12:27 +01:00
parent 6e38d42f2d
commit 5a87784d71
17 changed files with 438 additions and 25 deletions

View File

@ -15,8 +15,27 @@ dependencies {
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
// Start JSON config dependencies
// Bean Validation API
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
// Hibernate Validator (implementation)
implementation("org.hibernate:hibernate-validator:8.0.2.Final")
// Validation annotations processing
implementation("org.hibernate:hibernate-validator-annotation-processor:8.0.2.Final")
// Jackson/Dynamic Beans Integration
implementation("com.fasterxml.jackson.module:jackson-module-parameter-names:2.15.2")
// Jackson for JSON Parsing
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.2")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.15.2")
// End JSON config dependencies
}
tasks.test {
useJUnitPlatform()
}
tasks.jar {
archiveFileName.set("CaptureTheFlag.jar")
}

View File

@ -2,31 +2,74 @@ package com.alttd.ctf;
import com.alttd.ctf.commands.CommandManager;
import com.alttd.ctf.config.Config;
import com.alttd.ctf.config.GameConfig;
import com.alttd.ctf.config.Messages;
import com.alttd.ctf.events.OnSnowballHit;
import com.alttd.ctf.game.GameManager;
import com.alttd.ctf.json_config.JacksonConfig;
import com.alttd.ctf.json_config.JsonConfigManager;
import com.alttd.ctf.team.Team;
import lombok.extern.slf4j.Slf4j;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class Main extends JavaPlugin {
private GameManager gameManager = null;
@Override
public void onEnable() {
log.info("Plugin enabled!");
reloadConfigs();
GameManager gameManager = new GameManager();
this.gameManager = new GameManager();
registerTeams(); //Skipped in reloadConfig if gameManager is not created yet
CommandManager commandManager = new CommandManager(this, gameManager);
registerEvents(gameManager);
registerEvents();
}
public void reloadConfigs() {
Config.reload(this);
Messages.reload(this);
GameConfig.reload(this);
if (gameManager != null) {
registerTeams();
}
}
private void registerEvents(GameManager gameManager) {
private void registerEvents() {
PluginManager pluginManager = getServer().getPluginManager();
pluginManager.registerEvents(new OnSnowballHit(gameManager), this);
}
private void registerTeams() {
JsonConfigManager<Team> config = new JsonConfigManager<>(JacksonConfig.configureMapper());
List<Team> teams;
try {
File teamsDirectory = new File(getDataFolder(), "teams");
if (!teamsDirectory.exists() && !teamsDirectory.mkdirs()) {
log.error("Unable to make teams directory at {} shutting down plugin", teamsDirectory.getAbsolutePath());
}
teams = config.loadConfigs(Team.class, teamsDirectory);
} catch (IOException e) {
log.error("Unable to load teams, shutting down plugin", e);
getServer().getPluginManager().disablePlugin(this);
return;
}
teams.stream()
.collect(Collectors.groupingBy(Team::getId, Collectors.counting()))
.forEach((id, count) -> {
if (count > 1) {
log.warn("Duplicate team ID found: {}", id);
}
});
teams.forEach(gameManager::registerTeam);
}
}

View File

@ -2,6 +2,8 @@ package com.alttd.ctf.commands;
import com.alttd.ctf.Main;
import com.alttd.ctf.commands.subcommands.ChangeTeam;
import com.alttd.ctf.commands.subcommands.CreateTeam;
import com.alttd.ctf.commands.subcommands.Reload;
import com.alttd.ctf.commands.subcommands.Start;
import com.alttd.ctf.config.Messages;
import com.alttd.ctf.game.GameManager;
@ -34,7 +36,9 @@ public class CommandManager implements CommandExecutor, TabExecutor {
subCommands = Arrays.asList(
new ChangeTeam(gameManager),
new Start(gameManager)
new Start(gameManager),
new CreateTeam(main, gameManager),
new Reload(main)
);
}

View File

@ -4,6 +4,7 @@ import com.alttd.ctf.commands.SubCommand;
import com.alttd.ctf.config.Messages;
import com.alttd.ctf.game.GameManager;
import com.alttd.ctf.team.Team;
import lombok.AllArgsConstructor;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver;
import org.bukkit.Bukkit;
@ -13,14 +14,11 @@ import org.bukkit.entity.Player;
import java.util.List;
import java.util.Optional;
@AllArgsConstructor
public class ChangeTeam extends SubCommand {
private final GameManager gameManager;
public ChangeTeam(GameManager gameManager) {
this.gameManager = gameManager;
}
@FunctionalInterface
private interface ChangeTeamConsumer {
int apply(Player player, Team team);
@ -66,7 +64,7 @@ public class ChangeTeam extends SubCommand {
return;
}
gameManager.registerPlayer(team, player);
commandSender.sendRichMessage("<green><player> has been placed in<team>.</green>",
commandSender.sendRichMessage("<green><player> has been placed in <team>.</green>",
TagResolver.resolver(
Placeholder.component("player", player.displayName()),
Placeholder.component("team", team.getName())));

View File

@ -0,0 +1,100 @@
package com.alttd.ctf.commands.subcommands;
import com.alttd.ctf.Main;
import com.alttd.ctf.commands.SubCommand;
import com.alttd.ctf.config.Messages;
import com.alttd.ctf.game.GameManager;
import com.alttd.ctf.json_config.JacksonConfig;
import com.alttd.ctf.json_config.JsonConfigManager;
import com.alttd.ctf.team.Team;
import com.alttd.ctf.team.TeamColor;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import java.awt.*;
import java.io.File;
import java.io.IOException;
import java.util.List;
@Slf4j
@AllArgsConstructor
public class CreateTeam extends SubCommand {
private final Main main;
private final GameManager gameManager;
private final JsonConfigManager<Team> jsonConfigManager = new JsonConfigManager<>(JacksonConfig.configureMapper());
@FunctionalInterface
private interface CreateTeamConsumer {
int apply(Team team);
}
@Override
public int onCommand(CommandSender commandSender, String[] args) {
return handle(commandSender, args, team -> {
File teamsDirectory = new File(main.getDataFolder(), "teams");
try {
jsonConfigManager.saveConfig(team, teamsDirectory, String.valueOf(team.getId()));
} catch (IOException e) {
log.error("Unable to save config teams config with id {}.", team.getId(), e);
commandSender.sendRichMessage("<red>Unable to save team</red>");
return -1;
}
gameManager.registerTeam(team);
commandSender.sendRichMessage("<green>Created team <team> and registered them.</green>",
Placeholder.component("team", team.getName()));
return 0;
});
}
private int handle(CommandSender commandSender, String[] args, CreateTeamConsumer consumer) {
if (args.length != 3) {
return -1;
}
if (!(commandSender instanceof Player player)) {
commandSender.sendRichMessage(Messages.GENERIC.PLAYER_ONLY);
return -1;
}
String name = args[1];
if (name.length() > 16) {
commandSender.sendRichMessage("<red>name needs to be between 3 and 16 characters</red>");
return 1;
}
String color = args[2];
if (!color.matches("^#[0-9a-fA-F]{6}$")) {
commandSender.sendRichMessage("<red>Invalid hex color, style it like #FF00FF</red>");
return 2;
}
Color decodedColor = Color.decode(color);
TeamColor teamColor = new TeamColor(decodedColor.getRed(), decodedColor.getGreen(), decodedColor.getBlue(), color);
int highestId = gameManager.getTeams().stream()
.mapToInt(Team::getId)
.max()
.orElse(0);
Team team = new Team(MiniMessage.miniMessage().deserialize(String.format("<color:%s>%s</color>", color, name)),
highestId + 1, player.getLocation(), player.getLocation(), teamColor);
return consumer.apply(team);
}
@Override
public String getName() {
return "createteam";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
return List.of();
}
@Override
public String getHelpMessage() {
return Messages.HELP.CREATE_TEAM;
}
}

View File

@ -0,0 +1,37 @@
package com.alttd.ctf.commands.subcommands;
import com.alttd.ctf.Main;
import com.alttd.ctf.commands.SubCommand;
import com.alttd.ctf.config.Messages;
import lombok.AllArgsConstructor;
import org.bukkit.command.CommandSender;
import java.util.List;
@AllArgsConstructor
public class Reload extends SubCommand {
private final Main main;
@Override
public int onCommand(CommandSender commandSender, String[] args) {
main.reloadConfig();
commandSender.sendRichMessage("<green>Reloaded the configuration files</green>");
return 0;
}
@Override
public String getName() {
return "reload";
}
@Override
public List<String> getTabComplete(CommandSender commandSender, String[] args) {
return List.of();
}
@Override
public String getHelpMessage() {
return Messages.HELP.RELOAD;
}
}

View File

@ -22,14 +22,18 @@ public class Messages extends AbstractConfig {
public static String HELP_MESSAGE_WRAPPER = "<gold>Main help:\n<commands></gold>";
public static String HELP_MESSAGE = "<green>Show this menu: <gold>/ctf help</gold></green>";
public static String RELOAD = "<green>Reload the configs: <gold>/ctf reload</gold></green>";
public static String CHANGE_TEAM = "<green>Change a players team: <gold>/ctf changeteam <player> <team></gold></green>";
public static String CREATE_TEAM = "<green>Create a team: <gold>/ctf createteam <team_name> <hex_color></gold></green>";
public static String START = "<green>Start a new game: <gold>/ctf start <time_in_minutes></gold></green>";
@SuppressWarnings("unused")
private static void load() {
HELP_MESSAGE_WRAPPER = config.getString(prefix, "help-wrapper", HELP_MESSAGE_WRAPPER);
HELP_MESSAGE = config.getString(prefix, "help", HELP_MESSAGE);
RELOAD = config.getString(prefix, "reload", RELOAD);
CHANGE_TEAM = config.getString(prefix, "change-team", CHANGE_TEAM);
CREATE_TEAM = config.getString(prefix, "create-team", CREATE_TEAM);
START = config.getString(prefix, "start", START);
}
}

View File

@ -41,6 +41,10 @@ public class GameManager {
teams.values().forEach(team -> team.removePlayer(player.getUniqueId()));
}
public void registerTeam(Team team) {
teams.put(team.getId(), team);
}
public Collection<Team> getTeams() {
return teams.values();
}

View File

@ -0,0 +1,24 @@
package com.alttd.ctf.json_config;
import com.alttd.ctf.json_config.serializers.component.ComponentSerializer;
import com.alttd.ctf.json_config.serializers.component.ComponentDeserializer;
import com.alttd.ctf.json_config.serializers.location.LocationDeserializer;
import com.alttd.ctf.json_config.serializers.location.LocationSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
public class JacksonConfig {
public static ObjectMapper configureMapper() {
ObjectMapper mapper = new ObjectMapper();
SimpleModule module = new SimpleModule();
module.addSerializer(Component.class, new ComponentSerializer());
module.addDeserializer(Component.class, new ComponentDeserializer());
module.addSerializer(Location.class, new LocationSerializer());
module.addDeserializer(Location.class, new LocationDeserializer());
mapper.registerModule(module);
return mapper;
}
}

View File

@ -0,0 +1,44 @@
package com.alttd.ctf.json_config;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
public class JsonConfigManager<T> {
private final ObjectMapper objectMapper;
public JsonConfigManager(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public List<T> loadConfigs(Class<T> clazz, File directory) throws IOException {
List<T> configs = new ArrayList<>();
if (!directory.exists() || !directory.isDirectory()) {
throw new IllegalArgumentException("Invalid directory path: " + directory.getAbsolutePath());
}
File[] files = directory.listFiles((dir, name) -> name.endsWith(".json"));
if (files == null) {
return configs;
}
for (File file : files) {
T config = objectMapper.readValue(file, clazz);
ValidationUtil.validate(config);
configs.add(config);
}
return configs;
}
public void saveConfig(T object, File directory, String fileName) throws IOException {
objectMapper.writeValue(new File(directory, fileName + ".json"), object);
}
}

View File

@ -0,0 +1,26 @@
package com.alttd.ctf.json_config;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import java.util.Set;
public class ValidationUtil {
private static final ValidatorFactory VALIDATOR_FACTORY = Validation.buildDefaultValidatorFactory();
private static final Validator VALIDATOR = VALIDATOR_FACTORY.getValidator();
public static <T> void validate(T object) {
Set<ConstraintViolation<T>> violations = VALIDATOR.validate(object);
if (!violations.isEmpty()) {
StringBuilder errorMessage = new StringBuilder("Validation errors:\n");
for (ConstraintViolation<T> violation : violations) {
errorMessage.append("- ").append(violation.getPropertyPath()).append(": ").append(violation.getMessage()).append("\n");
}
throw new IllegalArgumentException(errorMessage.toString());
}
}
}

View File

@ -0,0 +1,28 @@
package com.alttd.ctf.json_config.serializers.component;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import java.io.IOException;
public class ComponentDeserializer extends JsonDeserializer<Component> {
@Override
public Component deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
String json = jsonParser.getText();
try {
return GsonComponentSerializer.gson().deserialize(json);
} catch (Exception e) {
deserializationContext.reportInputMismatch(
Component.class,
"Failed to deserialize Component JSON: %s. Error: %s",
json, e.getMessage()
);
return null;
}
}
}

View File

@ -0,0 +1,18 @@
package com.alttd.ctf.json_config.serializers.component;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import java.io.IOException;
public class ComponentSerializer extends JsonSerializer<Component> {
@Override
public void serialize(Component value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
String serialized = GsonComponentSerializer.gson().serialize(value);
gen.writeString(serialized);
}
}

View File

@ -0,0 +1,35 @@
package com.alttd.ctf.json_config.serializers.location;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import java.io.IOException;
public class LocationDeserializer extends JsonDeserializer<Location> {
@Override
public Location deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
JsonNode node = jsonParser.getCodec().readTree(jsonParser);
try {
World world = Bukkit.getWorld(node.get("world").asText());
double x = node.get("x").asDouble();
double y = node.get("y").asDouble();
double z = node.get("z").asDouble();
float yaw = (float) node.get("yaw").asDouble();
float pitch = (float) node.get("pitch").asDouble();
return new Location(world, x, y, z, yaw, pitch);
} catch (Exception e) {
deserializationContext.reportInputMismatch(
Location.class,
"Failed to deserialize Component JSON: %s. Error: %s",
node.asText(), e.getMessage()
);
return null;
}
}
}

View File

@ -0,0 +1,23 @@
package com.alttd.ctf.json_config.serializers.location;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.bukkit.Location;
import java.io.IOException;
public class LocationSerializer extends JsonSerializer<Location> {
@Override
public void serialize(Location value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeStartObject();
gen.writeStringField("world", value.getWorld().getName());
gen.writeNumberField("x", value.getX());
gen.writeNumberField("y", value.getY());
gen.writeNumberField("z", value.getZ());
gen.writeNumberField("yaw", value.getYaw());
gen.writeNumberField("pitch", value.getPitch());
gen.writeEndObject();
}
}

View File

@ -1,43 +1,40 @@
package com.alttd.ctf.team;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.jetbrains.annotations.NotNull;
import java.util.*;
@Slf4j
@AllArgsConstructor
public class Team {
private final HashMap<UUID, TeamPlayer> players = new HashMap<>();
@JsonProperty("name")
@NotNull
@Getter
private Component name;
@JsonProperty("id")
@Getter
private final int id;
@JsonProperty("spawnLocation")
@NotNull
@Getter
private Location spawnLocation;
@JsonProperty("worldBorderCenter")
@NotNull
@Getter
private Location worldBorderCenter; //TODO https://github.com/yannicklamprecht/WorldBorderAPI/blob/main/how-to-use.md
//TODO store team color to be used for kits and chat colors (in rgb?)
@JsonProperty("teamColor")
@NotNull
@Getter
private TeamColor color;
public Team(int id) {
this.id = id;
reloadTeamData();
}
private void reloadTeamData() {
this.color = new TeamColor(255, 0, 0, "#FF0000");
this.name = MiniMessage.miniMessage().deserialize(String.format("<color:#%s>Test Team</color>", color.hex()));
this.spawnLocation = Bukkit.getWorld("world").getSpawnLocation();
//TODO load team data from config
}
public void addPlayer(UUID uuid) {
players.put(uuid, new TeamPlayer(uuid, this));
log.debug("Added player with uuid {} to team with id {}", uuid, id);

View File

@ -1,4 +1,13 @@
package com.alttd.ctf.team;
public record TeamColor(int r, int g, int b, String hex) {
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
public record TeamColor(
@JsonProperty("red") @NotNull int r,
@JsonProperty("green") @NotNull int g,
@JsonProperty("blue") @NotNull int b,
@JsonProperty("hex") @NotNull @Pattern(regexp = "^#[A-Fa-f0-9]{6}$") String hex
) {
}