diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml index 9092587..18f7b3b 100644 --- a/dependency-reduced-pom.xml +++ b/dependency-reduced-pom.xml @@ -155,7 +155,7 @@ io.papermc.paper paper-api - 1.21.8-R0.1-SNAPSHOT + 1.21.4-R0.1-SNAPSHOT provided diff --git a/src/main/java/net/greenfieldmc/core/GreenfieldCore.java b/src/main/java/net/greenfieldmc/core/GreenfieldCore.java index 64ec303..dbb17a9 100644 --- a/src/main/java/net/greenfieldmc/core/GreenfieldCore.java +++ b/src/main/java/net/greenfieldmc/core/GreenfieldCore.java @@ -9,6 +9,7 @@ import net.greenfieldmc.core.paintingswitch.PaintingSwitchModule; import net.greenfieldmc.core.powershovel.PowerShovelModule; import net.greenfieldmc.core.redblock.RedblockModule; +import net.greenfieldmc.core.signmanager.SignManagerModule; import net.greenfieldmc.core.templates.TemplatesModule; import net.greenfieldmc.core.testresult.TestResultModule; import net.greenfieldmc.core.utilities.UtilitiesModule; @@ -40,7 +41,8 @@ public void onEnable() { new AdvancedBuildModule(this, ModuleConfig::isAdvancedBuildModeEnabled), new RedblockModule(this, ModuleConfig::isRedblockEnabled), new ChatFormatModule(this, ModuleConfig::isChatFormatEnabled), - new TemplatesModule(this, ModuleConfig::isTemplatesEnabled) + new TemplatesModule(this, ModuleConfig::isTemplatesEnabled), + new SignManagerModule(this, ModuleConfig::isSignManagerEnabled) )); MODULES.forEach(Module::enable); diff --git a/src/main/java/net/greenfieldmc/core/ModuleConfig.java b/src/main/java/net/greenfieldmc/core/ModuleConfig.java index 9e95f9c..e95cf50 100644 --- a/src/main/java/net/greenfieldmc/core/ModuleConfig.java +++ b/src/main/java/net/greenfieldmc/core/ModuleConfig.java @@ -19,6 +19,7 @@ public class ModuleConfig extends Configuration { private final boolean utilities; private final boolean authHub; private final boolean templates; + private final boolean signManager; public ModuleConfig(Plugin plugin) { super(plugin, ConfigType.YML, "moduleConfig"); @@ -37,6 +38,7 @@ public ModuleConfig(Plugin plugin) { addEntry("modules.utilities", true); addEntry("modules.authHub", true); addEntry("modules.templates", true); + addEntry("modules.signManager", true); save(); @@ -53,6 +55,7 @@ public ModuleConfig(Plugin plugin) { utilities = getBoolean("modules.utilities"); authHub = getBoolean("modules.authHub"); templates = getBoolean("modules.templates"); + signManager = getBoolean("modules.signManager"); } @@ -108,4 +111,8 @@ public boolean isTemplatesEnabled() { return templates; } + public boolean isSignManagerEnabled() { + return signManager; + } + } diff --git a/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java b/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java new file mode 100644 index 0000000..51cd45d --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java @@ -0,0 +1,107 @@ +package net.greenfieldmc.core.signmanager; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +import java.util.HashMap; +import java.util.Map; + +/** + * Utility for calculating Minecraft text pixel widths and centering text. + * Minecraft uses a variable-width bitmap font where each character has a + * specific pixel width (plus 1px gap between characters). + * Signs center-align text within a 90-pixel-wide area. + */ +public class MinecraftFontWidths { + + // Sign text rendering width in pixels + private static final int SIGN_WIDTH_PIXELS = 90; + + // Space character effective width (glyph 4px + 1px gap) + private static final int SPACE_EFFECTIVE_WIDTH = 5; + + // Default character width if not in the map (6px is the most common glyph width) + private static final int DEFAULT_CHAR_WIDTH = 6; + + // Map of character -> glyph pixel width (not including the 1px inter-char gap) + private static final Map CHAR_WIDTHS = new HashMap<>(); + + static { + // 2px glyphs + for (char c : "!,.:;|i".toCharArray()) CHAR_WIDTHS.put(c, 2); + CHAR_WIDTHS.put('\'', 2); + CHAR_WIDTHS.put('`', 2); + + // 3px glyphs + CHAR_WIDTHS.put('l', 3); + + // 4px glyphs + for (char c : "\"()*I[]{}t".toCharArray()) CHAR_WIDTHS.put(c, 4); + + // 5px glyphs + for (char c : "<>fk".toCharArray()) CHAR_WIDTHS.put(c, 5); + CHAR_WIDTHS.put(' ', 4); // space glyph is 4px + + // 7px glyphs + for (char c : "@~".toCharArray()) CHAR_WIDTHS.put(c, 7); + } + + /** + * Gets the pixel width of a single character glyph in Minecraft's default font. + */ + public static int getCharWidth(char c) { + return CHAR_WIDTHS.getOrDefault(c, DEFAULT_CHAR_WIDTH); + } + + /** + * Calculates the total pixel width of a string in Minecraft's default font. + * Each character contributes its glyph width + 1px gap (except the last character). + */ + public static int getTextWidth(String text) { + if (text == null || text.isEmpty()) return 0; + int width = 0; + for (int i = 0; i < text.length(); i++) { + width += getCharWidth(text.charAt(i)); + if (i < text.length() - 1) width += 1; // 1px gap between characters + } + return width; + } + + /** + * Calculates the pixel width of a Component's plain text content. + */ + public static int getComponentWidth(Component component) { + var plainText = PlainTextComponentSerializer.plainText().serialize(component); + return getTextWidth(plainText); + } + + /** + * Creates a center-padded version of the given component, + * mimicking how signs render center-aligned text. + * + * @param component The component to center. + * @param targetWidth The target pixel width to center within (typically SIGN_WIDTH_PIXELS). + * @return A new component with leading spaces to approximate center alignment. + */ + public static Component centerText(Component component, int targetWidth) { + int textWidth = getComponentWidth(component); + if (textWidth >= targetWidth) return component; + + int totalPadding = targetWidth - textWidth; + int leftPadding = totalPadding / 2; + + // Each space is SPACE_EFFECTIVE_WIDTH pixels wide (4px glyph + 1px gap) + int spaceCount = leftPadding / SPACE_EFFECTIVE_WIDTH; + if (spaceCount <= 0) return component; + + return Component.text(" ".repeat(spaceCount)).append(component); + } + + /** + * Centers a component using the standard sign width. + */ + public static Component centerText(Component component) { + return centerText(component, SIGN_WIDTH_PIXELS); + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java new file mode 100644 index 0000000..1b07055 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java @@ -0,0 +1,197 @@ +package net.greenfieldmc.core.signmanager; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BlockStateMeta; +import org.bukkit.block.Sign; +import org.bukkit.block.sign.Side; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +public class SavedSign { + + private String name; + private final Material signMaterial; + private final List frontLines; // GsonComponentSerializer JSON strings + private final List backLines; // GsonComponentSerializer JSON strings + + public SavedSign(String name, Material signMaterial, List frontLines, List backLines) { + this.name = name; + this.signMaterial = signMaterial; + this.frontLines = frontLines; + this.backLines = backLines; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Material getSignMaterial() { + return signMaterial; + } + + public List getFrontLines() { + return frontLines; + } + + public List getBackLines() { + return backLines; + } + + /** + * Builds hover text showing the sign's front and back text content. + */ + public List buildHoverLines() { + var lines = new ArrayList(); + lines.add(Component.text("── Front ──", NamedTextColor.GRAY)); + for (var line : frontLines) { + var component = deserializeLine(line); + var plain = PlainTextComponentSerializer.plainText().serialize(component); + if (!plain.isBlank()) { + lines.add(MinecraftFontWidths.centerText(component)); + } + } + lines.add(Component.text("── Back ──", NamedTextColor.GRAY)); + for (var line : backLines) { + var component = deserializeLine(line); + var plain = PlainTextComponentSerializer.plainText().serialize(component); + if (!plain.isBlank()) { + lines.add(MinecraftFontWidths.centerText(component)); + } + } + return lines; + } + + /** + * Converts the saved sign data back into a placeable sign ItemStack with + * the original front/back text restored in the block entity NBT. + */ + public ItemStack toItemStack() { + var item = new ItemStack(signMaterial, 1); + var meta = item.getItemMeta(); + + if (meta instanceof BlockStateMeta blockStateMeta) { + var state = blockStateMeta.getBlockState(); + if (state instanceof Sign sign) { + var frontSide = sign.getSide(Side.FRONT); + for (int i = 0; i < frontLines.size() && i < 4; i++) { + frontSide.line(i, deserializeLine(frontLines.get(i))); + } + var backSide = sign.getSide(Side.BACK); + for (int i = 0; i < backLines.size() && i < 4; i++) { + backSide.line(i, deserializeLine(backLines.get(i))); + } + blockStateMeta.setBlockState(state); + } + } + + item.setItemMeta(meta); + return item; + } + + /** + * Creates a SavedSign from a sign ItemStack held by a player. + */ + public static @Nullable SavedSign fromItemStack(ItemStack item, String name) { + if (item == null || !isSignMaterial(item.getType())) return null; + + var meta = item.getItemMeta(); + var frontLines = new ArrayList(); + var backLines = new ArrayList(); + + if (meta instanceof BlockStateMeta blockStateMeta) { + var state = blockStateMeta.getBlockState(); + if (state instanceof Sign sign) { + var frontSide = sign.getSide(Side.FRONT); + for (int i = 0; i < 4; i++) { + frontLines.add(serializeLine(frontSide.line(i))); + } + var backSide = sign.getSide(Side.BACK); + for (int i = 0; i < 4; i++) { + backLines.add(serializeLine(backSide.line(i))); + } + } + } + + // If no block state meta, just use empty lines + while (frontLines.size() < 4) frontLines.add(serializeLine(Component.empty())); + while (backLines.size() < 4) backLines.add(serializeLine(Component.empty())); + + return new SavedSign(name, item.getType(), frontLines, backLines); + } + + /** + * Returns the plain text content of all sign lines for search matching. + */ + public String getPlainText() { + var sb = new StringBuilder(); + sb.append(name).append(" "); + for (var line : frontLines) { + sb.append(PlainTextComponentSerializer.plainText().serialize(deserializeLine(line))).append(" "); + } + for (var line : backLines) { + sb.append(PlainTextComponentSerializer.plainText().serialize(deserializeLine(line))).append(" "); + } + return sb.toString(); + } + + /** + * Returns a human-readable sign type description based on the material name. + * e.g. "Oak Sign", "Birch Hanging Sign", "Dark Oak Wall Sign" + */ + public String getSignTypeDescription() { + var name = signMaterial.name(); + // Remove _SIGN suffix and convert underscores to spaces, title case + name = name.replace("_SIGN", "").replace("_", " "); + var words = name.toLowerCase().split(" "); + var sb = new StringBuilder(); + for (var word : words) { + if (!word.isEmpty()) { + sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)).append(" "); + } + } + sb.append("Sign"); + return sb.toString().trim(); + } + + /** + * Returns true if the sign material is a hanging sign type. + */ + public boolean isHangingSign() { + return signMaterial.name().contains("HANGING"); + } + + /** + * Returns true if the sign material is a wall sign type. + */ + public boolean isWallSign() { + return signMaterial.name().contains("WALL"); + } + + public static boolean isSignMaterial(Material material) { + return material != null && material.name().endsWith("_SIGN"); + } + + private static String serializeLine(Component component) { + return GsonComponentSerializer.gson().serialize(component); + } + + private static Component deserializeLine(String json) { + try { + return GsonComponentSerializer.gson().deserialize(json); + } catch (Exception e) { + return Component.text(json); + } + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java new file mode 100644 index 0000000..a1c8a82 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java @@ -0,0 +1,60 @@ +package net.greenfieldmc.core.signmanager; + +import org.bukkit.inventory.ItemStack; + +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a saved group of signs. A group appears as a single entry in the chat paginator + * using the display sign (from the main hand at save time). Clicking the group entry + * gives the player all member signs. + */ +public class SavedSignGroup { + + private final String name; + private final SavedSign displaySign; + private final List memberSigns; + + public SavedSignGroup(String name, SavedSign displaySign, List memberSigns) { + this.name = name; + this.displaySign = displaySign; + this.memberSigns = new ArrayList<>(memberSigns); + } + + public String getName() { + return name; + } + + public SavedSign getDisplaySign() { + return displaySign; + } + + public List getMemberSigns() { + return memberSigns; + } + + /** + * Returns all member signs as clean ItemStacks ready to be given to a player. + */ + public List toMemberItemStacks() { + var items = new ArrayList(); + for (var sign : memberSigns) { + items.add(sign.toItemStack()); + } + return items; + } + + /** + * Returns the combined plain text of the group for search matching. + */ + public String getPlainText() { + var sb = new StringBuilder(); + sb.append(name).append(" "); + sb.append(displaySign.getPlainText()).append(" "); + for (var sign : memberSigns) { + sb.append(sign.getPlainText()).append(" "); + } + return sb.toString(); + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java new file mode 100644 index 0000000..22324d8 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java @@ -0,0 +1,165 @@ +package net.greenfieldmc.core.signmanager; + +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.utils.text.pager.ChatPaginator; +import com.njdaeger.pdk.utils.text.pager.PageItem; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Represents a single entry in the Sign Manager paginator. + * Can be either an individual SavedSign or a SavedSignGroup. + * Groups appear as a single line using the display sign info, but give all member signs when clicked. + */ +@SuppressWarnings("DataFlowIssue") +public class SignManagerEntry implements PageItem { + + // Unicode block characters for sign type indicators + // Full block for normal signs, lower half block for hanging signs + private static final String BLOCK_FULL = "█"; // U+2588 Full Block + private static final String BLOCK_LOWER_HALF = "▄"; // U+2584 Lower Half Block + private static final String BLOCK_THREE_QUARTER = "▆"; // U+2586 Lower Three Quarters Block + + private final @Nullable SavedSign sign; + private final @Nullable SavedSignGroup group; + + private SignManagerEntry(@Nullable SavedSign sign, @Nullable SavedSignGroup group) { + this.sign = sign; + this.group = group; + } + + public static SignManagerEntry ofSign(SavedSign sign) { + return new SignManagerEntry(sign, null); + } + + public static SignManagerEntry ofGroup(SavedSignGroup group) { + return new SignManagerEntry(null, group); + } + + public boolean isGroup() { + return group != null; + } + + public String getName() { + return isGroup() ? group.getName() : sign.getName(); + } + + /** + * Returns the display sign — either the individual sign itself or the group's display sign. + */ + public SavedSign getDisplaySign() { + return isGroup() ? group.getDisplaySign() : sign; + } + + /** + * Returns all sign items to give the player when this entry is clicked. + */ + public List toGiveItemStacks() { + if (isGroup()) { + return group.toMemberItemStacks(); + } else { + return List.of(sign.toItemStack()); + } + } + + /** + * Returns the number of signs in this entry. + */ + public int getSignCount() { + return isGroup() ? group.getMemberSigns().size() : 1; + } + + /** + * Returns the plain text content for search matching. + */ + public String getPlainText() { + return isGroup() ? group.getPlainText() : sign.getPlainText(); + } + + @Override + public TextComponent getItemText(ChatPaginator paginator, ICommandContext generatorInfo) { + var displaySign = getDisplaySign(); + var material = displaySign.getSignMaterial(); + + // 1. Sign type indicator: colored unicode block with hover showing material + var blockChar = getBlockCharForSign(displaySign); + var blockColor = getColorForMaterial(material); + var typeIndicator = Component.text(blockChar, blockColor) + .hoverEvent(HoverEvent.showText(Component.text(displaySign.getSignTypeDescription(), NamedTextColor.GRAY))); + + // 2. Count bracket: [N] for groups, [1] for singles + var countText = Component.text(" [" + getSignCount() + "] ", NamedTextColor.GRAY); + + // 3. Name with hover (sign content) and click action (give signs) + var hoverContent = Component.text(); + var hoverLines = displaySign.buildHoverLines(); + for (int i = 0; i < hoverLines.size(); i++) { + if (i > 0) hoverContent.appendNewline(); + hoverContent.append(hoverLines.get(i)); + } + if (isGroup()) { + hoverContent.appendNewline(); + hoverContent.append(Component.text("Group: " + group.getName() + " (" + getSignCount() + " signs)", NamedTextColor.YELLOW)); + } + hoverContent.appendNewline(); + hoverContent.append(Component.text("Click to receive", NamedTextColor.GREEN)); + + var nameComponent = Component.text(getName(), paginator.getHighlightColor()) + .hoverEvent(HoverEvent.showText(hoverContent.build())) + .clickEvent(ClickEvent.runCommand("/sm give " + getName())); + + var line = Component.text(); + line.append(typeIndicator); + line.append(countText); + line.append(nameComponent); + + return line.build(); + } + + @Override + public String getPlainItemText(ChatPaginator paginator, ICommandContext generatorInfo) { + return getName(); + } + + /** + * Returns the appropriate unicode block character for the sign type. + * Full block for regular signs, lower half for hanging signs. + */ + private static String getBlockCharForSign(SavedSign sign) { + if (sign.isHangingSign()) { + return BLOCK_THREE_QUARTER; + } + return BLOCK_FULL; + } + + /** + * Returns a hex color for the sign material type. + * Colors approximate the wood type's appearance. + * These are placeholder values — the user will set exact colors manually. + */ + private static TextColor getColorForMaterial(Material material) { + var name = material.name(); + if (name.startsWith("OAK")) return TextColor.color(0xaa8a61); + if (name.startsWith("SPRUCE")) return TextColor.color(0x705538); + if (name.startsWith("BIRCH")) return TextColor.color(0xc4c4c4); + if (name.startsWith("JUNGLE")) return TextColor.color(0x494949); + if (name.startsWith("ACACIA")) return TextColor.color(0x4b4a4a); + if (name.startsWith("DARK_OAK")) return TextColor.color(0x374d2c); + if (name.startsWith("MANGROVE")) return TextColor.color(0xaa4334); + if (name.startsWith("CHERRY")) return TextColor.color(0x909090); + if (name.startsWith("BAMBOO")) return TextColor.color(0x2c2c2c); + if (name.startsWith("CRIMSON")) return TextColor.color(0xce7533); + if (name.startsWith("WARPED")) return TextColor.color(0x5787a2); + if (name.startsWith("PALE_OAK")) return TextColor.color(0xc7a840); + return TextColor.color(0xC4A054); // default to oak-ish + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java new file mode 100644 index 0000000..de87166 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java @@ -0,0 +1,29 @@ +package net.greenfieldmc.core.signmanager; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.util.function.BiFunction; +import java.util.function.Function; + +import static net.greenfieldmc.core.ComponentUtils.moduleMessage; + +public class SignManagerMessages { + + public static final TextComponent MODULE = moduleMessage("SignManager"); + + public static final String ERROR_NOT_HOLDING_SIGN = "You must be holding a sign item in your main hand."; + public static final String ERROR_SIGN_NOT_FOUND = "No saved sign found with that name."; + public static final String ERROR_GROUP_NOT_FOUND = "No saved sign group found with that name."; + public static final String ERROR_SIGN_ALREADY_EXISTS = "A sign or group with that name already exists."; + public static final String ERROR_NO_SIGNS_IN_HOTBAR = "No sign items found in your hotbar."; + public static final String ERROR_ENTRY_NOT_FOUND = "No saved sign or group found with that name."; + + public static final Function SIGN_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign \"" + name + "\".", NamedTextColor.GRAY)); + public static final Function SIGN_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign \"" + name + "\".", NamedTextColor.GRAY)); + public static final BiFunction SIGN_RENAMED = (oldName, newName) -> MODULE.append(Component.text("Renamed sign \"" + oldName + "\" to \"" + newName + "\".", NamedTextColor.GRAY)); + public static final Function GROUP_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign group \"" + name + "\".", NamedTextColor.GRAY)); + public static final Function GROUP_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign group \"" + name + "\".", NamedTextColor.GRAY)); + public static final Function GROUP_SAVED_COUNT = (count) -> MODULE.append(Component.text("Saved " + count + " sign(s) from your hotbar.", NamedTextColor.GRAY)); +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java new file mode 100644 index 0000000..c6f45fe --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java @@ -0,0 +1,40 @@ +package net.greenfieldmc.core.signmanager; + +import net.greenfieldmc.core.GreenfieldCore; +import net.greenfieldmc.core.Module; +import net.greenfieldmc.core.ModuleConfig; +import net.greenfieldmc.core.shared.services.IVaultService; +import net.greenfieldmc.core.shared.services.VaultServiceImpl; +import net.greenfieldmc.core.signmanager.services.ISignManagerService; +import net.greenfieldmc.core.signmanager.services.ISignManagerStorageService; +import net.greenfieldmc.core.signmanager.services.SignManagerCommandService; +import net.greenfieldmc.core.signmanager.services.SignManagerServiceImpl; +import net.greenfieldmc.core.signmanager.services.SignManagerStorageServiceImpl; + +import java.util.function.Predicate; + +public class SignManagerModule extends Module { + + private IVaultService vaultService; + private ISignManagerStorageService storageService; + private ISignManagerService signManagerService; + + public SignManagerModule(GreenfieldCore plugin, Predicate canEnable) { + super(plugin, canEnable); + } + + @Override + protected void tryEnable() throws Exception { + vaultService = enableIntegration(new VaultServiceImpl(plugin, this), true); + storageService = enableIntegration(new SignManagerStorageServiceImpl(plugin, this), true); + signManagerService = enableIntegration(new SignManagerServiceImpl(plugin, this, storageService), true); + enableIntegration(new SignManagerCommandService(plugin, this, signManagerService), true); + } + + @Override + protected void tryDisable() throws Exception { + disableIntegration(signManagerService); + disableIntegration(storageService); + disableIntegration(vaultService); + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java new file mode 100644 index 0000000..511bbfc --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java @@ -0,0 +1,44 @@ +package net.greenfieldmc.core.signmanager.arguments; + +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument; +import net.greenfieldmc.core.signmanager.services.ISignManagerService; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class SignEntryNameArgument extends AbstractStringTypedArgument { + + private static final DynamicCommandExceptionType ENTRY_NOT_FOUND = new DynamicCommandExceptionType(o -> () -> "Sign or group '" + o.toString() + "' not found"); + + private final ISignManagerService signManagerService; + + public SignEntryNameArgument(ISignManagerService signManagerService) { + this.signManagerService = signManagerService; + } + + @Override + public List listBasicSuggestions(ICommandContext commandContext) { + return signManagerService.getAllEntryNames(); + } + + @Override + public String convertToNative(String entryName) { + return entryName.toLowerCase(); + } + + @Override + public String convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException { + var names = signManagerService.getAllEntryNames(); + var match = names.stream().filter(n -> n.equalsIgnoreCase(nativeType)).findFirst().orElse(null); + if (match == null) { + reader.setCursor(reader.getCursor() - nativeType.length()); + throw ENTRY_NOT_FOUND.createWithContext(reader, nativeType); + } + return match; + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java new file mode 100644 index 0000000..c494413 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java @@ -0,0 +1,45 @@ +package net.greenfieldmc.core.signmanager.arguments; + +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument; +import net.greenfieldmc.core.signmanager.services.ISignManagerService; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class SignGroupNameArgument extends AbstractStringTypedArgument { + + private static final DynamicCommandExceptionType GROUP_NOT_FOUND = new DynamicCommandExceptionType(o -> () -> "Sign group '" + o.toString() + "' not found"); + + private final ISignManagerService signManagerService; + + public SignGroupNameArgument(ISignManagerService signManagerService) { + this.signManagerService = signManagerService; + } + + @Override + public List listBasicSuggestions(ICommandContext commandContext) { + return signManagerService.getGroupNames(); + } + + @Override + public String convertToNative(String groupName) { + return groupName.toLowerCase(); + } + + @Override + public String convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException { + var groups = signManagerService.getGroupNames(); + var match = groups.stream().filter(g -> g.equalsIgnoreCase(nativeType)).findFirst().orElse(null); + if (match == null) { + reader.setCursor(reader.getCursor() - nativeType.length()); + throw GROUP_NOT_FOUND.createWithContext(reader, nativeType); + } + return match; + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java new file mode 100644 index 0000000..b74bca7 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java @@ -0,0 +1,45 @@ +package net.greenfieldmc.core.signmanager.arguments; + +import com.mojang.brigadier.StringReader; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.DynamicCommandExceptionType; +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.services.ISignManagerService; +import org.bukkit.command.CommandSender; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class SignNameArgument extends AbstractStringTypedArgument { + + private static final DynamicCommandExceptionType SIGN_NOT_FOUND = new DynamicCommandExceptionType(o -> () -> "Sign '" + o.toString() + "' not found"); + + private final ISignManagerService signManagerService; + + public SignNameArgument(ISignManagerService signManagerService) { + this.signManagerService = signManagerService; + } + + @Override + public List listBasicSuggestions(ICommandContext commandContext) { + return signManagerService.getAllSigns(); + } + + @Override + public String convertToNative(SavedSign sign) { + return sign.getName().toLowerCase(); + } + + @Override + public SavedSign convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException { + var sign = signManagerService.getSign(nativeType); + if (sign == null) { + reader.setCursor(reader.getCursor() - nativeType.length()); + throw SIGN_NOT_FOUND.createWithContext(reader, nativeType); + } + return sign; + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java b/src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java new file mode 100644 index 0000000..4629d2b --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java @@ -0,0 +1,62 @@ +package net.greenfieldmc.core.signmanager.paginators; + +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.utils.text.pager.ChatPaginatorBuilder; +import com.njdaeger.pdk.utils.text.pager.ComponentPosition; +import com.njdaeger.pdk.utils.text.pager.LineWrappingMode; +import com.njdaeger.pdk.utils.text.pager.components.PageNavigationComponent; +import com.njdaeger.pdk.utils.text.pager.components.ResultCountComponent; +import net.greenfieldmc.core.signmanager.SignManagerEntry; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +public class SignManagerPaginator extends ChatPaginatorBuilder { + + public SignManagerPaginator() { + super(); + + setLineWrappingMode(LineWrappingMode.ELLIPSIS); + setResultsPerPage(8); + + // Title + addComponent(Component.text("Sign Manager", NamedTextColor.LIGHT_PURPLE), ComponentPosition.TOP_CENTER); + + // Result count + addComponent(new ResultCountComponent<>(true), ComponentPosition.TOP_LEFT); + + // Search query display in top right + addComponent((ctx, paginator, results, page) -> { + var query = ctx.getTyped("query", String.class, ""); + if (query == null || query.isBlank()) { + return Component.text("No filter", NamedTextColor.GRAY); + } + return Component.text("Search: ", NamedTextColor.GRAY) + .append(Component.text(query, NamedTextColor.GOLD)); + }, ComponentPosition.TOP_RIGHT); + + // Page navigation + addComponent(new PageNavigationComponent<>( + (ctx, res, pg) -> buildPageCommand(ctx, 1), + (ctx, res, pg) -> buildPageCommand(ctx, pg - 1), + (ctx, res, pg) -> buildPageCommand(ctx, pg + 1), + (ctx, res, pg) -> buildPageCommand(ctx, (int) Math.ceil(res.size() / 8.0)) + ), ComponentPosition.BOTTOM_CENTER); + + // Search button at bottom right + addComponent((ctx, paginator, results, pg) -> Component.text("[☀]", paginator.getHighlightColor()) + .hoverEvent(HoverEvent.showText(Component.text("Search for a sign", NamedTextColor.GRAY))) + .clickEvent(ClickEvent.suggestCommand("/sm search ")) + , ComponentPosition.BOTTOM_RIGHT); + } + + private static String buildPageCommand(ICommandContext ctx, int page) { + var query = ctx.getTyped("query", String.class, ""); + if (query == null || query.isBlank()) { + return "/sm page " + page; + } + return "/sm search " + query + " -page " + page; + } +} + diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java new file mode 100644 index 0000000..50119b5 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java @@ -0,0 +1,124 @@ +package net.greenfieldmc.core.signmanager.services; + +import net.greenfieldmc.core.IModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SavedSignGroup; +import net.greenfieldmc.core.signmanager.SignManagerEntry; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public interface ISignManagerService extends IModuleService { + + /** + * Save a sign from the player's main hand. + * @param player The player holding the sign. + * @param name The name to save the sign under. + * @return The saved sign, or null if the player is not holding a sign. + */ + @Nullable SavedSign saveSignFromHand(Player player, String name); + + /** + * Save all sign items from the player's hotbar as a group. + * The main hand item is used as the display sign. + * @param player The player whose hotbar to scan. + * @param groupName The group name. + * @return The saved group, or null if no signs found in hotbar or main hand is not a sign. + */ + @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName); + + /** + * Save a sign to the database. + * @param sign The sign to save. + */ + void saveSign(SavedSign sign); + + /** + * Rename a saved sign. + * @param oldName The current name of the sign. + * @param newName The new name for the sign. + * @return true if the sign was found and renamed. + */ + boolean renameSign(String oldName, String newName); + + /** + * Delete a saved sign or group by name. Tries sign first, then group. + * @param name The name of the entry. + * @return true if the entry was found and deleted. + */ + boolean deleteEntry(String name); + + /** + * Delete a saved sign by name. + * @param name The name of the sign. + * @return true if the sign was found and deleted. + */ + boolean deleteSign(String name); + + /** + * Delete a saved sign group by name. + * @param groupName The group name. + * @return true if the group was found and deleted. + */ + boolean deleteGroup(String groupName); + + /** + * Get a saved sign by name. + * @param name The name of the sign. + * @return The saved sign, or null if not found. + */ + @Nullable SavedSign getSign(String name); + + /** + * Check if a name is already taken by a sign or group. + * @param name The name to check. + * @return true if the name is in use. + */ + boolean nameExists(String name); + + /** + * Get all GUI entries (individual signs + groups as single entries), sorted alphabetically. + * @return A list of all entries. + */ + List getAllEntries(); + + /** + * Fuzzy search entries by name and content. + * @param query The search query. + * @return A list of matching entries. + */ + List searchEntries(String query); + + /** + * Give a player all sign items for an entry by name. + * @param player The player to give signs to. + * @param entryName The name of the sign or group entry. + * @return The entry that was given, or null if not found. + */ + @Nullable SignManagerEntry giveEntry(Player player, String entryName); + + /** + * Get all distinct group names. + * @return A list of group names. + */ + List getGroupNames(); + + /** + * Get all individual saved signs (not groups). + * @return A list of all individual saved signs. + */ + List getAllSigns(); + + /** + * Get all sign names (individual signs only, not groups). + * @return A list of sign names. + */ + List getSignNames(); + + /** + * Get all entry names (signs + groups). + * @return A list of all entry names. + */ + List getAllEntryNames(); +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java new file mode 100644 index 0000000..57478fa --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java @@ -0,0 +1,92 @@ +package net.greenfieldmc.core.signmanager.services; + +import net.greenfieldmc.core.IModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SavedSignGroup; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.function.Predicate; + +public interface ISignManagerStorageService extends IModuleService { + + /** + * Get a saved sign by name. + * @param name The name of the sign. + * @return The saved sign, or null if not found. + */ + @Nullable SavedSign getSign(String name); + + /** + * Get all saved signs matching a filter. + * @param filter The filter predicate. + * @return A list of matching saved signs. + */ + List getSigns(Predicate filter); + + /** + * Get all saved signs. + * @return A list of all saved signs. + */ + default List getSigns() { + return getSigns(sign -> true); + } + + /** + * Save a sign to the database. + * @param sign The sign to save. + */ + void saveSign(SavedSign sign); + + + /** + * Delete a sign by name. + * @param name The name of the sign to delete. + */ + void deleteSign(String name); + + /** + * Get a saved sign group by name. + * @param name The name of the group. + * @return The saved sign group, or null if not found. + */ + @Nullable SavedSignGroup getGroup(String name); + + /** + * Get all saved sign groups matching a filter. + * @param filter The filter predicate. + * @return A list of matching saved sign groups. + */ + List getGroups(Predicate filter); + + /** + * Get all saved sign groups. + * @return A list of all saved sign groups. + */ + default List getGroups() { + return getGroups(group -> true); + } + + /** + * Save a sign group to the database. + * @param group The sign group to save. + */ + void saveGroup(SavedSignGroup group); + + /** + * Delete a sign group by name. + * @param name The name of the group to delete. + */ + void deleteGroup(String name); + + /** + * Get all distinct group names. + * @return A list of unique group names. + */ + List getGroupNames(); + + /** + * Persist all changes to disk. + */ + void saveDatabase(); +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java new file mode 100644 index 0000000..a308e17 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java @@ -0,0 +1,168 @@ +package net.greenfieldmc.core.signmanager.services; + +import com.njdaeger.pdk.command.brigadier.ICommandContext; +import com.njdaeger.pdk.command.brigadier.builder.CommandBuilder; +import com.njdaeger.pdk.command.brigadier.builder.PdkArgumentTypes; +import com.njdaeger.pdk.command.exception.PDKCommandException; +import com.njdaeger.pdk.utils.text.pager.ChatPaginator; +import net.greenfieldmc.core.IModuleService; +import net.greenfieldmc.core.Module; +import net.greenfieldmc.core.ModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SignManagerEntry; +import net.greenfieldmc.core.signmanager.SignManagerMessages; +import net.greenfieldmc.core.signmanager.arguments.SignEntryNameArgument; +import net.greenfieldmc.core.signmanager.arguments.SignNameArgument; +import net.greenfieldmc.core.signmanager.paginators.SignManagerPaginator; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.plugin.Plugin; + +import java.util.stream.IntStream; + +public class SignManagerCommandService extends ModuleService implements IModuleService { + + private final ISignManagerService signManagerService; + private final ChatPaginator paginator = new SignManagerPaginator().build(); + + public SignManagerCommandService(Plugin plugin, Module module, ISignManagerService signManagerService) { + super(plugin, module); + this.signManagerService = signManagerService; + } + + // === User commands === + + private void listSigns(ICommandContext ctx) throws PDKCommandException { + var entries = signManagerService.getAllEntries(); + if (entries.isEmpty()) ctx.error("There are no saved signs to display."); + int page = ctx.getTyped("pageNumber", 1); + paginator.generatePage(ctx, entries, page).sendTo(ctx.getSender()); + } + + private void searchSigns(ICommandContext ctx) throws PDKCommandException { + var query = ctx.getTyped("query", String.class); + var entries = signManagerService.searchEntries(query); + if (entries.isEmpty()) ctx.error("No signs found matching \"" + query + "\"."); + int page = ctx.getFlag("page", 1); + paginator.generatePage(ctx, entries, page).sendTo(ctx.getSender()); + } + + private void giveSigns(ICommandContext ctx) throws PDKCommandException { + var player = ctx.asPlayer(); + var entryName = ctx.getTyped("entryName", String.class); + var entry = signManagerService.giveEntry(player, entryName); + if (entry == null) { + ctx.error(SignManagerMessages.ERROR_ENTRY_NOT_FOUND); + return; + } + if (entry.isGroup()) { + ctx.send(SignManagerMessages.MODULE.append( + Component.text("Gave you " + entry.getSignCount() + " sign(s) from group \"" + entry.getName() + "\".", NamedTextColor.GRAY))); + } else { + ctx.send(SignManagerMessages.MODULE.append( + Component.text("Gave you sign \"" + entry.getName() + "\".", NamedTextColor.GRAY))); + } + } + + // === Staff commands === + private void saveEntry(ICommandContext ctx) throws PDKCommandException { + var player = ctx.asPlayer(); + var signName = ctx.getTyped("name", String.class); + + if (signManagerService.nameExists(signName)) { + ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS); + return; + } + + var sign = signManagerService.saveSignFromHand(player, signName); + if (sign == null) { + ctx.error(SignManagerMessages.ERROR_NOT_HOLDING_SIGN); + return; + } + ctx.send(SignManagerMessages.SIGN_SAVED.apply(signName)); + } + + private void saveGroupEntry(ICommandContext ctx) throws PDKCommandException { + var player = ctx.asPlayer(); + var groupName = ctx.getTyped("name", String.class); + + if (signManagerService.nameExists(groupName)) { + ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS); + return; + } + + var group = signManagerService.saveGroupFromHotbar(player, groupName); + if (group == null) { + ctx.error(SignManagerMessages.ERROR_NO_SIGNS_IN_HOTBAR); + return; + } + ctx.send(SignManagerMessages.GROUP_SAVED.apply(groupName)); + ctx.send(SignManagerMessages.GROUP_SAVED_COUNT.apply(group.getMemberSigns().size())); + } + + private void renameSign(ICommandContext ctx) throws PDKCommandException { + var savedSign = ctx.getTyped("signName", SavedSign.class); + var newName = ctx.getTyped("newName", String.class); + + if (signManagerService.nameExists(newName)) { + ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS); + return; + } + + var oldName = savedSign.getName(); + signManagerService.renameSign(oldName, newName); + ctx.send(SignManagerMessages.SIGN_RENAMED.apply(oldName, newName)); + } + + /** + * Unified delete command. Deletes a sign or group by name. + */ + private void deleteEntry(ICommandContext ctx) throws PDKCommandException { + var entryName = ctx.getTyped("entryName", String.class); + boolean deleted = signManagerService.deleteEntry(entryName); + if (!deleted) { + ctx.error(SignManagerMessages.ERROR_ENTRY_NOT_FOUND); + return; + } + ctx.send(SignManagerMessages.SIGN_DELETED.apply(entryName)); + } + + @Override + public void tryEnable(Plugin plugin, Module module) throws Exception { + CommandBuilder.of("sm", "signmanager") + .description("Sign Manager - Save, search, and browse signs") + .permission("greenfieldcore.signmanager.use") + .defaultExecutor(this::listSigns) + .canExecute() + // User commands + .then("page") + .then("pageNumber", PdkArgumentTypes.integer(ctx -> IntStream.rangeClosed(1, (int) Math.ceil(signManagerService.getAllEntries().size() / 8.0)).boxed().toList(), () -> "Page number")).executes(this::listSigns) + .end() + .then("search") + .then("query", PdkArgumentTypes.greedyString()).executes(this::searchSigns) + .end() + .then("give") + .then("entryName", new SignEntryNameArgument(signManagerService)).executes(this::giveSigns) + .end() + // Staff commands + .then("save").permission("greenfieldcore.signmanager.manage") + .then("-group") // Group save flag + .then("name", PdkArgumentTypes.string()).executes(this::saveGroupEntry) + .end() + .then("name", PdkArgumentTypes.string()).executes(this::saveEntry) + .end() + .then("rename").permission("greenfieldcore.signmanager.manage") + .then("signName", new SignNameArgument(signManagerService)) + .then("newName", PdkArgumentTypes.string()).executes(this::renameSign) + .end() + .end() + .then("delete").permission("greenfieldcore.signmanager.manage") + .then("entryName", new SignEntryNameArgument(signManagerService)).executes(this::deleteEntry) + .end() + .register(plugin); + } + + @Override + public void tryDisable(Plugin plugin, Module module) throws Exception { + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java new file mode 100644 index 0000000..b6aeb86 --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java @@ -0,0 +1,190 @@ +package net.greenfieldmc.core.signmanager.services; + +import net.greenfieldmc.core.Module; +import net.greenfieldmc.core.ModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SavedSignGroup; +import net.greenfieldmc.core.signmanager.SignManagerEntry; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public class SignManagerServiceImpl extends ModuleService implements ISignManagerService { + + private final ISignManagerStorageService storageService; + + public SignManagerServiceImpl(Plugin plugin, Module module, ISignManagerStorageService storageService) { + super(plugin, module); + this.storageService = storageService; + } + + @Override + public void tryEnable(Plugin plugin, Module module) throws Exception { + } + + @Override + public void tryDisable(Plugin plugin, Module module) throws Exception { + } + + @Override + public @Nullable SavedSign saveSignFromHand(Player player, String name) { + var item = player.getInventory().getItemInMainHand(); + if (!SavedSign.isSignMaterial(item.getType())) return null; + + var sign = SavedSign.fromItemStack(item, name); + if (sign == null) return null; + + storageService.saveSign(sign); + storageService.saveDatabase(); + return sign; + } + + @Override + public @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName) { + var mainHandItem = player.getInventory().getItemInMainHand(); + if (!SavedSign.isSignMaterial(mainHandItem.getType())) return null; + + var displaySign = SavedSign.fromItemStack(mainHandItem, groupName + "_display"); + if (displaySign == null) return null; + + var memberSigns = new ArrayList(); + int signCount = 0; + for (int slot = 0; slot < 9; slot++) { + var item = player.getInventory().getItem(slot); + if (item == null || !SavedSign.isSignMaterial(item.getType())) continue; + + signCount++; + var memberSign = SavedSign.fromItemStack(item, groupName + "_" + signCount); + if (memberSign != null) memberSigns.add(memberSign); + } + + if (memberSigns.isEmpty()) return null; + + var group = new SavedSignGroup(groupName, displaySign, memberSigns); + storageService.saveGroup(group); + storageService.saveDatabase(); + return group; + } + + @Override + public void saveSign(SavedSign sign) { + storageService.saveSign(sign); + storageService.saveDatabase(); + } + + @Override + public boolean renameSign(String oldName, String newName) { + var sign = storageService.getSign(oldName); + if (sign == null) return false; + storageService.deleteSign(oldName); + sign.setName(newName); + storageService.saveSign(sign); + storageService.saveDatabase(); + return true; + } + + @Override + public boolean deleteEntry(String name) { + if (deleteSign(name)) return true; + return deleteGroup(name); + } + + @Override + public boolean deleteSign(String name) { + var sign = storageService.getSign(name); + if (sign == null) return false; + storageService.deleteSign(name); + storageService.saveDatabase(); + return true; + } + + @Override + public boolean deleteGroup(String groupName) { + var group = storageService.getGroup(groupName); + if (group == null) return false; + storageService.deleteGroup(groupName); + storageService.saveDatabase(); + return true; + } + + @Override + public @Nullable SavedSign getSign(String name) { + return storageService.getSign(name); + } + + @Override + public boolean nameExists(String name) { + return storageService.getSign(name) != null || storageService.getGroup(name) != null; + } + + @Override + public List getAllEntries() { + var entries = new ArrayList(); + storageService.getSigns().forEach(sign -> entries.add(SignManagerEntry.ofSign(sign))); + storageService.getGroups().forEach(group -> entries.add(SignManagerEntry.ofGroup(group))); + entries.sort(Comparator.comparing(entry -> entry.getName().toLowerCase())); + return entries; + } + + @Override + public List searchEntries(String query) { + if (query == null || query.isBlank()) return getAllEntries(); + var lowerQuery = query.toLowerCase(); + return getAllEntries().stream() + .filter(entry -> entry.getPlainText().toLowerCase().contains(lowerQuery)) + .toList(); + } + + @Override + public @Nullable SignManagerEntry giveEntry(Player player, String entryName) { + // Check individual signs first + var sign = storageService.getSign(entryName); + if (sign != null) { + var entry = SignManagerEntry.ofSign(sign); + for (var item : entry.toGiveItemStacks()) { + player.getInventory().addItem(item); + } + return entry; + } + + // Check groups + var group = storageService.getGroup(entryName); + if (group != null) { + var entry = SignManagerEntry.ofGroup(group); + for (var item : entry.toGiveItemStacks()) { + player.getInventory().addItem(item); + } + return entry; + } + + return null; + } + + @Override + public List getGroupNames() { + return storageService.getGroupNames(); + } + + @Override + public List getAllSigns() { + return storageService.getSigns(); + } + + @Override + public List getSignNames() { + return storageService.getSigns().stream().map(SavedSign::getName).toList(); + } + + @Override + public List getAllEntryNames() { + var names = new ArrayList(); + storageService.getSigns().forEach(sign -> names.add(sign.getName())); + storageService.getGroups().forEach(group -> names.add(group.getName())); + names.sort(String.CASE_INSENSITIVE_ORDER); + return names; + } +} diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java new file mode 100644 index 0000000..545082d --- /dev/null +++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java @@ -0,0 +1,162 @@ +package net.greenfieldmc.core.signmanager.services; + +import com.njdaeger.pdk.config.ConfigType; +import com.njdaeger.pdk.config.IConfig; +import net.greenfieldmc.core.Module; +import net.greenfieldmc.core.ModuleService; +import net.greenfieldmc.core.signmanager.SavedSign; +import net.greenfieldmc.core.signmanager.SavedSignGroup; +import org.bukkit.Material; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +public class SignManagerStorageServiceImpl extends ModuleService implements ISignManagerStorageService { + + private final Map signs = new HashMap<>(); + private final Map groups = new HashMap<>(); + private IConfig config; + + public SignManagerStorageServiceImpl(Plugin plugin, Module module) { + super(plugin, module); + } + + @Override + public void tryEnable(Plugin plugin, Module module) throws Exception { + try { + this.config = ConfigType.YML.createNew(plugin, "signmanager"); + + // Load individual signs + if (config.hasSection("signs")) { + for (var signName : config.getSection("signs").getKeys(false)) { + var sign = loadSign("signs." + signName, signName); + if (sign != null) signs.put(signName.toLowerCase(), sign); + } + } + + // Load groups + if (config.hasSection("groups")) { + for (var groupName : config.getSection("groups").getKeys(false)) { + // Load display sign + var displaySign = loadSign("groups." + groupName + ".displaySign", groupName + "_display"); + if (displaySign == null) { + plugin.getLogger().warning("SignManager: Group '" + groupName + "' has invalid display sign. Skipping."); + continue; + } + + // Load member signs + var memberSigns = new ArrayList(); + var section = config.getSection("groups." + groupName); + if (section.contains("members")) { + for (var memberKey : section.getSection("members").getKeys(false)) { + var memberSign = loadSign("groups." + groupName + ".members." + memberKey, memberKey); + if (memberSign != null) memberSigns.add(memberSign); + } + } + + groups.put(groupName.toLowerCase(), new SavedSignGroup(groupName, displaySign, memberSigns)); + } + } + } catch (Exception e) { + throw new Exception("Failed to enable SignManagerStorageService", e); + } + } + + private @Nullable SavedSign loadSign(String path, String name) { + var section = config.getSection(path); + if (section == null) return null; + var material = Material.matchMaterial(section.getString("material")); + if (material == null) return null; + var frontLines = section.getStringList("frontLines"); + var backLines = section.getStringList("backLines"); + + while (frontLines.size() < 4) frontLines.add("\"\""); + while (backLines.size() < 4) backLines.add("\"\""); + + return new SavedSign(name, material, frontLines, backLines); + } + + private void persistSign(String path, SavedSign sign) { + config.setEntry(path + ".material", sign.getSignMaterial().name()); + config.setEntry(path + ".frontLines", sign.getFrontLines()); + config.setEntry(path + ".backLines", sign.getBackLines()); + } + + @Override + public void tryDisable(Plugin plugin, Module module) throws Exception { + saveDatabase(); + } + + @Override + public @Nullable SavedSign getSign(String name) { + return signs.get(name.toLowerCase()); + } + + @Override + public List getSigns(Predicate filter) { + return new ArrayList<>(signs.values()).stream().filter(filter).toList(); + } + + @Override + public void saveSign(SavedSign sign) { + signs.put(sign.getName().toLowerCase(), sign); + persistSign("signs." + sign.getName().toLowerCase(), sign); + } + + + @Override + public void deleteSign(String name) { + var removed = signs.remove(name.toLowerCase()); + if (removed != null) { + config.setEntry("signs." + name.toLowerCase(), null); + } + } + + @Override + public @Nullable SavedSignGroup getGroup(String name) { + return groups.get(name.toLowerCase()); + } + + @Override + public List getGroups(Predicate filter) { + return new ArrayList<>(groups.values()).stream().filter(filter).toList(); + } + + @Override + public void saveGroup(SavedSignGroup group) { + groups.put(group.getName().toLowerCase(), group); + var basePath = "groups." + group.getName().toLowerCase(); + persistSign(basePath + ".displaySign", group.getDisplaySign()); + // Clear old members and rewrite + config.setEntry(basePath + ".members", null); + var memberSigns = group.getMemberSigns(); + for (int i = 0; i < memberSigns.size(); i++) { + persistSign(basePath + ".members.sign_" + i, memberSigns.get(i)); + } + } + + @Override + public void deleteGroup(String name) { + var removed = groups.remove(name.toLowerCase()); + if (removed != null) { + config.setEntry("groups." + name.toLowerCase(), null); + } + } + + @Override + public List getGroupNames() { + return new ArrayList<>(groups.keySet()); + } + + @Override + public void saveDatabase() { + signs.values().forEach(this::saveSign); + groups.values().forEach(this::saveGroup); + config.save(); + } +} diff --git a/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java b/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java index 2c9756c..cde09a9 100644 --- a/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java +++ b/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java @@ -96,7 +96,7 @@ public void setHasChanged(boolean hasChanged) { @Override public String getPlainItemText(ChatPaginator paginator, ICommandContext generatorInfo) { - return "Attempt #" + attemptNumber + " - Successful: " + successful; + return attemptNotes; } @Override diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index c34f869..5b2dfb2 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -10,4 +10,6 @@ softdepend: - Vault - dynmap - Essentials + - EssentialsChat + - LuckPerms - WorldEdit \ No newline at end of file