diff --git a/build.gradle.kts b/build.gradle.kts index 8e05b47..f68b1d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") } \ No newline at end of file diff --git a/src/main/java/com/alttd/ctf/Main.java b/src/main/java/com/alttd/ctf/Main.java index ccd9e8e..5e3124f 100644 --- a/src/main/java/com/alttd/ctf/Main.java +++ b/src/main/java/com/alttd/ctf/Main.java @@ -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 config = new JsonConfigManager<>(JacksonConfig.configureMapper()); + List 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); + } + } \ No newline at end of file diff --git a/src/main/java/com/alttd/ctf/commands/CommandManager.java b/src/main/java/com/alttd/ctf/commands/CommandManager.java index e6c3be7..4877529 100644 --- a/src/main/java/com/alttd/ctf/commands/CommandManager.java +++ b/src/main/java/com/alttd/ctf/commands/CommandManager.java @@ -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) ); } diff --git a/src/main/java/com/alttd/ctf/commands/subcommands/ChangeTeam.java b/src/main/java/com/alttd/ctf/commands/subcommands/ChangeTeam.java index 3d02e17..3329b32 100644 --- a/src/main/java/com/alttd/ctf/commands/subcommands/ChangeTeam.java +++ b/src/main/java/com/alttd/ctf/commands/subcommands/ChangeTeam.java @@ -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(" has been placed in.", + commandSender.sendRichMessage(" has been placed in .", TagResolver.resolver( Placeholder.component("player", player.displayName()), Placeholder.component("team", team.getName()))); diff --git a/src/main/java/com/alttd/ctf/commands/subcommands/CreateTeam.java b/src/main/java/com/alttd/ctf/commands/subcommands/CreateTeam.java new file mode 100644 index 0000000..f34534f --- /dev/null +++ b/src/main/java/com/alttd/ctf/commands/subcommands/CreateTeam.java @@ -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 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("Unable to save team"); + return -1; + } + gameManager.registerTeam(team); + commandSender.sendRichMessage("Created team and registered them.", + 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("name needs to be between 3 and 16 characters"); + return 1; + } + + String color = args[2]; + if (!color.matches("^#[0-9a-fA-F]{6}$")) { + commandSender.sendRichMessage("Invalid hex color, style it like #FF00FF"); + 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("%s", color, name)), + highestId + 1, player.getLocation(), player.getLocation(), teamColor); + + return consumer.apply(team); + } + + @Override + public String getName() { + return "createteam"; + } + + @Override + public List getTabComplete(CommandSender commandSender, String[] args) { + return List.of(); + } + + @Override + public String getHelpMessage() { + return Messages.HELP.CREATE_TEAM; + } +} diff --git a/src/main/java/com/alttd/ctf/commands/subcommands/Reload.java b/src/main/java/com/alttd/ctf/commands/subcommands/Reload.java new file mode 100644 index 0000000..672d263 --- /dev/null +++ b/src/main/java/com/alttd/ctf/commands/subcommands/Reload.java @@ -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("Reloaded the configuration files"); + return 0; + } + + @Override + public String getName() { + return "reload"; + } + + @Override + public List getTabComplete(CommandSender commandSender, String[] args) { + return List.of(); + } + + @Override + public String getHelpMessage() { + return Messages.HELP.RELOAD; + } +} diff --git a/src/main/java/com/alttd/ctf/config/Messages.java b/src/main/java/com/alttd/ctf/config/Messages.java index e4f7373..d5c2783 100644 --- a/src/main/java/com/alttd/ctf/config/Messages.java +++ b/src/main/java/com/alttd/ctf/config/Messages.java @@ -22,14 +22,18 @@ public class Messages extends AbstractConfig { public static String HELP_MESSAGE_WRAPPER = "Main help:\n"; public static String HELP_MESSAGE = "Show this menu: /ctf help"; + public static String RELOAD = "Reload the configs: /ctf reload"; public static String CHANGE_TEAM = "Change a players team: /ctf changeteam "; + public static String CREATE_TEAM = "Create a team: /ctf createteam "; public static String START = "Start a new game: /ctf start "; @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); } } diff --git a/src/main/java/com/alttd/ctf/game/GameManager.java b/src/main/java/com/alttd/ctf/game/GameManager.java index d3d711e..c6edfa4 100644 --- a/src/main/java/com/alttd/ctf/game/GameManager.java +++ b/src/main/java/com/alttd/ctf/game/GameManager.java @@ -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 getTeams() { return teams.values(); } diff --git a/src/main/java/com/alttd/ctf/json_config/JacksonConfig.java b/src/main/java/com/alttd/ctf/json_config/JacksonConfig.java new file mode 100644 index 0000000..f204cdf --- /dev/null +++ b/src/main/java/com/alttd/ctf/json_config/JacksonConfig.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/alttd/ctf/json_config/JsonConfigManager.java b/src/main/java/com/alttd/ctf/json_config/JsonConfigManager.java new file mode 100644 index 0000000..6910870 --- /dev/null +++ b/src/main/java/com/alttd/ctf/json_config/JsonConfigManager.java @@ -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 { + + private final ObjectMapper objectMapper; + + public JsonConfigManager(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public List loadConfigs(Class clazz, File directory) throws IOException { + List 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); + } +} diff --git a/src/main/java/com/alttd/ctf/json_config/ValidationUtil.java b/src/main/java/com/alttd/ctf/json_config/ValidationUtil.java new file mode 100644 index 0000000..01f0625 --- /dev/null +++ b/src/main/java/com/alttd/ctf/json_config/ValidationUtil.java @@ -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 void validate(T object) { + Set> violations = VALIDATOR.validate(object); + + if (!violations.isEmpty()) { + StringBuilder errorMessage = new StringBuilder("Validation errors:\n"); + for (ConstraintViolation violation : violations) { + errorMessage.append("- ").append(violation.getPropertyPath()).append(": ").append(violation.getMessage()).append("\n"); + } + throw new IllegalArgumentException(errorMessage.toString()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/alttd/ctf/json_config/serializers/component/ComponentDeserializer.java b/src/main/java/com/alttd/ctf/json_config/serializers/component/ComponentDeserializer.java new file mode 100644 index 0000000..99f0e08 --- /dev/null +++ b/src/main/java/com/alttd/ctf/json_config/serializers/component/ComponentDeserializer.java @@ -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 { + + @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; + } + } + +} diff --git a/src/main/java/com/alttd/ctf/json_config/serializers/component/ComponentSerializer.java b/src/main/java/com/alttd/ctf/json_config/serializers/component/ComponentSerializer.java new file mode 100644 index 0000000..e7eb28e --- /dev/null +++ b/src/main/java/com/alttd/ctf/json_config/serializers/component/ComponentSerializer.java @@ -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 { + + @Override + public void serialize(Component value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + String serialized = GsonComponentSerializer.gson().serialize(value); + gen.writeString(serialized); + } +} diff --git a/src/main/java/com/alttd/ctf/json_config/serializers/location/LocationDeserializer.java b/src/main/java/com/alttd/ctf/json_config/serializers/location/LocationDeserializer.java new file mode 100644 index 0000000..c0527e1 --- /dev/null +++ b/src/main/java/com/alttd/ctf/json_config/serializers/location/LocationDeserializer.java @@ -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 { + + @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; + } + } +} diff --git a/src/main/java/com/alttd/ctf/json_config/serializers/location/LocationSerializer.java b/src/main/java/com/alttd/ctf/json_config/serializers/location/LocationSerializer.java new file mode 100644 index 0000000..3e4396d --- /dev/null +++ b/src/main/java/com/alttd/ctf/json_config/serializers/location/LocationSerializer.java @@ -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 { + + @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(); + } +} diff --git a/src/main/java/com/alttd/ctf/team/Team.java b/src/main/java/com/alttd/ctf/team/Team.java index 9cc085d..7e0c6e2 100644 --- a/src/main/java/com/alttd/ctf/team/Team.java +++ b/src/main/java/com/alttd/ctf/team/Team.java @@ -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 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("Test Team", 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); diff --git a/src/main/java/com/alttd/ctf/team/TeamColor.java b/src/main/java/com/alttd/ctf/team/TeamColor.java index 695c606..f60c24d 100644 --- a/src/main/java/com/alttd/ctf/team/TeamColor.java +++ b/src/main/java/com/alttd/ctf/team/TeamColor.java @@ -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 +) { }