From 3b4e634179d47ab6b2c0d36447b1c27f24f26481 Mon Sep 17 00:00:00 2001 From: Piotr Zych <77621271+P1otrulla@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:17:07 +0200 Subject: [PATCH] Init --- .../com/eternalcode/core/EternalCoreApi.java | 9 + .../com/eternalcode/core/feature/kit/Kit.java | 204 ++++++++++++++++++ .../core/feature/kit/KitClaimResult.java | 23 ++ .../core/feature/kit/KitClaimService.java | 13 ++ .../core/feature/kit/KitClaimStatus.java | 14 ++ .../core/feature/kit/KitCooldownService.java | 34 +++ .../core/feature/kit/KitService.java | 31 +++ .../core/feature/kit/event/KitClaimEvent.java | 54 +++++ .../eternalcode/core/EternalCoreApiImpl.java | 18 ++ .../core/feature/kit/KitClaimNotifier.java | 46 ++++ .../core/feature/kit/KitClaimServiceImpl.java | 107 +++++++++ .../core/feature/kit/KitConfig.java | 128 +++++++++++ .../core/feature/kit/KitModule.java | 11 + .../core/feature/kit/KitNotices.java | 51 +++++ .../core/feature/kit/KitPermissions.java | 10 + .../core/feature/kit/KitServiceImpl.java | 105 +++++++++ .../core/feature/kit/KitSettings.java | 11 + .../core/feature/kit/command/KitArgument.java | 52 +++++ .../core/feature/kit/command/KitCommand.java | 46 ++++ .../kit/command/admin/KitAdminSaver.java | 41 ++++ .../kit/command/admin/KitCommandsCommand.java | 51 +++++ .../kit/command/admin/KitCooldownCommand.java | 40 ++++ .../kit/command/admin/KitCreateCommand.java | 87 ++++++++ .../kit/command/admin/KitDeleteCommand.java | 37 ++++ .../command/admin/KitDisplayNameCommand.java | 36 ++++ .../kit/command/admin/KitIconCommand.java | 45 ++++ .../kit/command/admin/KitItemsCommand.java | 31 +++ .../command/admin/KitPermissionCommand.java | 35 +++ .../admin/KitResetCooldownCommand.java | 44 ++++ .../kit/command/admin/KitSlotCommand.java | 48 +++++ .../kit/cooldown/KitCooldownController.java | 43 ++++ .../kit/cooldown/KitCooldownRepository.java | 23 ++ .../KitCooldownRepositoryOrmLite.java | 74 +++++++ .../kit/cooldown/KitCooldownServiceImpl.java | 58 +++++ .../kit/cooldown/KitCooldownStore.java | 111 ++++++++++ .../kit/cooldown/KitCooldownTable.java | 53 +++++ .../core/feature/kit/gui/KitGuiLayout.java | 28 +++ .../core/feature/kit/gui/KitInventory.java | 135 ++++++++++++ .../core/feature/kit/gui/KitItemRenderer.java | 93 ++++++++ .../core/feature/kit/gui/KitItemsEditor.java | 69 ++++++ .../kit/gui/KitItemsEditorController.java | 32 +++ .../feature/kit/gui/KitItemsEditorHolder.java | 36 ++++ .../feature/kit/gui/KitPreviewInventory.java | 68 ++++++ .../feature/kit/messages/ENKitMessages.java | 42 ++++ .../feature/kit/messages/KitMessages.java | 32 +++ .../feature/kit/messages/PLKitMessages.java | 42 ++++ .../feature/kit/repository/KitFileSchema.java | 98 +++++++++ .../feature/kit/repository/KitRepository.java | 15 ++ .../kit/repository/KitRepositoryYaml.java | 143 ++++++++++++ .../core/translation/Translation.java | 3 + .../implementation/ENTranslation.java | 4 + .../implementation/PLTranslation.java | 4 + 52 files changed, 2668 insertions(+) create mode 100644 eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/Kit.java create mode 100644 eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimResult.java create mode 100644 eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimService.java create mode 100644 eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimStatus.java create mode 100644 eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitCooldownService.java create mode 100644 eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitService.java create mode 100644 eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/event/KitClaimEvent.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitClaimNotifier.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitClaimServiceImpl.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitConfig.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitModule.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitNotices.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitPermissions.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitServiceImpl.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitSettings.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/KitArgument.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/KitCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitAdminSaver.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCommandsCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCooldownCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCreateCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitDeleteCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitDisplayNameCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitIconCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitItemsCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitPermissionCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitResetCooldownCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitSlotCommand.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownController.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownRepository.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownRepositoryOrmLite.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownServiceImpl.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownStore.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownTable.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitGuiLayout.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitInventory.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemRenderer.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditor.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditorController.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditorHolder.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitPreviewInventory.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/ENKitMessages.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/KitMessages.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/PLKitMessages.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitFileSchema.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitRepository.java create mode 100644 eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitRepositoryYaml.java diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/EternalCoreApi.java b/eternalcore-api/src/main/java/com/eternalcode/core/EternalCoreApi.java index 9c56892ce..8849db2a0 100644 --- a/eternalcore-api/src/main/java/com/eternalcode/core/EternalCoreApi.java +++ b/eternalcore-api/src/main/java/com/eternalcode/core/EternalCoreApi.java @@ -4,6 +4,9 @@ import com.eternalcode.core.feature.catboy.CatboyService; import com.eternalcode.core.feature.home.HomeService; import com.eternalcode.core.feature.jail.JailService; +import com.eternalcode.core.feature.kit.KitClaimService; +import com.eternalcode.core.feature.kit.KitCooldownService; +import com.eternalcode.core.feature.kit.KitService; import com.eternalcode.core.feature.msg.MsgService; import com.eternalcode.core.feature.ignore.IgnoreService; import com.eternalcode.core.feature.randomteleport.RandomTeleportService; @@ -23,6 +26,12 @@ public interface EternalCoreApi { JailService getJailService(); + KitService getKitService(); + + KitCooldownService getKitCooldownService(); + + KitClaimService getKitClaimService(); + MsgService getMsgService(); RandomTeleportService getRandomTeleportService(); diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/Kit.java b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/Kit.java new file mode 100644 index 000000000..2093421b1 --- /dev/null +++ b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/Kit.java @@ -0,0 +1,204 @@ +package com.eternalcode.core.feature.kit; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.regex.Pattern; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +/** + * Immutable kit definition. Every {@link ItemStack} going in or out is defensively cloned, + * so mutating returned items never changes the kit. Use {@link #toBuilder()} to derive a modified copy. + * + * @param name unique kit name, see {@link #isValidName(String)}. + * @param displayName MiniMessage formatted display name. + */ +public record Kit( + String name, + String displayName, + Duration cooldown, + String permission, + List items, + List commands, + ItemStack icon, + int slot +) { + + public static final int MIN_SLOT = 0; + + private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9_-]{1,32}$"); + private static final Material DEFAULT_ICON = Material.CHEST; + + public Kit { + if (!isValidName(name)) { + throw new IllegalArgumentException("Invalid kit name '" + name + "', expected " + NAME_PATTERN.pattern()); + } + + if (cooldown.isNegative()) { + throw new IllegalArgumentException("cooldown cannot be negative"); + } + + if (permission.isBlank()) { + throw new IllegalArgumentException("permission cannot be blank"); + } + + if (icon.getType().isAir()) { + throw new IllegalArgumentException("icon cannot be air"); + } + + if (slot < MIN_SLOT) { + throw new IllegalArgumentException("slot cannot be lower than " + MIN_SLOT); + } + + displayName = displayName == null ? name : displayName; + items = cloneItems(items); + commands = List.copyOf(commands); + icon = icon.clone(); + } + + public static boolean isValidName(String name) { + return name != null && NAME_PATTERN.matcher(name).matches(); + } + + public static Builder builder(String name) { + return new Builder(name); + } + + public Builder toBuilder() { + return new Builder(this.name) + .displayName(this.displayName) + .cooldown(this.cooldown) + .permission(this.permission) + .items(this.items) + .commands(this.commands) + .icon(this.icon) + .slot(this.slot); + } + + public boolean hasCooldown() { + return !this.cooldown.isZero(); + } + + /** + * @return defensive copy - ItemStack is mutable. + */ + @Override + public List items() { + return cloneItems(this.items); + } + + /** + * @return defensive copy - ItemStack is mutable. + */ + @Override + public ItemStack icon() { + return this.icon.clone(); + } + + private static List cloneItems(Collection source) { + List copy = new ArrayList<>(source.size()); + + for (ItemStack item : source) { + if (item != null && !item.getType().isAir()) { + copy.add(item.clone()); + } + } + + return List.copyOf(copy); + } + + /** + * Kits are identified by name only. + */ + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (!(other instanceof Kit kit)) { + return false; + } + + return this.name.equals(kit.name); + } + + @Override + public int hashCode() { + return this.name.hashCode(); + } + + @Override + public String toString() { + return "Kit{name='" + this.name + "', cooldown=" + this.cooldown + ", permission='" + this.permission + "'}"; + } + + public static final class Builder { + + private final String name; + private String displayName; + private Duration cooldown = Duration.ZERO; + private String permission; + private List items = List.of(); + private List commands = List.of(); + private ItemStack icon = new ItemStack(DEFAULT_ICON); + private int slot = MIN_SLOT; + + private Builder(String name) { + this.name = name; + } + + public Builder displayName(String displayName) { + this.displayName = displayName; + return this; + } + + public Builder cooldown(Duration cooldown) { + this.cooldown = cooldown; + return this; + } + + public Builder permission(String permission) { + this.permission = permission; + return this; + } + + public Builder items(Collection items) { + this.items = new ArrayList<>(items); + return this; + } + + public Builder commands(Collection commands) { + this.commands = List.copyOf(commands); + return this; + } + + public Builder icon(ItemStack icon) { + this.icon = icon; + return this; + } + + public Builder slot(int slot) { + this.slot = slot; + return this; + } + + /** + * Validation and defensive copying happen in the record's compact constructor. + */ + public Kit build() { + return new Kit( + this.name, + this.displayName, + this.cooldown, + this.permission, + this.items, + this.commands, + this.icon, + this.slot + ); + } + } +} diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimResult.java b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimResult.java new file mode 100644 index 000000000..25a15ffc3 --- /dev/null +++ b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimResult.java @@ -0,0 +1,23 @@ +package com.eternalcode.core.feature.kit; + +import java.time.Duration; +import java.util.Objects; + +/** + * @param status result of the claim. + * @param remainingCooldown remaining cooldown, {@link Duration#ZERO} unless status is {@link KitClaimStatus#ON_COOLDOWN}. + */ +public record KitClaimResult(KitClaimStatus status, Duration remainingCooldown) { + + public static KitClaimResult of(KitClaimStatus status) { + return new KitClaimResult(status, Duration.ZERO); + } + + public static KitClaimResult onCooldown(Duration remainingCooldown) { + return new KitClaimResult(KitClaimStatus.ON_COOLDOWN, remainingCooldown); + } + + public boolean isSuccess() { + return this.status == KitClaimStatus.SUCCESS; + } +} diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimService.java b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimService.java new file mode 100644 index 000000000..ce4727303 --- /dev/null +++ b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimService.java @@ -0,0 +1,13 @@ +package com.eternalcode.core.feature.kit; + +import org.bukkit.entity.Player; + +public interface KitClaimService { + + /** + * Full claim flow: permission check, cooldown check, {@link com.eternalcode.core.feature.kit.event.KitClaimEvent}, + * cooldown and item/command delivery. Must be called from the player's thread (main thread on Paper). + */ + KitClaimResult claimKit(Player player, Kit kit); + +} diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimStatus.java b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimStatus.java new file mode 100644 index 000000000..ca37a0f0b --- /dev/null +++ b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitClaimStatus.java @@ -0,0 +1,14 @@ +package com.eternalcode.core.feature.kit; + +public enum KitClaimStatus { + + SUCCESS, + NO_PERMISSION, + ON_COOLDOWN, + CANCELLED, + /** + * Player's cooldowns are still being loaded from the database (right after join) or loading failed. + */ + COOLDOWNS_NOT_LOADED + +} diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitCooldownService.java b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitCooldownService.java new file mode 100644 index 000000000..45dd6b1f6 --- /dev/null +++ b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitCooldownService.java @@ -0,0 +1,34 @@ +package com.eternalcode.core.feature.kit; + +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Kit cooldowns. Cooldowns of online players are kept in memory (loaded on join), + * every change is persisted to the database, so cooldowns are shared across the whole network. + */ +public interface KitCooldownService { + + /** + * @return remaining cooldown, or empty if the kit can be claimed or the player is offline. + */ + Optional getRemainingCooldown(UUID playerUniqueId, Kit kit); + + /** + * @return remaining cooldowns of all kits that are still blocked, keyed by kit name (empty for offline players). + */ + Map getRemainingCooldowns(UUID playerUniqueId); + + /** + * Starts the kit cooldown for the player. Kits without cooldown are ignored. + */ + void applyCooldown(UUID playerUniqueId, Kit kit); + + /** + * Resets the kit cooldown, works for offline players too. + */ + void resetCooldown(UUID playerUniqueId, Kit kit); + +} diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitService.java b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitService.java new file mode 100644 index 000000000..cf7ca237a --- /dev/null +++ b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/KitService.java @@ -0,0 +1,31 @@ +package com.eternalcode.core.feature.kit; + +import java.util.List; +import java.util.Optional; + +/** + * Manages kit definitions stored in {@code plugins/EternalCore/kits/.yml}. + * Reads are served from memory, writes update memory immediately and are persisted in the background. + */ +public interface KitService { + + Optional findKit(String name); + + boolean exists(String name); + + /** + * @return all kits sorted by their GUI slot. + */ + List getKits(); + + /** + * Creates the kit or replaces an existing kit with the same name. + */ + void saveKit(Kit kit); + + /** + * Deletes the kit file together with all cooldowns bound to it. + */ + void deleteKit(String name); + +} diff --git a/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/event/KitClaimEvent.java b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/event/KitClaimEvent.java new file mode 100644 index 000000000..31756a763 --- /dev/null +++ b/eternalcore-api/src/main/java/com/eternalcode/core/feature/kit/event/KitClaimEvent.java @@ -0,0 +1,54 @@ +package com.eternalcode.core.feature.kit.event; + +import com.eternalcode.core.feature.kit.Kit; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; + +/** + * Called right before a kit is given to the player (after permission and cooldown checks). + * Cancelling it prevents both the delivery and the cooldown. + */ +public class KitClaimEvent extends PlayerEvent implements Cancellable { + + private static final HandlerList HANDLER_LIST = new HandlerList(); + + private final Kit kit; + private final boolean bypassingCooldown; + + private boolean cancelled; + + public KitClaimEvent(Player player, Kit kit, boolean bypassingCooldown) { + super(player); + this.kit = kit; + this.bypassingCooldown = bypassingCooldown; + } + + public Kit getKit() { + return this.kit; + } + + public boolean isBypassingCooldown() { + return this.bypassingCooldown; + } + + @Override + public boolean isCancelled() { + return this.cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/EternalCoreApiImpl.java b/eternalcore-core/src/main/java/com/eternalcode/core/EternalCoreApiImpl.java index 4680437ba..a23f3d9a5 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/EternalCoreApiImpl.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/EternalCoreApiImpl.java @@ -5,6 +5,9 @@ import com.eternalcode.core.feature.home.HomeService; import com.eternalcode.core.feature.ignore.IgnoreService; import com.eternalcode.core.feature.jail.JailService; +import com.eternalcode.core.feature.kit.KitClaimService; +import com.eternalcode.core.feature.kit.KitCooldownService; +import com.eternalcode.core.feature.kit.KitService; import com.eternalcode.core.feature.msg.MsgService; import com.eternalcode.core.feature.randomteleport.RandomTeleportService; import com.eternalcode.core.feature.spawn.SpawnService; @@ -45,6 +48,21 @@ public JailService getJailService() { return this.dependencyProvider.getDependency(JailService.class); } + @Override + public KitService getKitService() { + return this.dependencyProvider.getDependency(KitService.class); + } + + @Override + public KitCooldownService getKitCooldownService() { + return this.dependencyProvider.getDependency(KitCooldownService.class); + } + + @Override + public KitClaimService getKitClaimService() { + return this.dependencyProvider.getDependency(KitClaimService.class); + } + @Override public MsgService getMsgService() { return this.dependencyProvider.getDependency(MsgService.class); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitClaimNotifier.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitClaimNotifier.java new file mode 100644 index 000000000..0666cc5f5 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitClaimNotifier.java @@ -0,0 +1,46 @@ +package com.eternalcode.core.feature.kit; + +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import com.eternalcode.core.util.DurationUtil; +import com.eternalcode.multification.notice.Notice; +import java.util.Map; +import java.util.function.Function; +import org.bukkit.entity.Player; + +/** + * Claims a kit and translates {@link KitClaimResult} into a notice. Shared by /kit and the GUI. + */ +@Service +public class KitClaimNotifier { + + private final KitClaimService claimService; + private final KitNotices notices; + + @Inject + KitClaimNotifier(KitClaimService claimService, KitNotices notices) { + this.claimService = claimService; + this.notices = notices; + } + + public void claimAndNotify(Player player, Kit kit) { + KitClaimResult result = this.claimService.claimKit(player, kit); + + this.notices.send(player, this.resolveMessage(result.status()), Map.of( + KitNotices.KIT, kit.displayName(), + KitNotices.KIT_NAME, kit.name(), + KitNotices.COOLDOWN, DurationUtil.format(result.remainingCooldown(), true) + )); + } + + private Function resolveMessage(KitClaimStatus status) { + return switch (status) { + case SUCCESS -> KitMessages::claimed; + case NO_PERMISSION -> KitMessages::noPermission; + case ON_COOLDOWN -> KitMessages::onCooldown; + case CANCELLED -> KitMessages::claimCancelled; + case COOLDOWNS_NOT_LOADED -> KitMessages::databaseError; + }; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitClaimServiceImpl.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitClaimServiceImpl.java new file mode 100644 index 000000000..d49ad564b --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitClaimServiceImpl.java @@ -0,0 +1,107 @@ +package com.eternalcode.core.feature.kit; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.core.event.EventCaller; +import com.eternalcode.core.feature.kit.cooldown.KitCooldownStore; +import com.eternalcode.core.feature.kit.event.KitClaimEvent; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +/** + * Claim flow: permission -> cooldown (memory) -> event -> cooldown persisted -> items + commands. + * Fully synchronous on the player's thread, so spam clicking cannot claim a kit twice. + */ +@Service +class KitClaimServiceImpl implements KitClaimService { + + private static final String PLAYER_PLACEHOLDER = "{PLAYER}"; + + private final KitCooldownService cooldownService; + private final KitCooldownStore cooldownStore; + private final EventCaller eventCaller; + private final Scheduler scheduler; + private final Server server; + + @Inject + KitClaimServiceImpl( + KitCooldownService cooldownService, + KitCooldownStore cooldownStore, + EventCaller eventCaller, + Scheduler scheduler, + Server server + ) { + this.cooldownService = cooldownService; + this.cooldownStore = cooldownStore; + this.eventCaller = eventCaller; + this.scheduler = scheduler; + this.server = server; + } + + @Override + public KitClaimResult claimKit(Player player, Kit kit) { + if (!player.hasPermission(kit.permission())) { + return KitClaimResult.of(KitClaimStatus.NO_PERMISSION); + } + + UUID uniqueId = player.getUniqueId(); + boolean bypassingCooldown = player.hasPermission(KitPermissions.BYPASS_COOLDOWN); + boolean checkCooldown = !bypassingCooldown && kit.hasCooldown(); + + if (checkCooldown && !this.cooldownStore.isLoaded(uniqueId)) { + return KitClaimResult.of(KitClaimStatus.COOLDOWNS_NOT_LOADED); + } + + if (checkCooldown) { + Optional remaining = this.cooldownService.getRemainingCooldown(uniqueId, kit); + + if (remaining.isPresent()) { + return KitClaimResult.onCooldown(remaining.get()); + } + } + + KitClaimEvent event = this.eventCaller.callEvent(new KitClaimEvent(player, kit, bypassingCooldown)); + + if (event.isCancelled()) { + return KitClaimResult.of(KitClaimStatus.CANCELLED); + } + + if (!bypassingCooldown) { + this.cooldownService.applyCooldown(uniqueId, kit); + } + + this.giveItems(player, kit); + this.dispatchCommands(player, kit); + + return KitClaimResult.of(KitClaimStatus.SUCCESS); + } + + private void giveItems(Player player, Kit kit) { + ItemStack[] items = kit.items().toArray(new ItemStack[0]); + Map leftovers = player.getInventory().addItem(items); + + leftovers.values().forEach(leftover -> player.getWorld().dropItemNaturally(player.getLocation(), leftover)); + } + + private void dispatchCommands(Player player, Kit kit) { + List commands = kit.commands(); + + if (commands.isEmpty()) { + return; + } + + String playerName = player.getName(); + + this.scheduler.run(() -> commands.forEach(command -> this.server.dispatchCommand( + this.server.getConsoleSender(), + command.replace(PLAYER_PLACEHOLDER, playerName) + ))); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitConfig.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitConfig.java new file mode 100644 index 000000000..c66616028 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitConfig.java @@ -0,0 +1,128 @@ +package com.eternalcode.core.feature.kit; + +import com.cryptomorin.xseries.XMaterial; +import com.eternalcode.core.configuration.AbstractConfigurationFile; +import com.eternalcode.core.injector.annotations.component.ConfigurationFile; +import com.eternalcode.core.util.MaterialUtil; +import eu.okaeri.configs.OkaeriConfig; +import eu.okaeri.configs.annotation.Comment; +import eu.okaeri.configs.annotation.Exclude; +import eu.okaeri.configs.annotation.Header; +import java.io.File; +import java.util.List; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.bukkit.Material; +import org.bukkit.event.inventory.ClickType; + +@Getter +@Accessors(fluent = true) +@ConfigurationFile +@Header({ + "Kit definitions live in the kits/ directory - one .yml file per kit.", + "Create and edit them in-game with /kitadmin or by hand, then use /eternalcore reload.", + "Kit cooldowns are stored in the database." +}) +public class KitConfig extends AbstractConfigurationFile implements KitSettings { + + @Exclude + private static final String FILE_NAME = "kits.yml"; + + @Comment("# Permission assigned to newly created kits: ") + public String defaultPermissionPrefix = "eternalcore.kits."; + + @Comment("# Hide kits the player has no permission for in /kit GUI") + public boolean hideKitsWithoutPermission = false; + + @Comment("# Kit GUI (MiniMessage format)") + public GuiSection gui = new GuiSection(); + + @Getter + @Accessors(fluent = true) + public static class GuiSection extends OkaeriConfig { + + @Comment("# Main /kit GUI") + public String title = "» Kits"; + public int rows = 3; + + public boolean fillEmptySlots = true; + public Material fillerMaterial = MaterialUtil.parseRequired(XMaterial.GRAY_STAINED_GLASS_PANE); + + @Comment("# Click types: LEFT, RIGHT, SHIFT_LEFT, SHIFT_RIGHT, MIDDLE...") + public ClickType claimClick = ClickType.LEFT; + public ClickType previewClick = ClickType.RIGHT; + + @Comment({ "# Lines appended to kit icon lore", "# {COOLDOWN} - remaining cooldown, {KIT_COOLDOWN} - kit cooldown" }) + public List availableLore = List.of( + "", + "Cooldown: {KIT_COOLDOWN}", + "► Available to claim!", + "", + "» LMB to claim, RMB to preview" + ); + + public List cooldownLore = List.of( + "", + "Cooldown: {KIT_COOLDOWN}", + "✘ Available in: {COOLDOWN}", + "", + "» RMB to preview" + ); + + public List noPermissionLore = List.of( + "", + "✘ You don't have access to this kit!", + "", + "» RMB to preview" + ); + + @Comment({ "# Preview GUI, buttons are placed in the last row", "# {KIT} - kit display name" }) + public String previewTitle = "» Kit {KIT}"; + + public Button claimButton = new Button( + MaterialUtil.parseRequired(XMaterial.LIME_DYE), + "► Claim kit", + List.of("» Click to claim this kit"), + 5 + ); + + public Button backButton = new Button( + MaterialUtil.parseRequired(XMaterial.ARROW), + "« Back", + List.of("» Click to return to the kit list"), + 3 + ); + + @Comment({ "# Admin items editor (/kitadmin items )", "# {KIT} - kit name" }) + public String editorTitle = "» Editing kit: {KIT}"; + public int editorRows = 4; + } + + @Getter + @Accessors(fluent = true) + public static class Button extends OkaeriConfig { + + public boolean enabled = true; + public Material material = Material.STONE; + public String name = ""; + public List lore = List.of(); + + @Comment("# Column in the last row (0-8)") + public int column = 0; + + public Button() { + } + + public Button(Material material, String name, List lore, int column) { + this.material = material; + this.name = name; + this.lore = lore; + this.column = column; + } + } + + @Override + public File getConfigFile(File dataFolder) { + return new File(dataFolder, FILE_NAME); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitModule.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitModule.java new file mode 100644 index 000000000..4859fd30c --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitModule.java @@ -0,0 +1,11 @@ +package com.eternalcode.core.feature.kit; + +import com.eternalcode.core.modules.ModuleDescription; + +@ModuleDescription("Kits: claimable item sets with database-backed cooldowns, GUI and admin editor (/kit, /kitadmin).") +final class KitModule { + + private KitModule() { + } + +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitNotices.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitNotices.java new file mode 100644 index 000000000..dc78ac1c1 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitNotices.java @@ -0,0 +1,51 @@ +package com.eternalcode.core.feature.kit; + +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import com.eternalcode.core.notice.EternalCoreBroadcast; +import com.eternalcode.core.notice.NoticeService; +import com.eternalcode.core.translation.Translation; +import com.eternalcode.core.viewer.Viewer; +import com.eternalcode.multification.notice.Notice; +import java.util.Map; +import java.util.function.Function; +import org.bukkit.command.CommandSender; + +/** + * Single entry point for kit notices - keeps commands and GUIs free of notice boilerplate. + */ +@Service +public class KitNotices { + + public static final String KIT = "{KIT}"; + public static final String KIT_NAME = "{KIT_NAME}"; + public static final String COOLDOWN = "{COOLDOWN}"; + public static final String PERMISSION = "{PERMISSION}"; + public static final String AMOUNT = "{AMOUNT}"; + public static final String SLOT = "{SLOT}"; + public static final String MAX = "{MAX}"; + public static final String NAME = "{NAME}"; + public static final String COMMAND = "{COMMAND}"; + public static final String PLAYER = "{PLAYER}"; + + private final NoticeService noticeService; + + @Inject + KitNotices(NoticeService noticeService) { + this.noticeService = noticeService; + } + + public void send(CommandSender sender, Function message) { + this.send(sender, message, Map.of()); + } + + public void send(CommandSender sender, Function message, Map placeholders) { + EternalCoreBroadcast broadcast = this.noticeService.create() + .sender(sender) + .notice(translation -> message.apply(translation.kit())); + + placeholders.forEach((key, value) -> broadcast.placeholder(key, value)); + broadcast.send(); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitPermissions.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitPermissions.java new file mode 100644 index 000000000..0768a7662 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitPermissions.java @@ -0,0 +1,10 @@ +package com.eternalcode.core.feature.kit; + +public final class KitPermissions { + + public static final String BYPASS_COOLDOWN = "eternalcore.kit.bypass"; + public static final String ADMIN = "eternalcore.kitadmin"; + + private KitPermissions() { + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitServiceImpl.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitServiceImpl.java new file mode 100644 index 000000000..5ca601a0b --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitServiceImpl.java @@ -0,0 +1,105 @@ +package com.eternalcode.core.feature.kit; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.core.feature.kit.cooldown.KitCooldownStore; +import com.eternalcode.core.feature.kit.repository.KitRepository; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import com.eternalcode.core.publish.Subscribe; +import com.eternalcode.core.publish.event.EternalReloadEvent; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Copy-on-write cache: readers never lock, writers swap an immutable snapshot. + */ +@Service +class KitServiceImpl implements KitService { + + private static final Comparator BY_SLOT = Comparator.comparingInt(Kit::slot) + .thenComparing(Kit::name); + + private final Object writeLock = new Object(); + private volatile Map kits = Map.of(); + + private final KitRepository kitRepository; + private final KitCooldownStore cooldownStore; + + @Inject + KitServiceImpl(KitRepository kitRepository, KitCooldownStore cooldownStore) { + this.kitRepository = kitRepository; + this.cooldownStore = cooldownStore; + + this.loadKits(); + } + + @Override + public Optional findKit(String name) { + if (name == null) { + return Optional.empty(); + } + + return Optional.ofNullable(this.kits.get(name)); + } + + @Override + public boolean exists(String name) { + return this.findKit(name).isPresent(); + } + + @Override + public List getKits() { + return this.kits.values().stream() + .sorted(BY_SLOT) + .toList(); + } + + @Override + public void saveKit(Kit kit) { + this.edit(snapshot -> snapshot.put(kit.name(), kit)); + + this.kitRepository.save(kit) + .exceptionally(FutureHandler::handleException); + } + + @Override + public void deleteKit(String name) { + this.edit(snapshot -> snapshot.remove(name)); + + this.kitRepository.delete(name) + .exceptionally(FutureHandler::handleException); + this.cooldownStore.deleteKit(name); + } + + @Subscribe(EternalReloadEvent.class) + void onReload(EternalReloadEvent event) { + this.loadKits(); + } + + private void loadKits() { + this.kitRepository.findAll() + .thenAccept(loadedKits -> { + Map loaded = loadedKits.stream() + .collect(Collectors.toUnmodifiableMap(Kit::name, Function.identity())); + + synchronized (this.writeLock) { + this.kits = loaded; + } + }) + .exceptionally(FutureHandler::handleException); + } + + private void edit(Consumer> editor) { + synchronized (this.writeLock) { + Map snapshot = new HashMap<>(this.kits); + editor.accept(snapshot); + this.kits = Map.copyOf(snapshot); + } + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitSettings.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitSettings.java new file mode 100644 index 000000000..e41f61aa7 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/KitSettings.java @@ -0,0 +1,11 @@ +package com.eternalcode.core.feature.kit; + +public interface KitSettings { + + String defaultPermissionPrefix(); + + boolean hideKitsWithoutPermission(); + + KitConfig.GuiSection gui(); + +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/KitArgument.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/KitArgument.java new file mode 100644 index 000000000..23c50db9e --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/KitArgument.java @@ -0,0 +1,52 @@ +package com.eternalcode.core.feature.kit.command; + +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitService; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.lite.LiteArgument; +import com.eternalcode.core.litecommand.argument.AbstractViewerArgument; +import com.eternalcode.core.notice.NoticeService; +import com.eternalcode.core.translation.Translation; +import com.eternalcode.core.translation.TranslationManager; +import dev.rollczi.litecommands.argument.Argument; +import dev.rollczi.litecommands.argument.parser.ParseResult; +import dev.rollczi.litecommands.invocation.Invocation; +import dev.rollczi.litecommands.suggestion.SuggestionContext; +import dev.rollczi.litecommands.suggestion.SuggestionResult; +import java.util.Locale; +import org.bukkit.command.CommandSender; + +@LiteArgument(type = Kit.class) +class KitArgument extends AbstractViewerArgument { + + private final KitService kitService; + private final NoticeService noticeService; + + @Inject + KitArgument(KitService kitService, TranslationManager translationManager, NoticeService noticeService) { + super(translationManager); + this.kitService = kitService; + this.noticeService = noticeService; + } + + @Override + public ParseResult parse(Invocation invocation, String argument, Translation translation) { + return this.kitService.findKit(argument.toLowerCase(Locale.ROOT)) + .map(ParseResult::success) + .orElseGet(() -> ParseResult.failure(this.noticeService.create() + .sender(invocation.sender()) + .notice(translation.kit().notFound()) + .placeholder(KitNotices.KIT, argument))); + } + + @Override + public SuggestionResult suggest(Invocation invocation, Argument argument, SuggestionContext context) { + CommandSender sender = invocation.sender(); + + return SuggestionResult.of(this.kitService.getKits().stream() + .filter(kit -> sender.hasPermission(kit.permission())) + .map(Kit::name) + .toList()); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/KitCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/KitCommand.java new file mode 100644 index 000000000..77a426de7 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/KitCommand.java @@ -0,0 +1,46 @@ +package com.eternalcode.core.feature.kit.command; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.annotations.scan.permission.PermissionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitClaimNotifier; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.gui.KitInventory; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import org.bukkit.entity.Player; + +@Command(name = "kit", aliases = { "kits" }) +@Permission("eternalcore.kit") +@PermissionDocs( + name = "Kit Cooldown Bypass", + permission = KitPermissions.BYPASS_COOLDOWN, + description = "Allows claiming kits without cooldown" +) +class KitCommand { + + private final KitInventory kitInventory; + private final KitClaimNotifier claimNotifier; + + @Inject + KitCommand(KitInventory kitInventory, KitClaimNotifier claimNotifier) { + this.kitInventory = kitInventory; + this.claimNotifier = claimNotifier; + } + + @Execute + @DescriptionDocs(description = "Opens kit GUI") + void open(@Sender Player player) { + this.kitInventory.open(player); + } + + @Execute + @DescriptionDocs(description = "Claims the kit", arguments = "") + void claim(@Sender Player player, @Arg Kit kit) { + this.claimNotifier.claimAndNotify(player, kit); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitAdminSaver.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitAdminSaver.java new file mode 100644 index 000000000..004345b66 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitAdminSaver.java @@ -0,0 +1,41 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitService; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import com.eternalcode.multification.notice.Notice; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import org.bukkit.command.CommandSender; + +/** + * Persists an admin change and reports the outcome - shared by every /kitadmin command. + */ +@Service +class KitAdminSaver { + + private final KitService kitService; + private final KitNotices notices; + + @Inject + KitAdminSaver(KitService kitService, KitNotices notices) { + this.kitService = kitService; + this.notices = notices; + } + + void save(CommandSender sender, Kit kit, Function successMessage) { + this.save(sender, kit, successMessage, Map.of()); + } + + void save(CommandSender sender, Kit kit, Function successMessage, Map placeholders) { + Map allPlaceholders = new HashMap<>(placeholders); + allPlaceholders.put(KitNotices.KIT, kit.name()); + + this.kitService.saveKit(kit); + this.notices.send(sender, successMessage, allPlaceholders); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCommandsCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCommandsCommand.java new file mode 100644 index 000000000..8ec66c83a --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCommandsCommand.java @@ -0,0 +1,51 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.join.Join; +import dev.rollczi.litecommands.annotations.permission.Permission; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.bukkit.command.CommandSender; + +@Command(name = "kitadmin commands") +@Permission(KitPermissions.ADMIN) +class KitCommandsCommand { + + private static final String COMMAND_PREFIX = "/"; + + private final KitAdminSaver saver; + + @Inject + KitCommandsCommand(KitAdminSaver saver) { + this.saver = saver; + } + + @Execute(name = "add") + @DescriptionDocs(description = "Adds console command executed on claim, {PLAYER} = player name", arguments = " ") + void add(@Sender CommandSender sender, @Arg Kit kit, @Join String command) { + String normalized = command.startsWith(COMMAND_PREFIX) ? command.substring(COMMAND_PREFIX.length()) : command; + + List commands = new ArrayList<>(kit.commands()); + commands.add(normalized); + + this.saver.save(sender, kit.toBuilder().commands(commands).build(), KitMessages::commandAdded, Map.of( + KitNotices.COMMAND, normalized + )); + } + + @Execute(name = "clear") + @DescriptionDocs(description = "Removes all kit commands", arguments = "") + void clear(@Sender CommandSender sender, @Arg Kit kit) { + this.saver.save(sender, kit.toBuilder().commands(List.of()).build(), KitMessages::commandsCleared); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCooldownCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCooldownCommand.java new file mode 100644 index 000000000..30ae1bc5d --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCooldownCommand.java @@ -0,0 +1,40 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.util.DurationUtil; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import java.time.Duration; +import java.util.Map; +import org.bukkit.command.CommandSender; + +@Command(name = "kitadmin cooldown") +@Permission(KitPermissions.ADMIN) +class KitCooldownCommand { + + private final KitAdminSaver saver; + + @Inject + KitCooldownCommand(KitAdminSaver saver) { + this.saver = saver; + } + + @Execute + @DescriptionDocs(description = "Changes kit cooldown (0s = no cooldown)", arguments = " ") + void setCooldown(@Sender CommandSender sender, @Arg Kit kit, @Arg Duration cooldown) { + Duration safeCooldown = cooldown.isNegative() ? Duration.ZERO : cooldown; + Kit updated = kit.toBuilder().cooldown(safeCooldown).build(); + + this.saver.save(sender, updated, KitMessages::cooldownChanged, Map.of( + KitNotices.COOLDOWN, DurationUtil.format(safeCooldown, true) + )); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCreateCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCreateCommand.java new file mode 100644 index 000000000..da9bdd406 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitCreateCommand.java @@ -0,0 +1,87 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.KitService; +import com.eternalcode.core.feature.kit.KitSettings; +import com.eternalcode.core.feature.kit.gui.KitGuiLayout; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import java.time.Duration; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.bukkit.command.CommandSender; + +@Command(name = "kitadmin create") +@Permission(KitPermissions.ADMIN) +class KitCreateCommand { + + private final KitService kitService; + private final KitSettings settings; + private final KitAdminSaver saver; + private final KitNotices notices; + + @Inject + KitCreateCommand(KitService kitService, KitSettings settings, KitAdminSaver saver, KitNotices notices) { + this.kitService = kitService; + this.settings = settings; + this.saver = saver; + this.notices = notices; + } + + @Execute + @DescriptionDocs(description = "Creates a kit without cooldown", arguments = "") + void createWithoutCooldown(@Sender CommandSender sender, @Arg String name) { + this.create(sender, name, Duration.ZERO); + } + + @Execute + @DescriptionDocs(description = "Creates a kit", arguments = " ") + void create(@Sender CommandSender sender, @Arg String name, @Arg Duration cooldown) { + String kitName = name.toLowerCase(Locale.ROOT); + + if (!Kit.isValidName(kitName)) { + this.notices.send(sender, KitMessages::invalidName, Map.of(KitNotices.KIT, name)); + return; + } + + if (this.kitService.exists(kitName)) { + this.notices.send(sender, KitMessages::alreadyExists, Map.of(KitNotices.KIT, kitName)); + return; + } + + String permission = this.settings.defaultPermissionPrefix() + kitName; + Kit kit = Kit.builder(kitName) + .cooldown(cooldown.isNegative() ? Duration.ZERO : cooldown) + .permission(permission) + .slot(this.findFreeSlot()) + .build(); + + this.saver.save(sender, kit, KitMessages::created, Map.of(KitNotices.PERMISSION, permission)); + } + + private int findFreeSlot() { + Set usedSlots = this.kitService.getKits().stream() + .map(Kit::slot) + .collect(Collectors.toSet()); + + int slotCount = KitGuiLayout.slotCount(this.settings.gui().rows()); + + for (int slot = Kit.MIN_SLOT; slot < slotCount; slot++) { + if (!usedSlots.contains(slot)) { + return slot; + } + } + + return Kit.MIN_SLOT; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitDeleteCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitDeleteCommand.java new file mode 100644 index 000000000..381d038a1 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitDeleteCommand.java @@ -0,0 +1,37 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.KitService; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import java.util.Map; +import org.bukkit.command.CommandSender; + +@Command(name = "kitadmin delete") +@Permission(KitPermissions.ADMIN) +class KitDeleteCommand { + + private final KitService kitService; + private final KitNotices notices; + + @Inject + KitDeleteCommand(KitService kitService, KitNotices notices) { + this.kitService = kitService; + this.notices = notices; + } + + @Execute + @DescriptionDocs(description = "Deletes the kit file and all its cooldowns", arguments = "") + void delete(@Sender CommandSender sender, @Arg Kit kit) { + this.kitService.deleteKit(kit.name()); + this.notices.send(sender, KitMessages::deleted, Map.of(KitNotices.KIT, kit.name())); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitDisplayNameCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitDisplayNameCommand.java new file mode 100644 index 000000000..254cccaf7 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitDisplayNameCommand.java @@ -0,0 +1,36 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.join.Join; +import dev.rollczi.litecommands.annotations.permission.Permission; +import java.util.Map; +import org.bukkit.command.CommandSender; + +@Command(name = "kitadmin displayname") +@Permission(KitPermissions.ADMIN) +class KitDisplayNameCommand { + + private final KitAdminSaver saver; + + @Inject + KitDisplayNameCommand(KitAdminSaver saver) { + this.saver = saver; + } + + @Execute + @DescriptionDocs(description = "Changes kit display name (MiniMessage)", arguments = " ") + void setDisplayName(@Sender CommandSender sender, @Arg Kit kit, @Join String displayName) { + this.saver.save(sender, kit.toBuilder().displayName(displayName).build(), KitMessages::displayNameChanged, Map.of( + KitNotices.NAME, displayName + )); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitIconCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitIconCommand.java new file mode 100644 index 000000000..1853512cf --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitIconCommand.java @@ -0,0 +1,45 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +@Command(name = "kitadmin icon") +@Permission(KitPermissions.ADMIN) +class KitIconCommand { + + private final KitAdminSaver saver; + private final KitNotices notices; + + @Inject + KitIconCommand(KitAdminSaver saver, KitNotices notices) { + this.saver = saver; + this.notices = notices; + } + + @Execute + @DescriptionDocs(description = "Sets kit GUI icon to the item in hand (name/lore of the item are kept)", arguments = "") + void setIcon(@Sender Player player, @Arg Kit kit) { + ItemStack hand = player.getInventory().getItemInMainHand(); + + if (hand.getType().isAir()) { + this.notices.send(player, KitMessages::emptyHand); + return; + } + + ItemStack icon = hand.clone(); + icon.setAmount(1); + + this.saver.save(player, kit.toBuilder().icon(icon).build(), KitMessages::iconChanged); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitItemsCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitItemsCommand.java new file mode 100644 index 000000000..276b1ce6d --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitItemsCommand.java @@ -0,0 +1,31 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.gui.KitItemsEditor; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import org.bukkit.entity.Player; + +@Command(name = "kitadmin items") +@Permission(KitPermissions.ADMIN) +class KitItemsCommand { + + private final KitItemsEditor itemsEditor; + + @Inject + KitItemsCommand(KitItemsEditor itemsEditor) { + this.itemsEditor = itemsEditor; + } + + @Execute + @DescriptionDocs(description = "Opens kit items editor, items are saved on close", arguments = "") + void edit(@Sender Player player, @Arg Kit kit) { + this.itemsEditor.open(player, kit); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitPermissionCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitPermissionCommand.java new file mode 100644 index 000000000..ad3dd3b37 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitPermissionCommand.java @@ -0,0 +1,35 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import java.util.Map; +import org.bukkit.command.CommandSender; + +@Command(name = "kitadmin permission") +@Permission(KitPermissions.ADMIN) +class KitPermissionCommand { + + private final KitAdminSaver saver; + + @Inject + KitPermissionCommand(KitAdminSaver saver) { + this.saver = saver; + } + + @Execute + @DescriptionDocs(description = "Changes permission required to claim the kit", arguments = " ") + void setPermission(@Sender CommandSender sender, @Arg Kit kit, @Arg String permission) { + Kit updated = kit.toBuilder().permission(permission).build(); + + this.saver.save(sender, updated, KitMessages::permissionChanged, Map.of(KitNotices.PERMISSION, permission)); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitResetCooldownCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitResetCooldownCommand.java new file mode 100644 index 000000000..69f244856 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitResetCooldownCommand.java @@ -0,0 +1,44 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitCooldownService; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import java.util.Map; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; + +@Command(name = "kitadmin resetcooldown") +@Permission(KitPermissions.ADMIN) +class KitResetCooldownCommand { + + private final KitCooldownService cooldownService; + private final KitNotices notices; + + @Inject + KitResetCooldownCommand(KitCooldownService cooldownService, KitNotices notices) { + this.cooldownService = cooldownService; + this.notices = notices; + } + + @Execute + @DescriptionDocs(description = "Resets player's kit cooldown", arguments = " ") + void reset(@Sender CommandSender sender, @Arg OfflinePlayer target, @Arg Kit kit) { + String targetName = target.getName() == null ? target.getUniqueId().toString() : target.getName(); + + this.cooldownService.resetCooldown(target.getUniqueId(), kit); + + this.notices.send(sender, KitMessages::cooldownReset, Map.of( + KitNotices.KIT, kit.name(), + KitNotices.PLAYER, targetName + )); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitSlotCommand.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitSlotCommand.java new file mode 100644 index 000000000..3ad7cee57 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/command/admin/KitSlotCommand.java @@ -0,0 +1,48 @@ +package com.eternalcode.core.feature.kit.command.admin; + +import com.eternalcode.annotations.scan.command.DescriptionDocs; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitPermissions; +import com.eternalcode.core.feature.kit.KitSettings; +import com.eternalcode.core.feature.kit.gui.KitGuiLayout; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Sender; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; +import java.util.Map; +import org.bukkit.command.CommandSender; + +@Command(name = "kitadmin slot") +@Permission(KitPermissions.ADMIN) +class KitSlotCommand { + + private final KitAdminSaver saver; + private final KitSettings settings; + private final KitNotices notices; + + @Inject + KitSlotCommand(KitAdminSaver saver, KitSettings settings, KitNotices notices) { + this.saver = saver; + this.settings = settings; + this.notices = notices; + } + + @Execute + @DescriptionDocs(description = "Moves the kit to another GUI slot", arguments = " ") + void setSlot(@Sender CommandSender sender, @Arg Kit kit, @Arg int slot) { + int maxSlot = KitGuiLayout.slotCount(this.settings.gui().rows()) - 1; + + if (slot < Kit.MIN_SLOT || slot > maxSlot) { + this.notices.send(sender, KitMessages::invalidSlot, Map.of(KitNotices.MAX, String.valueOf(maxSlot))); + return; + } + + this.saver.save(sender, kit.toBuilder().slot(slot).build(), KitMessages::slotChanged, Map.of( + KitNotices.SLOT, String.valueOf(slot) + )); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownController.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownController.java new file mode 100644 index 000000000..835485cc2 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownController.java @@ -0,0 +1,43 @@ +package com.eternalcode.core.feature.kit.cooldown; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Controller; +import java.util.UUID; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +@Controller +class KitCooldownController implements Listener { + + private final KitCooldownStore store; + + @Inject + KitCooldownController(KitCooldownStore store, Server server) { + this.store = store; + + for (Player player : server.getOnlinePlayers()) { + this.load(player.getUniqueId()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + void onJoin(PlayerJoinEvent event) { + this.load(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR) + void onQuit(PlayerQuitEvent event) { + this.store.unload(event.getPlayer().getUniqueId()); + } + + private void load(UUID playerUniqueId) { + this.store.load(playerUniqueId) + .exceptionally(FutureHandler::handleException); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownRepository.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownRepository.java new file mode 100644 index 000000000..9b5c0e876 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownRepository.java @@ -0,0 +1,23 @@ +package com.eternalcode.core.feature.kit.cooldown; + +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +interface KitCooldownRepository { + + CompletableFuture> findExpiration(UUID playerUniqueId, String kitName); + + CompletableFuture> findExpirations(UUID playerUniqueId); + + CompletableFuture save(UUID playerUniqueId, String kitName, Instant expiresAt); + + CompletableFuture delete(UUID playerUniqueId, String kitName); + + CompletableFuture deleteByKit(String kitName); + + CompletableFuture deleteExpired(Instant now); + +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownRepositoryOrmLite.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownRepositoryOrmLite.java new file mode 100644 index 000000000..1a373ae0e --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownRepositoryOrmLite.java @@ -0,0 +1,74 @@ +package com.eternalcode.core.feature.kit.cooldown; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.core.database.AbstractRepositoryOrmLite; +import com.eternalcode.core.database.DatabaseManager; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Repository; +import com.j256.ormlite.stmt.DeleteBuilder; +import com.j256.ormlite.table.TableUtils; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +@Repository +class KitCooldownRepositoryOrmLite extends AbstractRepositoryOrmLite implements KitCooldownRepository { + + @Inject + private KitCooldownRepositoryOrmLite(DatabaseManager databaseManager, Scheduler scheduler) throws SQLException { + super(databaseManager, scheduler); + TableUtils.createTableIfNotExists(databaseManager.connectionSource(), KitCooldownTable.class); + } + + @Override + public CompletableFuture> findExpiration(UUID playerUniqueId, String kitName) { + String id = KitCooldownTable.createId(playerUniqueId, kitName); + + return this.selectSafe(KitCooldownTable.class, id) + .thenApply(table -> table.map(KitCooldownTable::getExpiresAt)); + } + + @Override + public CompletableFuture> findExpirations(UUID playerUniqueId) { + return this.action(KitCooldownTable.class, dao -> dao.queryBuilder() + .where() + .eq(KitCooldownTable.PLAYER_COLUMN, playerUniqueId) + .query()) + .thenApply(tables -> tables.stream() + .collect(Collectors.toUnmodifiableMap(KitCooldownTable::getKitName, KitCooldownTable::getExpiresAt))); + } + + @Override + public CompletableFuture save(UUID playerUniqueId, String kitName, Instant expiresAt) { + return this.save(KitCooldownTable.class, new KitCooldownTable(playerUniqueId, kitName, expiresAt)) + .thenApply(status -> null); + } + + @Override + public CompletableFuture delete(UUID playerUniqueId, String kitName) { + return this.deleteById(KitCooldownTable.class, KitCooldownTable.createId(playerUniqueId, kitName)) + .thenApply(deleted -> null); + } + + @Override + public CompletableFuture deleteByKit(String kitName) { + return this.action(KitCooldownTable.class, dao -> { + DeleteBuilder builder = dao.deleteBuilder(); + builder.where().eq(KitCooldownTable.KIT_COLUMN, kitName); + return builder.delete(); + }).thenApply(deleted -> null); + } + + @Override + public CompletableFuture deleteExpired(Instant now) { + return this.action(KitCooldownTable.class, dao -> { + DeleteBuilder builder = dao.deleteBuilder(); + builder.where().le(KitCooldownTable.EXPIRES_AT_COLUMN, now.toEpochMilli()); + return builder.delete(); + }).thenApply(deleted -> null); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownServiceImpl.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownServiceImpl.java new file mode 100644 index 000000000..09cf8c00f --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownServiceImpl.java @@ -0,0 +1,58 @@ +package com.eternalcode.core.feature.kit.cooldown; + +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitCooldownService; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +class KitCooldownServiceImpl implements KitCooldownService { + + private final KitCooldownStore store; + + @Inject + KitCooldownServiceImpl(KitCooldownStore store) { + this.store = store; + } + + @Override + public Optional getRemainingCooldown(UUID playerUniqueId, Kit kit) { + return this.store.findExpiration(playerUniqueId, kit.name()) + .map(expiresAt -> Duration.between(Instant.now(), expiresAt)) + .filter(KitCooldownServiceImpl::isActive); + } + + @Override + public Map getRemainingCooldowns(UUID playerUniqueId) { + Instant now = Instant.now(); + + return this.store.findExpirations(playerUniqueId).entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), Duration.between(now, entry.getValue()))) + .filter(entry -> isActive(entry.getValue())) + .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + @Override + public void applyCooldown(UUID playerUniqueId, Kit kit) { + if (!kit.hasCooldown()) { + return; + } + + this.store.save(playerUniqueId, kit.name(), Instant.now().plus(kit.cooldown())); + } + + @Override + public void resetCooldown(UUID playerUniqueId, Kit kit) { + this.store.delete(playerUniqueId, kit.name()); + } + + private static boolean isActive(Duration remaining) { + return !remaining.isNegative() && !remaining.isZero(); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownStore.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownStore.java new file mode 100644 index 000000000..841281682 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownStore.java @@ -0,0 +1,111 @@ +package com.eternalcode.core.feature.kit.cooldown; + +import com.eternalcode.commons.concurrent.FutureHandler; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import org.bukkit.Server; + +/** + * Internal cooldown storage: in-memory cache of online players + write-through to the database. + * The only class allowed to talk to {@link KitCooldownRepository}. + */ +@Service +public class KitCooldownStore { + + private final Map> cache = new ConcurrentHashMap<>(); + + private final KitCooldownRepository repository; + private final Server server; + + @Inject + KitCooldownStore(KitCooldownRepository repository, Server server) { + this.repository = repository; + this.server = server; + + this.repository.deleteExpired(Instant.now()) + .exceptionally(FutureHandler::handleException); + } + + public boolean isLoaded(UUID playerUniqueId) { + return this.cache.containsKey(playerUniqueId); + } + + Optional findExpiration(UUID playerUniqueId, String kitName) { + Map expirations = this.cache.get(playerUniqueId); + + if (expirations == null) { + return Optional.empty(); + } + + return Optional.ofNullable(expirations.get(kitName)); + } + + Map findExpirations(UUID playerUniqueId) { + Map expirations = this.cache.get(playerUniqueId); + + if (expirations == null) { + return Map.of(); + } + + return Map.copyOf(expirations); + } + + void save(UUID playerUniqueId, String kitName, Instant expiresAt) { + Map expirations = this.cache.get(playerUniqueId); + + if (expirations != null) { + expirations.put(kitName, expiresAt); + } + + this.repository.save(playerUniqueId, kitName, expiresAt) + .exceptionally(FutureHandler::handleException); + } + + void delete(UUID playerUniqueId, String kitName) { + Map expirations = this.cache.get(playerUniqueId); + + if (expirations != null) { + expirations.remove(kitName); + } + + this.repository.delete(playerUniqueId, kitName) + .exceptionally(FutureHandler::handleException); + } + + public void deleteKit(String kitName) { + this.cache.values().forEach(expirations -> expirations.remove(kitName)); + + this.repository.deleteByKit(kitName) + .exceptionally(FutureHandler::handleException); + } + + CompletableFuture load(UUID playerUniqueId) { + return this.repository.findExpirations(playerUniqueId) + .thenAccept(expirations -> { + if (this.server.getPlayer(playerUniqueId) == null) { + return; + } + + Instant now = Instant.now(); + Map active = new ConcurrentHashMap<>(); + + expirations.forEach((kitName, expiresAt) -> { + if (expiresAt.isAfter(now)) { + active.put(kitName, expiresAt); + } + }); + + this.cache.put(playerUniqueId, active); + }); + } + + void unload(UUID playerUniqueId) { + this.cache.remove(playerUniqueId); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownTable.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownTable.java new file mode 100644 index 000000000..bbd199855 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/cooldown/KitCooldownTable.java @@ -0,0 +1,53 @@ +package com.eternalcode.core.feature.kit.cooldown; + +import com.j256.ormlite.field.DatabaseField; +import com.j256.ormlite.table.DatabaseTable; +import java.time.Instant; +import java.util.UUID; + +/** + * ORMLite has no composite keys, so the id is "playerUuid:kitName". + */ +@DatabaseTable(tableName = "eternal_core_kit_cooldowns") +class KitCooldownTable { + + static final String PLAYER_COLUMN = "player_id"; + static final String KIT_COLUMN = "kit_name"; + static final String EXPIRES_AT_COLUMN = "expires_at"; + + private static final String ID_SEPARATOR = ":"; + + @DatabaseField(columnName = "id", id = true) + private String id; + + @DatabaseField(columnName = PLAYER_COLUMN, index = true) + private UUID playerUniqueId; + + @DatabaseField(columnName = KIT_COLUMN, index = true) + private String kitName; + + @DatabaseField(columnName = EXPIRES_AT_COLUMN) + private long expiresAtMillis; + + KitCooldownTable() { + } + + KitCooldownTable(UUID playerUniqueId, String kitName, Instant expiresAt) { + this.id = createId(playerUniqueId, kitName); + this.playerUniqueId = playerUniqueId; + this.kitName = kitName; + this.expiresAtMillis = expiresAt.toEpochMilli(); + } + + static String createId(UUID playerUniqueId, String kitName) { + return playerUniqueId + ID_SEPARATOR + kitName; + } + + String getKitName() { + return this.kitName; + } + + Instant getExpiresAt() { + return Instant.ofEpochMilli(this.expiresAtMillis); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitGuiLayout.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitGuiLayout.java new file mode 100644 index 000000000..95faff0fe --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitGuiLayout.java @@ -0,0 +1,28 @@ +package com.eternalcode.core.feature.kit.gui; + +public final class KitGuiLayout { + + public static final int ROW_SIZE = 9; + public static final int MIN_ROWS = 1; + public static final int MAX_ROWS = 6; + + private KitGuiLayout() { + } + + public static int clampRows(int rows) { + return Math.max(MIN_ROWS, Math.min(MAX_ROWS, rows)); + } + + public static int slotCount(int rows) { + return clampRows(rows) * ROW_SIZE; + } + + public static int rowsFor(int itemCount) { + return clampRows((itemCount + ROW_SIZE - 1) / ROW_SIZE); + } + + public static int lastRowSlot(int rows, int column) { + int safeColumn = Math.max(0, Math.min(ROW_SIZE - 1, column)); + return (clampRows(rows) - 1) * ROW_SIZE + safeColumn; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitInventory.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitInventory.java new file mode 100644 index 000000000..24293815c --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitInventory.java @@ -0,0 +1,135 @@ +package com.eternalcode.core.feature.kit.gui; + +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitClaimNotifier; +import com.eternalcode.core.feature.kit.KitConfig; +import com.eternalcode.core.feature.kit.KitCooldownService; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitService; +import com.eternalcode.core.feature.kit.KitSettings; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import com.eternalcode.core.util.DurationUtil; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.inventory.ItemStack; + +@Service +public class KitInventory { + + private static final String KIT_COOLDOWN_PLACEHOLDER = "{KIT_COOLDOWN}"; + + private final KitService kitService; + private final KitCooldownService cooldownService; + private final KitClaimNotifier claimNotifier; + private final KitPreviewInventory previewInventory; + private final KitItemRenderer itemRenderer; + private final KitSettings settings; + private final KitNotices notices; + + @Inject + KitInventory( + KitService kitService, + KitCooldownService cooldownService, + KitClaimNotifier claimNotifier, + KitPreviewInventory previewInventory, + KitItemRenderer itemRenderer, + KitSettings settings, + KitNotices notices + ) { + this.kitService = kitService; + this.cooldownService = cooldownService; + this.claimNotifier = claimNotifier; + this.previewInventory = previewInventory; + this.itemRenderer = itemRenderer; + this.settings = settings; + this.notices = notices; + } + + public void open(Player player) { + List visibleKits = this.kitService.getKits().stream() + .filter(kit -> !this.settings.hideKitsWithoutPermission() || player.hasPermission(kit.permission())) + .toList(); + + if (visibleKits.isEmpty()) { + this.notices.send(player, KitMessages::noKits); + return; + } + + Map cooldowns = this.cooldownService.getRemainingCooldowns(player.getUniqueId()); + this.create(player, visibleKits, cooldowns).open(player); + } + + private Gui create(Player player, List kits, Map cooldowns) { + KitConfig.GuiSection gui = this.settings.gui(); + int rows = KitGuiLayout.clampRows(gui.rows()); + int slotCount = KitGuiLayout.slotCount(rows); + + Gui inventory = Gui.gui() + .title(this.itemRenderer.deserialize(gui.title())) + .rows(rows) + .disableAllInteractions() + .create(); + + for (Kit kit : kits) { + if (kit.slot() >= slotCount) { + continue; + } + + GuiItem guiItem = new GuiItem( + this.renderIcon(player, kit, cooldowns.get(kit.name())), + event -> this.handleClick(player, kit, event.getClick()) + ); + + inventory.setItem(kit.slot(), guiItem); + } + + if (gui.fillEmptySlots()) { + inventory.getFiller().fill(new GuiItem(this.itemRenderer.renderFiller(gui.fillerMaterial()))); + } + + return inventory; + } + + private ItemStack renderIcon(Player player, Kit kit, Duration remainingCooldown) { + KitConfig.GuiSection gui = this.settings.gui(); + Map placeholders = Map.of( + KitNotices.COOLDOWN, remainingCooldown == null ? "" : DurationUtil.format(remainingCooldown, true), + KIT_COOLDOWN_PLACEHOLDER, DurationUtil.format(kit.cooldown(), true) + ); + + List statusLore; + + if (!player.hasPermission(kit.permission())) { + statusLore = gui.noPermissionLore(); + } + else if (remainingCooldown != null) { + statusLore = gui.cooldownLore(); + } + else { + statusLore = gui.availableLore(); + } + + return this.itemRenderer.renderKitIcon(kit, statusLore, placeholders); + } + + private void handleClick(Player player, Kit kit, ClickType clickType) { + KitConfig.GuiSection gui = this.settings.gui(); + + if (clickType == gui.claimClick()) { + player.closeInventory(); + this.claimNotifier.claimAndNotify(player, kit); + return; + } + + if (clickType == gui.previewClick()) { + this.previewInventory.open(player, kit, () -> this.open(player)); + } + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemRenderer.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemRenderer.java new file mode 100644 index 000000000..cf71f0f37 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemRenderer.java @@ -0,0 +1,93 @@ +package com.eternalcode.core.feature.kit.gui; + +import com.eternalcode.commons.adventure.AdventureUtil; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitConfig; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import dev.triumphteam.gui.builder.item.ItemBuilder; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Material; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +/** + * Builds display items. The only kit GUI class that knows how items are decorated. + */ +@Service +class KitItemRenderer { + + private static final Component EMPTY_NAME = Component.text(" "); + + private final MiniMessage miniMessage; + + @Inject + KitItemRenderer(MiniMessage miniMessage) { + this.miniMessage = miniMessage; + } + + ItemStack renderKitIcon(Kit kit, List statusLore, Map placeholders) { + ItemStack icon = kit.icon(); + List lore = new ArrayList<>(this.readLegacyLore(icon)); + + statusLore.stream() + .map(line -> this.deserialize(this.applyPlaceholders(line, placeholders))) + .forEach(lore::add); + + return ItemBuilder.from(icon) + .name(this.deserialize(kit.displayName())) + .lore(lore) + .flags(ItemFlag.values()) + .build(); + } + + ItemStack renderButton(KitConfig.Button button) { + return ItemBuilder.from(button.material()) + .name(this.deserialize(button.name())) + .lore(button.lore().stream().map(this::deserialize).toList()) + .flags(ItemFlag.values()) + .build(); + } + + ItemStack renderFiller(Material material) { + return ItemBuilder.from(material) + .name(EMPTY_NAME) + .flags(ItemFlag.values()) + .build(); + } + + Component deserialize(String text) { + return AdventureUtil.resetItalic(this.miniMessage.deserialize(text)); + } + + String deserializeLegacy(String text) { + return AdventureUtil.SECTION_SERIALIZER.serialize(this.miniMessage.deserialize(text)); + } + + private List readLegacyLore(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + + if (meta == null || !meta.hasLore() || meta.getLore() == null) { + return List.of(); + } + + return meta.getLore().stream() + .map(line -> (Component) AdventureUtil.SECTION_SERIALIZER.deserialize(line)) + .toList(); + } + + private String applyPlaceholders(String text, Map placeholders) { + String result = text; + + for (Map.Entry placeholder : placeholders.entrySet()) { + result = result.replace(placeholder.getKey(), placeholder.getValue()); + } + + return result; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditor.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditor.java new file mode 100644 index 000000000..192c82a19 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditor.java @@ -0,0 +1,69 @@ +package com.eternalcode.core.feature.kit.gui; + +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitService; +import com.eternalcode.core.feature.kit.KitSettings; +import com.eternalcode.core.feature.kit.messages.KitMessages; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +@Service +public class KitItemsEditor { + + private final KitService kitService; + private final KitItemRenderer itemRenderer; + private final KitSettings settings; + private final KitNotices notices; + private final Server server; + + @Inject + KitItemsEditor(KitService kitService, KitItemRenderer itemRenderer, KitSettings settings, KitNotices notices, Server server) { + this.kitService = kitService; + this.itemRenderer = itemRenderer; + this.settings = settings; + this.notices = notices; + this.server = server; + } + + public void open(Player player, Kit kit) { + List items = kit.items(); + int rows = Math.max(KitGuiLayout.clampRows(this.settings.gui().editorRows()), KitGuiLayout.rowsFor(items.size())); + String title = this.itemRenderer.deserializeLegacy(this.settings.gui().editorTitle().replace(KitNotices.KIT, kit.name())); + + KitItemsEditorHolder holder = new KitItemsEditorHolder(kit.name()); + Inventory inventory = this.server.createInventory(holder, KitGuiLayout.slotCount(rows), title); + holder.attach(inventory); + + inventory.setContents(items.toArray(new ItemStack[0])); + player.openInventory(inventory); + + this.notices.send(player, KitMessages::itemsEditorOpened, Map.of(KitNotices.KIT, kit.name())); + } + + void save(Player player, KitItemsEditorHolder holder) { + this.kitService.findKit(holder.getKitName()).ifPresent(kit -> { + List items = Arrays.stream(holder.getInventory().getContents()) + .filter(Objects::nonNull) + .filter(item -> !item.getType().isAir()) + .toList(); + + Kit updated = kit.toBuilder().items(items).build(); + + this.kitService.saveKit(updated); + + this.notices.send(player, KitMessages::itemsSaved, Map.of( + KitNotices.KIT, kit.name(), + KitNotices.AMOUNT, String.valueOf(items.size()) + )); + }); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditorController.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditorController.java new file mode 100644 index 000000000..64d107837 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditorController.java @@ -0,0 +1,32 @@ +package com.eternalcode.core.feature.kit.gui; + +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Controller; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryCloseEvent; + +@Controller +class KitItemsEditorController implements Listener { + + private final KitItemsEditor itemsEditor; + + @Inject + KitItemsEditorController(KitItemsEditor itemsEditor) { + this.itemsEditor = itemsEditor; + } + + @EventHandler + void onClose(InventoryCloseEvent event) { + if (!(event.getInventory().getHolder() instanceof KitItemsEditorHolder holder)) { + return; + } + + if (!(event.getPlayer() instanceof Player player)) { + return; + } + + this.itemsEditor.save(player, holder); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditorHolder.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditorHolder.java new file mode 100644 index 000000000..b542b67cf --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitItemsEditorHolder.java @@ -0,0 +1,36 @@ +package com.eternalcode.core.feature.kit.gui; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.NotNull; + +/** + * Marks an inventory as a kit items editor. Plain Bukkit inventory on purpose - GUI libraries + * tag their items (e.g. Triumph's "mf-gui" PDC key), which would end up saved inside the kit. + */ +final class KitItemsEditorHolder implements InventoryHolder { + + private final String kitName; + private Inventory inventory; + + KitItemsEditorHolder(String kitName) { + this.kitName = kitName; + } + + String getKitName() { + return this.kitName; + } + + void attach(Inventory inventory) { + if (this.inventory != null) { + throw new IllegalStateException("Inventory already attached to editor of kit " + this.kitName); + } + + this.inventory = inventory; + } + + @Override + public @NotNull Inventory getInventory() { + return this.inventory; + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitPreviewInventory.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitPreviewInventory.java new file mode 100644 index 000000000..3186f8b44 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/gui/KitPreviewInventory.java @@ -0,0 +1,68 @@ +package com.eternalcode.core.feature.kit.gui; + +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitClaimNotifier; +import com.eternalcode.core.feature.kit.KitConfig; +import com.eternalcode.core.feature.kit.KitNotices; +import com.eternalcode.core.feature.kit.KitSettings; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Service; +import dev.triumphteam.gui.guis.Gui; +import dev.triumphteam.gui.guis.GuiItem; +import java.util.List; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +@Service +public class KitPreviewInventory { + + private static final int BUTTON_ROWS = 1; + + private final KitClaimNotifier claimNotifier; + private final KitItemRenderer itemRenderer; + private final KitSettings settings; + + @Inject + KitPreviewInventory(KitClaimNotifier claimNotifier, KitItemRenderer itemRenderer, KitSettings settings) { + this.claimNotifier = claimNotifier; + this.itemRenderer = itemRenderer; + this.settings = settings; + } + + /** + * @param backAction executed by the back button, e.g. reopening the kit list. + */ + public void open(Player player, Kit kit, Runnable backAction) { + KitConfig.GuiSection gui = this.settings.gui(); + List items = kit.items(); + int rows = KitGuiLayout.clampRows(Math.min(KitGuiLayout.rowsFor(items.size()), KitGuiLayout.MAX_ROWS - BUTTON_ROWS) + BUTTON_ROWS); + int itemSlots = (rows - BUTTON_ROWS) * KitGuiLayout.ROW_SIZE; + + Gui inventory = Gui.gui() + .title(this.itemRenderer.deserialize(gui.previewTitle().replace(KitNotices.KIT, kit.displayName()))) + .rows(rows) + .disableAllInteractions() + .create(); + + for (int slot = 0; slot < items.size() && slot < itemSlots; slot++) { + inventory.setItem(slot, new GuiItem(items.get(slot))); + } + + this.placeButton(inventory, rows, gui.claimButton(), () -> { + player.closeInventory(); + this.claimNotifier.claimAndNotify(player, kit); + }); + this.placeButton(inventory, rows, gui.backButton(), backAction); + + inventory.open(player); + } + + private void placeButton(Gui inventory, int rows, KitConfig.Button button, Runnable action) { + if (!button.enabled()) { + return; + } + + GuiItem guiItem = new GuiItem(this.itemRenderer.renderButton(button), event -> action.run()); + inventory.setItem(KitGuiLayout.lastRowSlot(rows, button.column()), guiItem); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/ENKitMessages.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/ENKitMessages.java new file mode 100644 index 000000000..39f8829e0 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/ENKitMessages.java @@ -0,0 +1,42 @@ +package com.eternalcode.core.feature.kit.messages; + +import com.eternalcode.multification.notice.Notice; +import eu.okaeri.configs.OkaeriConfig; +import eu.okaeri.configs.annotation.Comment; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +public class ENKitMessages extends OkaeriConfig implements KitMessages { + + @Comment("# {KIT} - kit name (displayName), {KIT_NAME} - kit name, {COOLDOWN} - remaining time") + Notice notFound = Notice.chat("✘ Kit {KIT_NAME} does not exist!"); + Notice noKits = Notice.chat("✘ There are no kits!"); + Notice noPermission = Notice.chat("✘ You don't have access to kit {KIT_NAME}!"); + Notice onCooldown = Notice.chat("✘ Kit {KIT} will be available in {COOLDOWN}!"); + Notice claimed = Notice.chat("► You claimed kit {KIT}!"); + Notice claimCancelled = Notice.chat("✘ Could not claim kit {KIT}!"); + + @Comment({ " ", "# Kit administration" }) + Notice invalidName = Notice.chat("✘ Invalid kit name {KIT}! Allowed: a-z, 0-9, _ and - (max 32 chars)"); + Notice alreadyExists = Notice.chat("✘ Kit {KIT_NAME} already exists!"); + Notice created = Notice.chat( + "► Created kit {KIT} (permission: {PERMISSION})!", + "Set kit items?'>» /kitadmin items {KIT} to set items! (Click)" + ); + Notice deleted = Notice.chat("► Deleted kit {KIT}!"); + Notice itemsEditorOpened = Notice.chat("► Put the items in and close the inventory to save kit {KIT}."); + Notice itemsSaved = Notice.chat("► Saved {AMOUNT} items in kit {KIT}!"); + Notice permissionChanged = Notice.chat("► Kit {KIT} permission is now {PERMISSION}!"); + Notice cooldownChanged = Notice.chat("► Kit {KIT} cooldown is now {COOLDOWN}!"); + Notice emptyHand = Notice.chat("✘ You must hold an item in your hand!"); + Notice iconChanged = Notice.chat("► Changed icon of kit {KIT}!"); + Notice invalidSlot = Notice.chat("✘ Slot must be in range 0-{MAX}!"); + Notice slotChanged = Notice.chat("► Kit {KIT} is now on slot {SLOT}!"); + Notice displayNameChanged = Notice.chat("► Kit {KIT} display name is now {NAME}!"); + Notice commandAdded = Notice.chat("► Added command /{COMMAND} to kit {KIT}!"); + Notice commandsCleared = Notice.chat("► Cleared commands of kit {KIT}!"); + Notice cooldownReset = Notice.chat("► Reset kit {KIT} cooldown for {PLAYER}!"); + Notice databaseError = Notice.chat("✘ A database error occurred, check the console!"); +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/KitMessages.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/KitMessages.java new file mode 100644 index 000000000..773bc6e19 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/KitMessages.java @@ -0,0 +1,32 @@ +package com.eternalcode.core.feature.kit.messages; + +import com.eternalcode.multification.notice.Notice; + +public interface KitMessages { + + Notice notFound(); + Notice noKits(); + Notice noPermission(); + Notice onCooldown(); + Notice claimed(); + Notice claimCancelled(); + + Notice invalidName(); + Notice alreadyExists(); + Notice created(); + Notice deleted(); + Notice itemsEditorOpened(); + Notice itemsSaved(); + Notice permissionChanged(); + Notice cooldownChanged(); + Notice emptyHand(); + Notice iconChanged(); + Notice invalidSlot(); + Notice slotChanged(); + Notice displayNameChanged(); + Notice commandAdded(); + Notice commandsCleared(); + Notice cooldownReset(); + Notice databaseError(); + +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/PLKitMessages.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/PLKitMessages.java new file mode 100644 index 000000000..fa81f0cb7 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/messages/PLKitMessages.java @@ -0,0 +1,42 @@ +package com.eternalcode.core.feature.kit.messages; + +import com.eternalcode.multification.notice.Notice; +import eu.okaeri.configs.OkaeriConfig; +import eu.okaeri.configs.annotation.Comment; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +public class PLKitMessages extends OkaeriConfig implements KitMessages { + + @Comment("# {KIT} - nazwa kitu (displayName), {KIT_NAME} - nazwa kitu, {COOLDOWN} - pozostały czas") + Notice notFound = Notice.chat("✘ Kit {KIT_NAME} nie istnieje!"); + Notice noKits = Notice.chat("✘ Nie ma żadnych kitów!"); + Notice noPermission = Notice.chat("✘ Nie masz dostępu do kitu {KIT_NAME}!"); + Notice onCooldown = Notice.chat("✘ Kit {KIT} będzie dostępny za {COOLDOWN}!"); + Notice claimed = Notice.chat("► Odebrano kit {KIT}!"); + Notice claimCancelled = Notice.chat("✘ Nie udało się odebrać kitu {KIT}!"); + + @Comment({ " ", "# Administracja kitami" }) + Notice invalidName = Notice.chat("✘ Niepoprawna nazwa kitu {KIT}! Dozwolone: a-z, 0-9, _ i - (max 32 znaki)"); + Notice alreadyExists = Notice.chat("✘ Kit {KIT_NAME} już istnieje!"); + Notice created = Notice.chat( + "► Stworzono kit {KIT} (uprawnienie: {PERMISSION})!", + "Ustawić przedmioty kitu?'>» /kitadmin items {KIT} by ustawić przedmioty! (Kliknij)" + ); + Notice deleted = Notice.chat("► Usunięto kit {KIT}!"); + Notice itemsEditorOpened = Notice.chat("► Włóż przedmioty i zamknij ekwipunek, aby zapisać kit {KIT}."); + Notice itemsSaved = Notice.chat("► Zapisano {AMOUNT} przedmiotów w kicie {KIT}!"); + Notice permissionChanged = Notice.chat("► Uprawnienie kitu {KIT} to teraz {PERMISSION}!"); + Notice cooldownChanged = Notice.chat("► Cooldown kitu {KIT} to teraz {COOLDOWN}!"); + Notice emptyHand = Notice.chat("✘ Musisz trzymać przedmiot w ręce!"); + Notice iconChanged = Notice.chat("► Zmieniono ikonę kitu {KIT}!"); + Notice invalidSlot = Notice.chat("✘ Slot musi być z zakresu 0-{MAX}!"); + Notice slotChanged = Notice.chat("► Kit {KIT} jest teraz na slocie {SLOT}!"); + Notice displayNameChanged = Notice.chat("► Nazwa wyświetlana kitu {KIT} to teraz {NAME}!"); + Notice commandAdded = Notice.chat("► Dodano komendę /{COMMAND} do kitu {KIT}!"); + Notice commandsCleared = Notice.chat("► Wyczyszczono komendy kitu {KIT}!"); + Notice cooldownReset = Notice.chat("► Zresetowano cooldown kitu {KIT} dla {PLAYER}!"); + Notice databaseError = Notice.chat("✘ Wystąpił błąd bazy danych, sprawdź konsolę!"); +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitFileSchema.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitFileSchema.java new file mode 100644 index 000000000..fe0c29d17 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitFileSchema.java @@ -0,0 +1,98 @@ +package com.eternalcode.core.feature.kit.repository; + +import com.eternalcode.commons.time.DurationParser; +import com.eternalcode.commons.time.TemporalAmountParser; +import com.eternalcode.core.feature.kit.Kit; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.List; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; + +/** + * Maps a {@link Kit} to/from a kit yml file. Items use Bukkit's native ItemStack serialization, + * so kit files stay human readable and can be edited by hand. + */ +final class KitFileSchema { + + static final String FILE_EXTENSION = ".yml"; + + private static final String DISPLAY_NAME = "display-name"; + private static final String COOLDOWN = "cooldown"; + private static final String PERMISSION = "permission"; + private static final String SLOT = "slot"; + private static final String ICON = "icon"; + private static final String ITEMS = "items"; + private static final String COMMANDS = "commands"; + + private static final List HEADER = List.of( + "Kit definition - file name is the kit name.", + "cooldown: e.g. 30m, 1h30m, 1d (0s = no cooldown)", + "commands: executed by console, {PLAYER} = player name", + "Reload with /eternalcore reload" + ); + + private static final String NO_COOLDOWN = "0s"; + + private static final TemporalAmountParser DURATION_PARSER = new DurationParser() + .withUnit("s", ChronoUnit.SECONDS) + .withUnit("m", ChronoUnit.MINUTES) + .withUnit("h", ChronoUnit.HOURS) + .withUnit("d", ChronoUnit.DAYS); + + private KitFileSchema() { + } + + static YamlConfiguration write(Kit kit) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.options().setHeader(HEADER); + + yaml.set(DISPLAY_NAME, kit.displayName()); + yaml.set(COOLDOWN, formatCooldown(kit.cooldown())); + yaml.set(PERMISSION, kit.permission()); + yaml.set(SLOT, kit.slot()); + yaml.set(ICON, kit.icon()); + yaml.set(ITEMS, kit.items()); + yaml.set(COMMANDS, kit.commands()); + + return yaml; + } + + static Kit read(String kitName, YamlConfiguration yaml) { + String permission = yaml.getString(PERMISSION); + String cooldown = yaml.getString(COOLDOWN); + ItemStack icon = yaml.getItemStack(ICON); + + if (permission == null || cooldown == null || icon == null) { + throw new IllegalStateException("Kit '" + kitName + "' is missing one of required keys: " + + PERMISSION + ", " + COOLDOWN + ", " + ICON); + } + + return Kit.builder(kitName) + .displayName(yaml.getString(DISPLAY_NAME, kitName)) + .cooldown(parseCooldown(cooldown)) + .permission(permission) + .slot(yaml.getInt(SLOT, Kit.MIN_SLOT)) + .icon(icon) + .items(readItems(yaml)) + .commands(yaml.getStringList(COMMANDS)) + .build(); + } + + private static String formatCooldown(Duration cooldown) { + return cooldown.isZero() ? NO_COOLDOWN : DURATION_PARSER.format(cooldown); + } + + private static Duration parseCooldown(String cooldown) { + return NO_COOLDOWN.equals(cooldown.trim()) ? Duration.ZERO : DURATION_PARSER.parse(cooldown.trim()); + } + + private static List readItems(YamlConfiguration yaml) { + List rawItems = yaml.getList(ITEMS, List.of()); + + return rawItems.stream() + .filter(ItemStack.class::isInstance) + .map(ItemStack.class::cast) + .toList(); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitRepository.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitRepository.java new file mode 100644 index 000000000..8815fe512 --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitRepository.java @@ -0,0 +1,15 @@ +package com.eternalcode.core.feature.kit.repository; + +import com.eternalcode.core.feature.kit.Kit; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface KitRepository { + + CompletableFuture> findAll(); + + CompletableFuture save(Kit kit); + + CompletableFuture delete(String kitName); + +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitRepositoryYaml.java b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitRepositoryYaml.java new file mode 100644 index 000000000..363c9f5fd --- /dev/null +++ b/eternalcore-core/src/main/java/com/eternalcode/core/feature/kit/repository/KitRepositoryYaml.java @@ -0,0 +1,143 @@ +package com.eternalcode.core.feature.kit.repository; + +import com.eternalcode.commons.scheduler.Scheduler; +import com.eternalcode.core.feature.kit.Kit; +import com.eternalcode.core.feature.kit.KitSettings; +import com.eternalcode.core.injector.annotations.Inject; +import com.eternalcode.core.injector.annotations.component.Repository; +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.Material; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.inventory.ItemStack; + +/** + * One file per kit: {@code plugins/EternalCore/kits/.yml}. + * Kit names are validated by {@link Kit#isValidName(String)}, so they are always safe file names. + */ +@Repository +class KitRepositoryYaml implements KitRepository { + + private static final String KITS_DIRECTORY = "kits"; + private static final String EXAMPLE_KIT_NAME = "starter"; + private static final Duration EXAMPLE_KIT_COOLDOWN = Duration.ofHours(1); + + private final Object fileLock = new Object(); + + private final File kitsDirectory; + private final KitSettings settings; + private final Scheduler scheduler; + private final Logger logger; + + @Inject + KitRepositoryYaml(File dataFolder, KitSettings settings, Scheduler scheduler, Logger logger) { + this.kitsDirectory = new File(dataFolder, KITS_DIRECTORY); + this.settings = settings; + this.scheduler = scheduler; + this.logger = logger; + } + + @Override + public CompletableFuture> findAll() { + return this.scheduler.completeAsync(() -> { + synchronized (this.fileLock) { + if (!this.kitsDirectory.exists()) { + this.createDirectoryWithExample(); + } + + return this.readKits(); + } + }); + } + + @Override + public CompletableFuture save(Kit kit) { + return this.scheduler.completeAsync(() -> { + synchronized (this.fileLock) { + this.writeKit(kit); + return null; + } + }); + } + + @Override + public CompletableFuture delete(String kitName) { + return this.scheduler.completeAsync(() -> { + synchronized (this.fileLock) { + try { + Files.deleteIfExists(this.kitFile(kitName).toPath()); + return null; + } + catch (IOException exception) { + throw new UncheckedIOException("Failed to delete kit file of " + kitName, exception); + } + } + }); + } + + private List readKits() { + File[] files = this.kitsDirectory.listFiles((directory, fileName) -> fileName.endsWith(KitFileSchema.FILE_EXTENSION)); + + if (files == null) { + return List.of(); + } + + List kits = new ArrayList<>(files.length); + + for (File file : files) { + String kitName = file.getName().substring(0, file.getName().length() - KitFileSchema.FILE_EXTENSION.length()); + + if (!Kit.isValidName(kitName)) { + this.logger.warning("Skipping kit file with invalid name: " + file.getName()); + continue; + } + + try { + kits.add(KitFileSchema.read(kitName, YamlConfiguration.loadConfiguration(file))); + } + catch (RuntimeException exception) { + this.logger.log(Level.SEVERE, "Failed to load kit file " + file.getName(), exception); + } + } + + return kits; + } + + private void writeKit(Kit kit) { + try { + Files.createDirectories(this.kitsDirectory.toPath()); + KitFileSchema.write(kit).save(this.kitFile(kit.name())); + } + catch (IOException exception) { + throw new UncheckedIOException("Failed to save kit file of " + kit.name(), exception); + } + } + + private void createDirectoryWithExample() { + Kit example = Kit.builder(EXAMPLE_KIT_NAME) + .displayName("Starter") + .cooldown(EXAMPLE_KIT_COOLDOWN) + .permission(this.settings.defaultPermissionPrefix() + EXAMPLE_KIT_NAME) + .icon(new ItemStack(Material.STONE_SWORD)) + .items(List.of( + new ItemStack(Material.STONE_SWORD), + new ItemStack(Material.STONE_PICKAXE), + new ItemStack(Material.COOKED_BEEF, 16) + )) + .build(); + + this.writeKit(example); + } + + private File kitFile(String kitName) { + return new File(this.kitsDirectory, kitName + KitFileSchema.FILE_EXTENSION); + } +} diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/translation/Translation.java b/eternalcore-core/src/main/java/com/eternalcode/core/translation/Translation.java index f5357fda1..7a03b9678 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/translation/Translation.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/translation/Translation.java @@ -34,6 +34,7 @@ import com.eternalcode.core.feature.ignore.messages.IgnoreMessages; import com.eternalcode.core.feature.itemedit.messages.ItemEditMessages; import com.eternalcode.core.feature.jail.messages.JailMessages; +import com.eternalcode.core.feature.kit.messages.KitMessages; import com.eternalcode.core.feature.joinmessage.messages.JoinMessage; import com.eternalcode.core.feature.kill.messages.KillMessages; import com.eternalcode.core.feature.motd.messages.MotdMessages; @@ -113,6 +114,8 @@ interface Format { WarpMessages warp(); + KitMessages kit(); + SpawnMessages spawn(); NearMessages near(); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/ENTranslation.java b/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/ENTranslation.java index c433159cd..2b3a20a5c 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/ENTranslation.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/ENTranslation.java @@ -29,6 +29,7 @@ import com.eternalcode.core.feature.ignore.messages.ENIgnoreMessages; import com.eternalcode.core.feature.itemedit.messages.ENItemEditMessages; import com.eternalcode.core.feature.jail.messages.ENJailMessages; +import com.eternalcode.core.feature.kit.messages.ENKitMessages; import com.eternalcode.core.feature.joinmessage.messages.ENJoinMessage; import com.eternalcode.core.feature.kill.messages.ENKillMessages; import com.eternalcode.core.feature.lag.messages.ENLagMessages; @@ -211,6 +212,9 @@ public static class ENFormatSection extends OkaeriConfig implements Format { @Comment("# Warp") public ENWarpMessages warp = new ENWarpMessages(); + @Comment("# Kits") + public ENKitMessages kit = new ENKitMessages(); + @Comment("# Spawn") public ENSpawnMessages spawn = new ENSpawnMessages(); diff --git a/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/PLTranslation.java b/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/PLTranslation.java index 17773c22d..508d62b98 100644 --- a/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/PLTranslation.java +++ b/eternalcore-core/src/main/java/com/eternalcode/core/translation/implementation/PLTranslation.java @@ -35,6 +35,7 @@ import com.eternalcode.core.feature.ignore.messages.PLIgnoreMessages; import com.eternalcode.core.feature.itemedit.messages.PLItemEditMessages; import com.eternalcode.core.feature.jail.messages.PLJailMessages; +import com.eternalcode.core.feature.kit.messages.PLKitMessages; import com.eternalcode.core.feature.joinmessage.messages.PLJoinMessage; import com.eternalcode.core.feature.kill.messages.PLKillMessages; import com.eternalcode.core.feature.motd.messages.PLMotdMessages; @@ -207,6 +208,9 @@ public static class PLFormatSection extends OkaeriConfig implements Format { @Comment("# Punkty teleportacji (warp)") public PLWarpMessages warp = new PLWarpMessages(); + @Comment("# Kity") + public PLKitMessages kit = new PLKitMessages(); + @Comment("# Spawn") public PLSpawnMessages spawn = new PLSpawnMessages();