diff --git a/src/main/java/dev/deepcore/DeepCorePlugin.java b/src/main/java/dev/deepcore/DeepCorePlugin.java index c380500..70809f7 100644 --- a/src/main/java/dev/deepcore/DeepCorePlugin.java +++ b/src/main/java/dev/deepcore/DeepCorePlugin.java @@ -26,7 +26,7 @@ public void onEnable() { return; } - deepCoreLogger.info("DeepCore enabled."); + deepCoreLogger.info("DeepCore loaded!"); } @Override @@ -35,6 +35,13 @@ public void onDisable() { challengeRuntime.getChallengeManager().saveToConfig(); } if (challengeRuntime != null) { + challengeRuntime.getTrainingManager().shutdown(); + } + if (challengeRuntime != null) { + if (challengeRuntime.getChallengeSessionManager().isRunningPhase() + || challengeRuntime.getChallengeSessionManager().isPausedPhase()) { + challengeRuntime.getChallengeSessionManager().endChallengeAndReturnToPrep(); + } challengeRuntime.getChallengeSessionManager().shutdown(); } if (challengeRuntime != null) { diff --git a/src/main/java/dev/deepcore/challenge/ChallengeAdminFacade.java b/src/main/java/dev/deepcore/challenge/ChallengeAdminFacade.java index 4c1f375..97e8e61 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeAdminFacade.java +++ b/src/main/java/dev/deepcore/challenge/ChallengeAdminFacade.java @@ -1,9 +1,11 @@ package dev.deepcore.challenge; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; import java.util.Map; +import org.bukkit.World; import org.bukkit.command.CommandSender; /** @@ -15,6 +17,7 @@ public final class ChallengeAdminFacade { private final ChallengeManager challengeManager; private final ChallengeSessionManager challengeSessionManager; private final WorldResetManager worldResetManager; + private final TrainingManager trainingManager; private final DeepCoreLogger logger; /** @@ -25,6 +28,7 @@ public final class ChallengeAdminFacade { * @param challengeManager challenge configuration manager * @param challengeSessionManager session lifecycle coordinator * @param worldResetManager world reset orchestration service + * @param trainingManager training gym orchestration service * @param logger logger used for command and config operations */ public ChallengeAdminFacade( @@ -32,11 +36,13 @@ public ChallengeAdminFacade( ChallengeManager challengeManager, ChallengeSessionManager challengeSessionManager, WorldResetManager worldResetManager, + TrainingManager trainingManager, DeepCoreLogger logger) { this.plugin = plugin; this.challengeManager = challengeManager; this.challengeSessionManager = challengeSessionManager; this.worldResetManager = worldResetManager; + this.trainingManager = trainingManager; this.logger = logger; } @@ -171,6 +177,26 @@ public void resetWorlds(CommandSender sender) { worldResetManager.resetThreeWorlds(sender); } + /** + * Selects the active lobby world by key. + * + * @param selector one of limbo, overworld, or nether + * @return selected lobby world name, or null when selection failed + */ + public String selectLobbyWorld(String selector) { + World selected = worldResetManager.selectLobbyWorld(selector); + return selected == null ? null : selected.getName(); + } + + /** + * Teleports online players to the currently selected active lobby world. + * + * @return count of players teleported + */ + public int teleportOnlinePlayersToActiveLobby() { + return worldResetManager.teleportOnlinePlayersToActiveLobby(); + } + /** * Reloads config and applies settings immediately when in prep phase. * @@ -179,6 +205,7 @@ public void resetWorlds(CommandSender sender) { public boolean reloadConfigAndApply() { plugin.reloadConfig(); logger.loadFromConfig(); + trainingManager.reloadFromConfig(); if (!challengeSessionManager.isPrepPhase()) { return false; } diff --git a/src/main/java/dev/deepcore/challenge/ChallengeCommand.java b/src/main/java/dev/deepcore/challenge/ChallengeCommand.java index ee2b33a..f9654f2 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeCommand.java +++ b/src/main/java/dev/deepcore/challenge/ChallengeCommand.java @@ -1,6 +1,7 @@ package dev.deepcore.challenge; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import java.util.List; import org.bukkit.command.Command; @@ -22,15 +23,23 @@ public final class ChallengeCommand implements CommandExecutor, TabCompleter { * @param challengeManager challenge settings and component manager * @param challengeSessionManager challenge session orchestration manager * @param worldResetManager world reset and world lifecycle manager + * @param trainingManager training gym manager */ public ChallengeCommand( DeepCorePlugin plugin, ChallengeManager challengeManager, ChallengeSessionManager challengeSessionManager, - WorldResetManager worldResetManager) { + WorldResetManager worldResetManager, + TrainingManager trainingManager) { ChallengeAdminFacade adminFacade = new ChallengeAdminFacade( - plugin, challengeManager, challengeSessionManager, worldResetManager, plugin.getDeepCoreLogger()); - this.coreCommandHandler = new ChallengeCoreCommandHandler(adminFacade, plugin.getDeepCoreLogger()); + plugin, + challengeManager, + challengeSessionManager, + worldResetManager, + trainingManager, + plugin.getDeepCoreLogger()); + this.coreCommandHandler = + new ChallengeCoreCommandHandler(adminFacade, trainingManager, plugin.getDeepCoreLogger()); this.logsCommandHandler = new ChallengeLogsCommandHandler(plugin.getDeepCoreLogger()); } diff --git a/src/main/java/dev/deepcore/challenge/ChallengeCoreCommandHandler.java b/src/main/java/dev/deepcore/challenge/ChallengeCoreCommandHandler.java index 25038ca..d4c32dc 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeCoreCommandHandler.java +++ b/src/main/java/dev/deepcore/challenge/ChallengeCoreCommandHandler.java @@ -1,5 +1,6 @@ package dev.deepcore.challenge; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.logging.DeepCoreLogger; import java.util.ArrayList; import java.util.Arrays; @@ -15,16 +16,20 @@ */ public final class ChallengeCoreCommandHandler { private final ChallengeAdminFacade adminFacade; + private final TrainingManager trainingManager; private final DeepCoreLogger logger; /** * Creates a core command handler for non-logs `/challenge` subcommands. * - * @param adminFacade admin facade providing challenge control operations - * @param logger logger used for command diagnostics + * @param adminFacade admin facade providing challenge control operations + * @param trainingManager training gym manager + * @param logger logger used for command diagnostics */ - public ChallengeCoreCommandHandler(ChallengeAdminFacade adminFacade, DeepCoreLogger logger) { + public ChallengeCoreCommandHandler( + ChallengeAdminFacade adminFacade, TrainingManager trainingManager, DeepCoreLogger logger) { this.adminFacade = adminFacade; + this.trainingManager = trainingManager; this.logger = logger; } @@ -43,6 +48,9 @@ public boolean handle(CommandSender sender, String[] args) { String subcommand = args[0].toLowerCase(Locale.ROOT); switch (subcommand) { + case "train" -> { + return trainingManager.handleCommand(sender, args); + } case "list" -> { sendInfo(sender, ChatColor.GOLD + "Available challenge modes:"); for (ChallengeMode mode : ChallengeMode.values()) { @@ -52,6 +60,9 @@ public boolean handle(CommandSender sender, String[] args) { return true; } case "enable" -> { + if (!requireAdminPermission(sender)) { + return true; + } if (!canEditSettings(sender)) { return true; } @@ -61,6 +72,9 @@ public boolean handle(CommandSender sender, String[] args) { return true; } case "disable" -> { + if (!requireAdminPermission(sender)) { + return true; + } if (!canEditSettings(sender)) { return true; } @@ -70,6 +84,9 @@ public boolean handle(CommandSender sender, String[] args) { return true; } case "mode" -> { + if (!requireAdminPermission(sender)) { + return true; + } if (!canEditSettings(sender)) { return true; } @@ -166,8 +183,34 @@ public boolean handle(CommandSender sender, String[] args) { adminFacade.resetWorlds(sender); return true; } + case "lobby" -> { + if (!requireAdminPermission(sender)) { + return true; + } + + if (args.length < 2) { + sendInfo(sender, ChatColor.RED + "Usage: /challenge lobby "); + return true; + } + + String selector = args[1].toLowerCase(Locale.ROOT); + String selectedWorldName = adminFacade.selectLobbyWorld(selector); + if (selectedWorldName == null) { + sendInfo(sender, ChatColor.RED + "Unknown lobby selector. Use limbo|overworld|nether."); + return true; + } + + int teleported = adminFacade.teleportOnlinePlayersToActiveLobby(); + sendInfo( + sender, ChatColor.GREEN + "Active lobby world set to: " + ChatColor.YELLOW + selectedWorldName); + sendInfo( + sender, + ChatColor.GREEN + "Teleported players to active lobby: " + ChatColor.YELLOW + teleported); + return true; + } case "reload" -> { - if (!sender.hasPermission("deepcore.challenge.reload")) { + if (!sender.hasPermission("deepcore.challenge.reload") + && !sender.hasPermission("deepcore.challenge.admin")) { sendInfo(sender, ChatColor.RED + "You do not have permission to reload config."); return true; } @@ -186,7 +229,7 @@ public boolean handle(CommandSender sender, String[] args) { sendInfo( sender, ChatColor.RED - + "Unknown subcommand. Use /challenge ."); + + "Unknown subcommand. Use /challenge ."); return true; } } @@ -204,6 +247,7 @@ public List tabComplete(String[] args) { args[0], Arrays.asList( "status", + "train", "list", "enable", "disable", @@ -215,10 +259,15 @@ public List tabComplete(String[] args) { "resume", "reset", "resetworld", + "lobby", "reload", "logs")); } + if (args.length >= 2 && args[0].equalsIgnoreCase("train")) { + return trainingManager.tabComplete(args); + } + if (args.length == 2 && args[0].equalsIgnoreCase("mode")) { return filterByPrefix( args[1], @@ -227,6 +276,10 @@ public List tabComplete(String[] args) { .collect(Collectors.toList())); } + if (args.length == 2 && args[0].equalsIgnoreCase("lobby")) { + return filterByPrefix(args[1], Arrays.asList("limbo", "overworld", "nether")); + } + if (args.length == 2 && args[0].equalsIgnoreCase("component")) { List options = new ArrayList<>(); options.add("list"); @@ -271,6 +324,10 @@ private void handleComponentSubcommand(CommandSender sender, String[] args) { return; } + if (!requireAdminPermission(sender)) { + return; + } + if (!canEditSettings(sender)) { return; } @@ -341,6 +398,15 @@ private boolean canEditSettings(CommandSender sender) { return false; } + private boolean requireAdminPermission(CommandSender sender) { + if (sender.hasPermission("deepcore.challenge.admin")) { + return true; + } + + sendInfo(sender, ChatColor.RED + "You do not have permission to manage challenge settings."); + return false; + } + private void sendInfo(CommandSender sender, String message) { logger.sendInfo(sender, message); } diff --git a/src/main/java/dev/deepcore/challenge/ChallengeRuntime.java b/src/main/java/dev/deepcore/challenge/ChallengeRuntime.java index 02d9dd4..32c3e7a 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeRuntime.java +++ b/src/main/java/dev/deepcore/challenge/ChallengeRuntime.java @@ -1,5 +1,6 @@ package dev.deepcore.challenge; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.records.RunRecordsService; @@ -11,6 +12,7 @@ public final class ChallengeRuntime { private final ChallengeSessionManager challengeSessionManager; private final WorldResetManager worldResetManager; private final RunRecordsService runRecordsService; + private final TrainingManager trainingManager; /** * Creates an immutable runtime container for challenge services. @@ -19,16 +21,19 @@ public final class ChallengeRuntime { * @param challengeSessionManager challenge session orchestration manager * @param worldResetManager world reset and lifecycle manager * @param runRecordsService run records persistence/query service + * @param trainingManager training gym manager */ public ChallengeRuntime( ChallengeManager challengeManager, ChallengeSessionManager challengeSessionManager, WorldResetManager worldResetManager, - RunRecordsService runRecordsService) { + RunRecordsService runRecordsService, + TrainingManager trainingManager) { this.challengeManager = challengeManager; this.challengeSessionManager = challengeSessionManager; this.worldResetManager = worldResetManager; this.runRecordsService = runRecordsService; + this.trainingManager = trainingManager; } /** @@ -66,4 +71,13 @@ public WorldResetManager getWorldResetManager() { public RunRecordsService getRunRecordsService() { return runRecordsService; } + + /** + * Returns the training gym manager instance. + * + * @return training manager + */ + public TrainingManager getTrainingManager() { + return trainingManager; + } } diff --git a/src/main/java/dev/deepcore/challenge/ChallengeRuntimeInitializer.java b/src/main/java/dev/deepcore/challenge/ChallengeRuntimeInitializer.java index f299d18..8cd5670 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeRuntimeInitializer.java +++ b/src/main/java/dev/deepcore/challenge/ChallengeRuntimeInitializer.java @@ -1,6 +1,7 @@ package dev.deepcore.challenge; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; import dev.deepcore.records.RunRecordsService; @@ -33,12 +34,18 @@ public ChallengeRuntime initialize(DeepCorePlugin plugin, DeepCoreLogger logger) recordsService.initialize(); challengeSessionManager.setRecordsService(recordsService); + TrainingManager trainingManager = new TrainingManager(plugin); + trainingManager.initialize(); + challengeSessionManager.registerEventListeners(); challengeSessionManager.initialize(); - registerChallengeCommand(plugin, challengeManager, challengeSessionManager, worldResetManager, logger); + registerChallengeCommand( + plugin, challengeManager, challengeSessionManager, worldResetManager, trainingManager, logger); + registerLobbyCommand(plugin, trainingManager, challengeSessionManager, logger); - return new ChallengeRuntime(challengeManager, challengeSessionManager, worldResetManager, recordsService); + return new ChallengeRuntime( + challengeManager, challengeSessionManager, worldResetManager, recordsService, trainingManager); } private void registerChallengeCommand( @@ -46,16 +53,31 @@ private void registerChallengeCommand( ChallengeManager challengeManager, ChallengeSessionManager challengeSessionManager, WorldResetManager worldResetManager, + TrainingManager trainingManager, DeepCoreLogger logger) { PluginCommand command = plugin.getCommand("challenge"); if (command == null) { throw new IllegalStateException("Command 'challenge' is not defined in plugin.yml."); } - ChallengeCommand challengeCommand = - new ChallengeCommand(plugin, challengeManager, challengeSessionManager, worldResetManager); + ChallengeCommand challengeCommand = new ChallengeCommand( + plugin, challengeManager, challengeSessionManager, worldResetManager, trainingManager); command.setExecutor(challengeCommand); command.setTabCompleter(challengeCommand); logger.debug("Challenge command registered."); } + + private void registerLobbyCommand( + DeepCorePlugin plugin, + TrainingManager trainingManager, + ChallengeSessionManager challengeSessionManager, + DeepCoreLogger logger) { + PluginCommand command = plugin.getCommand("lobby"); + if (command == null) { + throw new IllegalStateException("Command 'lobby' is not defined in plugin.yml."); + } + + command.setExecutor(new LobbyCommand(trainingManager, challengeSessionManager)); + logger.debug("Lobby command registered."); + } } diff --git a/src/main/java/dev/deepcore/challenge/LobbyCommand.java b/src/main/java/dev/deepcore/challenge/LobbyCommand.java new file mode 100644 index 0000000..5c69148 --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/LobbyCommand.java @@ -0,0 +1,43 @@ +package dev.deepcore.challenge; + +import dev.deepcore.challenge.training.TrainingManager; +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +/** + * Handles the /lobby command, returning players to the DeepCore lobby from the training gym. + */ +public final class LobbyCommand implements CommandExecutor { + private final TrainingManager trainingManager; + private final ChallengeSessionManager challengeSessionManager; + + /** + * Creates a lobby command handler. + * + * @param trainingManager training manager for leave-training logic + * @param challengeSessionManager session manager for lobby teleport logic + */ + public LobbyCommand(TrainingManager trainingManager, ChallengeSessionManager challengeSessionManager) { + this.trainingManager = trainingManager; + this.challengeSessionManager = challengeSessionManager; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage("Only players can use /lobby."); + return true; + } + + if (challengeSessionManager.isRunningPhase()) { + player.sendMessage(ChatColor.RED + "You cannot use /lobby during an active challenge."); + return true; + } + + trainingManager.leaveTraining(player); + return true; + } +} diff --git a/src/main/java/dev/deepcore/challenge/config/ChallengeConfigView.java b/src/main/java/dev/deepcore/challenge/config/ChallengeConfigView.java index 854dde7..e8639e0 100644 --- a/src/main/java/dev/deepcore/challenge/config/ChallengeConfigView.java +++ b/src/main/java/dev/deepcore/challenge/config/ChallengeConfigView.java @@ -106,4 +106,13 @@ public String lobbyOverworldWorldName() { public String lobbyNetherWorldName() { return plugin.getConfig().getString("reset.lobby-nether-world-name", "deepcore_lobby_nether"); } + + /** + * Returns configured training world name. + * + * @return world name used for training gym drills + */ + public String trainingWorldName() { + return plugin.getConfig().getString("training.world", "deepcore_gym"); + } } diff --git a/src/main/java/dev/deepcore/challenge/portal/PortalTransitCoordinatorService.java b/src/main/java/dev/deepcore/challenge/portal/PortalTransitCoordinatorService.java index 75062a2..cb7f2d2 100644 --- a/src/main/java/dev/deepcore/challenge/portal/PortalTransitCoordinatorService.java +++ b/src/main/java/dev/deepcore/challenge/portal/PortalTransitCoordinatorService.java @@ -144,20 +144,6 @@ public void handlePlayerPortal(PlayerPortalEvent event) { public void handlePlayerMove(PlayerMoveEvent event) { if (sessionState.is(SessionState.Phase.RUNNING)) { tryHandleEndPortalTransit(event.getPlayer()); - return; - } - - Location to = event.getTo(); - if (to == null) { - return; - } - - if (worldClassificationService.isLobbyOrLimboWorld(to.getWorld())) { - return; - } - - if (!prepAreaService.isWithinPrepArea(to)) { - event.setTo(prepAreaService.clampToPrepArea(to)); } } diff --git a/src/main/java/dev/deepcore/challenge/session/PrepAreaService.java b/src/main/java/dev/deepcore/challenge/session/PrepAreaService.java index d9d2dfa..4ab8f2b 100644 --- a/src/main/java/dev/deepcore/challenge/session/PrepAreaService.java +++ b/src/main/java/dev/deepcore/challenge/session/PrepAreaService.java @@ -4,7 +4,6 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; -import org.bukkit.WorldBorder; import org.bukkit.entity.Player; /** @@ -57,18 +56,7 @@ public void clearBorders() { * be cleared */ public void applyBorder(Player player, boolean runningPhase, Predicate isLobbyWorld) { - if (runningPhase || isLobbyWorld.test(player.getWorld())) { - player.setWorldBorder(null); - return; - } - - Location spawn = player.getWorld().getSpawnLocation(); - WorldBorder border = Bukkit.createWorldBorder(); - border.setCenter(spawn.getBlockX() + 0.5D, spawn.getBlockZ() + 0.5D); - border.setSize(prepAreaDiameterBlocks); - border.setWarningDistance(0); - border.setDamageAmount(0.0D); - player.setWorldBorder(border); + player.setWorldBorder(null); } /** diff --git a/src/main/java/dev/deepcore/challenge/session/PrepGuiCoordinatorService.java b/src/main/java/dev/deepcore/challenge/session/PrepGuiCoordinatorService.java index b112e6f..751aa0f 100644 --- a/src/main/java/dev/deepcore/challenge/session/PrepGuiCoordinatorService.java +++ b/src/main/java/dev/deepcore/challenge/session/PrepGuiCoordinatorService.java @@ -290,6 +290,10 @@ public void handlePrepGuiClick(InventoryClickEvent event) { player.closeInventory(); previewOrchestratorService.playPreviewDestroyAnimationThenReset(player); + }, + () -> { + player.closeInventory(); + player.performCommand("challenge train"); }); } @@ -328,6 +332,15 @@ public void handlePrepGuiClose(InventoryCloseEvent event) { return; } + String trainingWorldName = "deepcore_gym"; + if (plugin.getConfig() != null) { + trainingWorldName = plugin.getConfig().getString("training.world", "deepcore_gym"); + } + if (player.getWorld() != null && player.getWorld().getName().equalsIgnoreCase(trainingWorldName)) { + prepBookService.removeFromInventory(player); + return; + } + prepBookService.giveIfMissing(player); player.updateInventory(); }); diff --git a/src/main/java/dev/deepcore/challenge/session/PrepGuiFlowService.java b/src/main/java/dev/deepcore/challenge/session/PrepGuiFlowService.java index 53d7a36..58a25ae 100644 --- a/src/main/java/dev/deepcore/challenge/session/PrepGuiFlowService.java +++ b/src/main/java/dev/deepcore/challenge/session/PrepGuiFlowService.java @@ -53,6 +53,7 @@ public PrepGuiFlowService( * @param openPrepGui action to open a specific prep GUI page * @param closeInventory action to close the player's inventory * @param resetWorldFlow action to trigger world regeneration flow + * @param trainingTeleportFlow action to teleport player to training world * @return true when the click was handled by prep GUI flow logic */ public boolean handleClick( @@ -64,21 +65,25 @@ public boolean handleClick( Runnable refreshOpenPrepGuis, Consumer openPrepGui, Runnable closeInventory, - Runnable resetWorldFlow) { + Runnable resetWorldFlow, + Runnable trainingTeleportFlow) { if (slot == 47 && page != PrepGuiPage.RUN_HISTORY) { readyToggleFlow.run(); return true; } - boolean canResetWorldFromPage = - page == PrepGuiPage.CATEGORIES || page == PrepGuiPage.INVENTORY || page == PrepGuiPage.HEALTH; - if (canResetWorldFromPage && slot == 51) { + if (page == PrepGuiPage.CATEGORIES && slot == 51) { resetWorldFlow.run(); return true; } - if (page == PrepGuiPage.RUN_HISTORY && slot == 45) { - openPrepGui.accept(PrepGuiPage.CATEGORIES); + if (page == PrepGuiPage.CATEGORIES && slot == 53) { + trainingTeleportFlow.run(); + return true; + } + + if (slot == 45 && page == PrepGuiPage.CATEGORIES) { + closeInventory.run(); return true; } @@ -103,11 +108,6 @@ public boolean handleClick( return true; } - if (slot == 45 && page == PrepGuiPage.CATEGORIES) { - closeInventory.run(); - return true; - } - if (page == PrepGuiPage.INVENTORY) { if (slot == 20) { prepSettingsService.toggleComponent(ChallengeComponent.KEEP_INVENTORY); @@ -163,7 +163,7 @@ public boolean handleClick( return true; } - if (slot == 51 && prepGuiRenderer.hasRunHistoryNextPage(currentPage, totalRecords)) { + if (slot == 52 && prepGuiRenderer.hasRunHistoryNextPage(currentPage, totalRecords)) { runHistoryPageIndices.put(player.getUniqueId(), currentPage + 1); openPrepGui.accept(PrepGuiPage.RUN_HISTORY); return true; diff --git a/src/main/java/dev/deepcore/challenge/session/PrepReadinessService.java b/src/main/java/dev/deepcore/challenge/session/PrepReadinessService.java index d75c71e..28c4db2 100644 --- a/src/main/java/dev/deepcore/challenge/session/PrepReadinessService.java +++ b/src/main/java/dev/deepcore/challenge/session/PrepReadinessService.java @@ -109,7 +109,7 @@ public void tryStartCountdown( } prepAreaService.applyBordersToOnlinePlayers( - sessionState.is(SessionState.Phase.RUNNING), worldClassificationService::isLobbyOrLimboWorld); + sessionState.is(SessionState.Phase.RUNNING), worldClassificationService::isPrepBorderExemptWorld); startCountdown(participants, startRun); } @@ -128,7 +128,7 @@ public void cancelCountdownIfNoPlayersOnline(Set participants) { prepCountdownService.cancel(); sessionState.setPhase(SessionState.Phase.PREP); participants.clear(); - log.info("Countdown canceled because all players left."); + log.info("Countdown canceled - players left."); } private void startCountdown(Set participants, Runnable startRun) { @@ -139,6 +139,6 @@ private void startCountdown(Set participants, Runnable startRun) { || Bukkit.getOnlinePlayers().isEmpty(), () -> cancelCountdownIfNoPlayersOnline(participants), startRun, - secondsLeft -> log.info("Speedrun starts in " + secondsLeft + "...")); + secondsLeft -> log.info("Run starts in " + secondsLeft + "...")); } } diff --git a/src/main/java/dev/deepcore/challenge/session/RunCompletionService.java b/src/main/java/dev/deepcore/challenge/session/RunCompletionService.java index 14881bf..573128e 100644 --- a/src/main/java/dev/deepcore/challenge/session/RunCompletionService.java +++ b/src/main/java/dev/deepcore/challenge/session/RunCompletionService.java @@ -75,7 +75,7 @@ public void handleEntityDeath(EntityDeathEvent event) { long dragonDeathTime = System.currentTimeMillis(); runProgressService.markDragonKilled(dragonDeathTime); - log.info("Challenge complete: Ender Dragon defeated!"); + log.info("Victory! Ender Dragon defeated!"); recordRunIfAvailable(dragonDeathTime); startCompletionReturnCountdown(); @@ -106,7 +106,7 @@ private void startCompletionReturnCountdown() { 10, () -> sessionState.is(SessionState.Phase.RUNNING) && runProgressService.isDragonKilled(), this::completeDragonWinFlow, - secondsLeft -> log.info("Returning to lobby in " + secondsLeft + " seconds.")); + secondsLeft -> log.info("Lobby in " + secondsLeft + "s...")); } private void completeDragonWinFlow() { diff --git a/src/main/java/dev/deepcore/challenge/session/RunHealthCoordinatorService.java b/src/main/java/dev/deepcore/challenge/session/RunHealthCoordinatorService.java index fe348a9..33144c7 100644 --- a/src/main/java/dev/deepcore/challenge/session/RunHealthCoordinatorService.java +++ b/src/main/java/dev/deepcore/challenge/session/RunHealthCoordinatorService.java @@ -244,6 +244,7 @@ public void restoreDefaultMaxHealth(Player player) { maxHealthAttribute.setBaseValue(restored); } initialHalfHeartOriginalMaxHealth.remove(player.getUniqueId()); + player.setHealth(maxHealthAttribute.getValue()); } private void applyInitialHalfHeartMaxHealth(Player player) { diff --git a/src/main/java/dev/deepcore/challenge/session/RunPauseResumeService.java b/src/main/java/dev/deepcore/challenge/session/RunPauseResumeService.java index 2936eb0..e095735 100644 --- a/src/main/java/dev/deepcore/challenge/session/RunPauseResumeService.java +++ b/src/main/java/dev/deepcore/challenge/session/RunPauseResumeService.java @@ -100,7 +100,7 @@ public boolean pause(CommandSender sender, boolean announceBroadcast) { snapshotParticipantsForPause(); transitionToPausedPhase(); if (announceBroadcast) { - log.info("Challenge paused by " + sender.getName() + "."); + log.info("Run paused by " + sender.getName()); } return true; } @@ -120,7 +120,7 @@ public boolean resume(CommandSender sender) { restoreParticipantSnapshotsAfterResume(); restartRunTasksAfterResume(); - log.info("Challenge resumed by " + sender.getName() + "."); + log.info("Run resumed by " + sender.getName()); return true; } diff --git a/src/main/java/dev/deepcore/challenge/session/RunStartService.java b/src/main/java/dev/deepcore/challenge/session/RunStartService.java index 04071f5..619f510 100644 --- a/src/main/java/dev/deepcore/challenge/session/RunStartService.java +++ b/src/main/java/dev/deepcore/challenge/session/RunStartService.java @@ -186,7 +186,7 @@ public void startRun() { prepareParticipantsForRun(); launchRunTasksAndSync(); - log.info("DeepCore run started."); + log.info("Run started!"); } private boolean validateRunStartPreconditions() { @@ -198,7 +198,7 @@ private boolean validateRunStartPreconditions() { participants.clear(); announceDiscoPreviewStartBlocked.run(); prepAreaService.applyBordersToOnlinePlayers( - sessionState.is(SessionState.Phase.RUNNING), worldClassificationService::isLobbyOrLimboWorld); + sessionState.is(SessionState.Phase.RUNNING), worldClassificationService::isPrepBorderExemptWorld); Bukkit.getScheduler().runTask(plugin, refreshOpenPrepGuis); return false; } diff --git a/src/main/java/dev/deepcore/challenge/session/RunStatusService.java b/src/main/java/dev/deepcore/challenge/session/RunStatusService.java index 8f62665..6e63a5c 100644 --- a/src/main/java/dev/deepcore/challenge/session/RunStatusService.java +++ b/src/main/java/dev/deepcore/challenge/session/RunStatusService.java @@ -93,7 +93,7 @@ public Component buildRunActionBarMessage( private void maybeMarkBlazeObjectiveReached(long timestampMillis, boolean runningPhase) { runProgressService .maybeMarkBlazeObjectiveReached(runningPhase, lastObservedTeamBlazeRodCount, timestampMillis) - .ifPresent(splitMs -> log.info("Objective complete: Collect 6 Blaze Rods (split: " + .ifPresent(splitMs -> log.debug("Objective complete: Collect 6 Blaze Rods (split: " + runUiFormattingService.formatSplitDuration(splitMs) + ")")); } diff --git a/src/main/java/dev/deepcore/challenge/session/SessionFailureService.java b/src/main/java/dev/deepcore/challenge/session/SessionFailureService.java index 5fa97bd..9dbd9e4 100644 --- a/src/main/java/dev/deepcore/challenge/session/SessionFailureService.java +++ b/src/main/java/dev/deepcore/challenge/session/SessionFailureService.java @@ -81,21 +81,20 @@ public void handleHardcoreFailureIfNeeded() { /** Evaluates and handles all-players-dead failure reset conditions. */ public void handleAllPlayersDeadFailureIfNeeded() { - if (!sessionState.is(SessionState.Phase.RUNNING) - || challengeManager.isComponentEnabled(ChallengeComponent.HARDCORE) - || participants.isEmpty()) { + WorldResetManager worldResetManager = worldResetManagerSupplier.get(); + if (!sessionState.is(SessionState.Phase.RUNNING) || worldResetManager == null) { return; } - boolean allDead = participants.stream() - .allMatch(uuid -> recentlyDeadPlayers.contains(uuid) || eliminatedPlayers.contains(uuid)); + if (!challengeManager.isEnabled() || challengeManager.isComponentEnabled(ChallengeComponent.HARDCORE)) { + return; + } - if (!allDead || recentlyDeadPlayers.isEmpty()) { + if (participants.isEmpty() || recentlyDeadPlayers.isEmpty()) { return; } - WorldResetManager worldResetManager = worldResetManagerSupplier.get(); - if (worldResetManager == null || !challengeManager.isEnabled()) { + if (!recentlyDeadPlayers.containsAll(participants)) { return; } diff --git a/src/main/java/dev/deepcore/challenge/session/SessionPlayerLifecycleService.java b/src/main/java/dev/deepcore/challenge/session/SessionPlayerLifecycleService.java index b3ff3ac..581c360 100644 --- a/src/main/java/dev/deepcore/challenge/session/SessionPlayerLifecycleService.java +++ b/src/main/java/dev/deepcore/challenge/session/SessionPlayerLifecycleService.java @@ -267,7 +267,11 @@ public void handlePlayerJoin(PlayerJoinEvent event) { } if (sessionState.is(SessionState.Phase.PREP)) { - prepBookService.giveIfMissing(player); + if (worldClassificationService.isTrainingWorld(player.getWorld())) { + prepBookService.removeFromInventory(player); + } else { + prepBookService.giveIfMissing(player); + } Bukkit.getScheduler().runTask(plugin, refreshOpenPrepGuis); return; } @@ -383,7 +387,7 @@ public void handlePlayerDeath(PlayerDeathEvent event) { sessionFailureService.handleHardcoreFailureIfNeeded(); } else { recentlyDeadPlayers.add(playerId); - Bukkit.getScheduler().runTaskLater(plugin, sessionFailureService::handleAllPlayersDeadFailureIfNeeded, 1L); + sessionFailureService.handleAllPlayersDeadFailureIfNeeded(); } } @@ -463,6 +467,10 @@ public void handlePlayerChangedWorld(PlayerChangedWorldEvent event) { eliminatedPlayers); playerLobbyStateService.applyLobbyInventoryLoadoutIfInLobbyWorld(event.getPlayer()); + if (worldClassificationService.isTrainingWorld(event.getPlayer().getWorld())) { + prepBookService.removeFromInventory(event.getPlayer()); + } + if (!sessionState.is(SessionState.Phase.RUNNING)) { prepAreaService.applyBorder( event.getPlayer(), diff --git a/src/main/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorService.java b/src/main/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorService.java index e041384..b596fa4 100644 --- a/src/main/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorService.java +++ b/src/main/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorService.java @@ -198,10 +198,14 @@ public void initialize() { previewOrchestratorService.removeLobbyBlockDisplayEntities(); for (Player player : Bukkit.getOnlinePlayers()) { previewAnchorService.teleportToLobbyIfConfigured(player); - prepBookService.giveIfMissing(player); + if (!worldClassificationService.isTrainingWorld(player.getWorld())) { + prepBookService.giveIfMissing(player); + } else { + prepBookService.removeFromInventory(player); + } } prepAreaService.applyBordersToOnlinePlayers( - sessionState.is(SessionState.Phase.RUNNING), worldClassificationService::isLobbyOrLimboWorld); + sessionState.is(SessionState.Phase.RUNNING), worldClassificationService::isPrepBorderExemptWorld); previewOrchestratorService.removeLobbyBlockDisplayEntities(); refreshLobbyPreview.run(); } @@ -267,11 +271,15 @@ public void resetForNewRun() { } clearLobbySidebar.accept(player); runHealthCoordinatorService.restoreDefaultMaxHealth(player); - prepBookService.giveIfMissing(player); + if (!worldClassificationService.isTrainingWorld(player.getWorld())) { + prepBookService.giveIfMissing(player); + } else { + prepBookService.removeFromInventory(player); + } } prepAreaService.applyBordersToOnlinePlayers( - sessionState.is(SessionState.Phase.RUNNING), worldClassificationService::isLobbyOrLimboWorld); + sessionState.is(SessionState.Phase.RUNNING), worldClassificationService::isPrepBorderExemptWorld); refreshLobbyPreview.run(); Bukkit.getScheduler().runTask(plugin, refreshOpenPrepGuis); } @@ -289,7 +297,7 @@ public void endChallengeAndReturnToPrep() { player.setGameMode(GameMode.SURVIVAL); } } - log.info("DeepCore is now back in prep mode."); + log.info("Waiting for players..."); } /** @@ -298,8 +306,11 @@ public void endChallengeAndReturnToPrep() { * @param player player who should receive prep book when in prep phase */ public void ensurePrepBook(Player player) { - if (sessionState.is(SessionState.Phase.PREP)) { + if (sessionState.is(SessionState.Phase.PREP) + && !worldClassificationService.isTrainingWorld(player.getWorld())) { prepBookService.giveIfMissing(player); + } else if (worldClassificationService.isTrainingWorld(player.getWorld())) { + prepBookService.removeFromInventory(player); } } diff --git a/src/main/java/dev/deepcore/challenge/training/TrainingChallengeType.java b/src/main/java/dev/deepcore/challenge/training/TrainingChallengeType.java new file mode 100644 index 0000000..f472bca --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/training/TrainingChallengeType.java @@ -0,0 +1,52 @@ +package dev.deepcore.challenge.training; + +import java.util.Arrays; +import java.util.Optional; + +/** + * Supported training mini-challenge types. + */ +public enum TrainingChallengeType { + PORTAL("portal", "Portal"), + CRAFT("craft", "Craft"), + CHEST("chest", "Chest"), + BRIDGE("bridge", "Bridge"); + + private final String key; + private final String displayName; + + TrainingChallengeType(String key, String displayName) { + this.key = key; + this.displayName = displayName; + } + + /** + * Returns the stable key used in config and command args. + * + * @return lowercase challenge key + */ + public String key() { + return key; + } + + /** + * Returns the user-facing challenge display name. + * + * @return display name + */ + public String displayName() { + return displayName; + } + + /** + * Parses a challenge type from a configured or typed key. + * + * @param key key to resolve + * @return matching challenge type when found + */ + public static Optional fromKey(String key) { + return Arrays.stream(values()) + .filter(value -> value.key.equalsIgnoreCase(key)) + .findFirst(); + } +} diff --git a/src/main/java/dev/deepcore/challenge/training/TrainingManager.java b/src/main/java/dev/deepcore/challenge/training/TrainingManager.java new file mode 100644 index 0000000..2240d4d --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/training/TrainingManager.java @@ -0,0 +1,2215 @@ +package dev.deepcore.challenge.training; + +import dev.deepcore.DeepCorePlugin; +import dev.deepcore.logging.DeepCoreLogger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.SoundCategory; +import org.bukkit.World; +import org.bukkit.WorldCreator; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Container; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockFormEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.CraftItemEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerKickEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.world.PortalCreateEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.scoreboard.DisplaySlot; +import org.bukkit.scoreboard.Objective; +import org.bukkit.scoreboard.Scoreboard; + +/** + * Manages training-world mini-challenges, timers, HUD output, and persistence. + */ +public final class TrainingManager implements Listener { + private static final String TRAINING_OBJECTIVE_NAME = "deepcore_train"; + private static final String TRAINING_MUSIC_SOUND = "storytime:training.background"; + private static final String TRAINING_OBJECTIVE_TITLE = + ChatColor.AQUA + " " + ChatColor.BOLD + "Training Gym" + ChatColor.RESET + ChatColor.AQUA + " "; + + private final JavaPlugin plugin; + private final DeepCoreLogger log; + private final TrainingStatsStore statsStore; + + private final Map definitions; + private final Map activeByChallenge; + private final Map activeByPlayer; + private final Map returnLocations; + private final Map arenaSnapshots; + private final Map hiddenStartButtonSnapshots; + private final Map> trackedPortalLavaByChallenge; + private final Map lastDeathWorldByPlayer; + private final Map pendingAttemptRespawnByPlayer; + private final Map bridgeParticleTaskByPlayer; + + private String trainingWorldName; + private Location trainingLobbySpawn; + private Location trainingRespawnSpawn; + private boolean enabled; + private BukkitTask hudTask; + private TrainingReturnItemService returnItemService; + + /** + * Creates a training manager with configuration-backed behavior. + * + * @param plugin plugin instance + */ + public TrainingManager(JavaPlugin plugin) { + this.plugin = plugin; + this.log = ((DeepCorePlugin) plugin).getDeepCoreLogger(); + this.statsStore = new TrainingStatsStore(plugin); + this.definitions = new EnumMap<>(TrainingChallengeType.class); + this.activeByChallenge = new EnumMap<>(TrainingChallengeType.class); + this.activeByPlayer = new HashMap<>(); + this.returnLocations = new HashMap<>(); + this.arenaSnapshots = new EnumMap<>(TrainingChallengeType.class); + this.hiddenStartButtonSnapshots = new EnumMap<>(TrainingChallengeType.class); + this.trackedPortalLavaByChallenge = new EnumMap<>(TrainingChallengeType.class); + this.lastDeathWorldByPlayer = new HashMap<>(); + this.pendingAttemptRespawnByPlayer = new HashMap<>(); + this.bridgeParticleTaskByPlayer = new HashMap<>(); + } + + /** + * Initializes configuration, persistence, listener registration, and HUD tasks. + */ + public void initialize() { + loadFromConfig(); + statsStore.load(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + NamespacedKey returnItemKey = new NamespacedKey(plugin, "training_return_item"); + returnItemService = new TrainingReturnItemService(plugin, this, returnItemKey); + returnItemService.initialize(); + startHudTask(); + resetAllChallengeArenas(); + } + + /** Stops scheduled tasks and flushes stats to disk. */ + public void shutdown() { + restoreAllActiveAttemptsForShutdown(); + if (hudTask != null) { + hudTask.cancel(); + hudTask = null; + } + if (returnItemService != null) { + returnItemService.shutdown(); + } + clearAllTrainingHud(); + statsStore.save(); + } + + private void restoreAllActiveAttemptsForShutdown() { + if (activeByPlayer.isEmpty()) { + return; + } + + for (ActiveAttempt attempt : new ArrayList<>(activeByPlayer.values())) { + Player player = Bukkit.getPlayer(attempt.playerId()); + if (player != null) { + stopBridgeVisuals(player); + } + restoreAttemptArenaState(attempt.type()); + restoreStartButton(attempt.type()); + } + + bridgeParticleTaskByPlayer.values().forEach(BukkitTask::cancel); + bridgeParticleTaskByPlayer.clear(); + activeByPlayer.clear(); + activeByChallenge.clear(); + } + + /** + * Reloads training configuration from plugin config. + */ + public void reloadFromConfig() { + loadFromConfig(); + } + + /** + * Handles `/challenge train ...` command execution. + * + * @param sender command sender + * @param args full `/challenge` argument array + * @return true after handling the command + */ + public boolean handleCommand(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + log.sendWarn(sender, "Only players can use training commands."); + return true; + } + + if (!enabled) { + log.sendWarn(sender, "Training gym is currently disabled."); + return true; + } + + if (args.length == 1) { + teleportToTrainingLobby(player); + return true; + } + + String subcommand = args[1].toLowerCase(Locale.ROOT); + switch (subcommand) { + case "leave" -> leaveTraining(player); + case "stats" -> showStats(player, args.length >= 3 ? args[2] : null); + case "start" -> teleportToChallengeStartArea(player, args.length >= 3 ? args[2] : null); + case "reset" -> resetOwnAttempt(player); + default -> log.sendWarn( + player, + "Usage: /challenge train [leave|stats [portal|craft|chest|bridge]|start |reset]"); + } + return true; + } + + /** + * Returns tab completions for `/challenge train ...` arguments. + * + * @param args full `/challenge` argument array + * @return completions for train subcommand context + */ + public List tabComplete(String[] args) { + if (args.length == 2) { + return filterByPrefix(args[1], List.of("leave", "stats", "start", "reset")); + } + + if (args.length == 3 && (args[1].equalsIgnoreCase("stats") || args[1].equalsIgnoreCase("start"))) { + return filterByPrefix( + args[2], + Arrays.stream(TrainingChallengeType.values()) + .map(TrainingChallengeType::key) + .collect(Collectors.toList())); + } + + return List.of(); + } + + private void loadFromConfig() { + FileConfiguration config = plugin.getConfig(); + enabled = config.getBoolean("training.enabled", true); + trainingWorldName = config.getString("training.world", "deepcore_gym"); + ensureTrainingWorldLoaded(); + trainingLobbySpawn = readLocation(config, "training.lobby-spawn").orElse(null); + trainingRespawnSpawn = readLocation(config, "training.respawn-spawn").orElse(trainingLobbySpawn); + + definitions.clear(); + for (TrainingChallengeType type : TrainingChallengeType.values()) { + String basePath = "training.challenges." + type.key(); + if (!config.getBoolean(basePath + ".enabled", true)) { + continue; + } + + Optional region = readCuboid(config, basePath + ".region"); + Optional sidebarRegion = readCuboid(config, basePath + ".sidebar-region"); + Optional startButton = readLocation(config, basePath + ".start-button"); + Optional startLocation = readLocation(config, basePath + ".start-location"); + if (region.isEmpty() || startButton.isEmpty() || startLocation.isEmpty()) { + log.warn("Training challenge '" + type.key() + "' is missing required config and will be disabled."); + continue; + } + + Location bridgeCompletionPlate = readLocation(config, basePath + ".completion-pressure-plate") + .orElse(null); + Location hopperLocation = readLocation(config, basePath + ".hopper").orElse(null); + int minChests = Math.max(1, config.getInt(basePath + ".min-chests", 4)); + int maxChests = Math.max(minChests, config.getInt(basePath + ".max-chests", 8)); + int minBeds = Math.max(1, config.getInt("training.craft.beds.min", 5)); + int maxBeds = Math.max(minBeds, config.getInt("training.craft.beds.max", 8)); + int minEyes = Math.max(1, config.getInt("training.craft.eyes-of-ender.min", 7)); + int maxEyes = Math.max(minEyes, config.getInt("training.craft.eyes-of-ender.max", 9)); + List bridgePlatforms = + type == TrainingChallengeType.BRIDGE ? readBridgePlatforms(config, basePath) : List.of(); + + definitions.put( + type, + new ChallengeDefinition( + type, + region.get(), + sidebarRegion.orElse(null), + startButton.get(), + startLocation.get(), + bridgeCompletionPlate, + hopperLocation, + minChests, + maxChests, + minBeds, + maxBeds, + minEyes, + maxEyes, + bridgePlatforms)); + } + + arenaSnapshots.clear(); + trackedPortalLavaByChallenge.clear(); + } + + private void ensureTrainingWorldLoaded() { + if (!enabled || trainingWorldName == null || trainingWorldName.isBlank()) { + return; + } + + if (Bukkit.getWorld(trainingWorldName) != null) { + return; + } + + try { + WorldCreator creator = new WorldCreator(trainingWorldName); + creator.environment(World.Environment.NORMAL); + creator.type(org.bukkit.WorldType.FLAT); + creator.generateStructures(false); + try { + creator.generatorSettings( + "{\"biome\":\"minecraft:the_void\",\"layers\":[{\"block\":\"minecraft:air\",\"height\":1}],\"structures\":{\"structures\":{}}}"); + } catch (Throwable ignored) { + // Some server variants may not accept generator settings via API. + } + + World loaded = Bukkit.createWorld(creator); + if (loaded == null) { + log.warn("Configured training world '" + trainingWorldName + "' could not be loaded."); + } + } catch (RuntimeException ex) { + log.warn("Failed loading training world '" + trainingWorldName + "': " + ex.getMessage()); + } + } + + private void startHudTask() { + if (hudTask != null) { + hudTask.cancel(); + } + + hudTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickHud, 0L, 2L); + } + + private void tickHud() { + if (!enabled) { + clearAllTrainingHud(); + return; + } + + checkPortalAttemptCompletion(); + + for (Player player : Bukkit.getOnlinePlayers()) { + if (!isInTrainingWorld(player)) { + clearTrainingSidebar(player); + continue; + } + + ActiveAttempt activeAttempt = activeByPlayer.get(player.getUniqueId()); + if (activeAttempt != null) { + player.sendActionBar(Component.text(buildActiveActionBar(activeAttempt))); + clearTrainingSidebar(player); + continue; + } + + showIdleSidebar(player); + } + } + + private void clearAllTrainingHud() { + for (Player player : Bukkit.getOnlinePlayers()) { + clearTrainingSidebar(player); + if (isInTrainingWorld(player)) { + player.sendActionBar(Component.empty()); + } + } + } + + private void showIdleSidebar(Player player) { + if (Bukkit.getScoreboardManager() == null) { + return; + } + + TrainingChallengeType regionChallenge = + resolveChallengeByLocation(player.getLocation()).orElse(null); + TrainingStatsStore.PlayerChallengeStats stats = regionChallenge == null + ? new TrainingStatsStore.PlayerChallengeStats(-1L, List.of()) + : statsStore.getStats(player.getUniqueId(), regionChallenge); + + Scoreboard scoreboard = Bukkit.getScoreboardManager().getNewScoreboard(); + Objective objective = + scoreboard.registerNewObjective(TRAINING_OBJECTIVE_NAME, "dummy", TRAINING_OBJECTIVE_TITLE); + objective.setDisplaySlot(DisplaySlot.SIDEBAR); + applyBlankSidebarNumberFormat(objective); + + int score = 12; + objective.getScore(ChatColor.DARK_GRAY.toString()).setScore(score--); + objective + .getScore(ChatColor.YELLOW + "Current Mini-challenge: " + ChatColor.WHITE + + (regionChallenge == null ? "NONE" : regionChallenge.displayName())) + .setScore(score--); + objective.getScore(ChatColor.GRAY.toString()).setScore(score--); + objective + .getScore(ChatColor.GRAY + "Best: " + formatOptionalDuration(stats.bestTimeMs())) + .setScore(score--); + objective.getScore(ChatColor.BLUE.toString()).setScore(score--); + + List attempts = stats.lastAttemptsMs(); + if (attempts.isEmpty()) { + objective.getScore(ChatColor.DARK_GRAY + "No attempts yet").setScore(score--); + } else { + for (int index = 0; index < attempts.size() && index < 5; index++) { + objective + .getScore(ChatColor.AQUA + "Try " + (index + 1) + ": " + ChatColor.WHITE + + formatDurationMmSsCc(attempts.get(index))) + .setScore(score--); + } + } + + player.setScoreboard(scoreboard); + } + + private void clearTrainingSidebar(Player player) { + if (Bukkit.getScoreboardManager() == null) { + return; + } + + Scoreboard scoreboard = player.getScoreboard(); + if (scoreboard == null) { + return; + } + + Objective objective = scoreboard.getObjective(TRAINING_OBJECTIVE_NAME); + if (objective != null) { + player.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard()); + } + } + + private void teleportToTrainingLobby(Player player) { + World world = Bukkit.getWorld(trainingWorldName); + if (world == null) { + log.sendError(player, "Training world '" + trainingWorldName + "' is not loaded."); + return; + } + + if (!isInTrainingWorld(player)) { + returnLocations.put(player.getUniqueId(), player.getLocation().clone()); + } + + Location destination = trainingLobbySpawn == null + ? world.getSpawnLocation().clone().add(0.5, 0.0, 0.5) + : trainingLobbySpawn.clone(); + if (destination.getWorld() == null) { + destination.setWorld(world); + } + + player.teleport(destination); + if (returnItemService != null) { + returnItemService.onPlayerEnterTraining(player); + } + log.sendInfo(player, ChatColor.GREEN + "Welcome to Training Gym."); + } + + /** + * Returns a player from their training attempt to the training lobby. + * + * @param player player to return to lobby + */ + public void returnToTrainingLobby(Player player) { + UUID playerId = player.getUniqueId(); + ActiveAttempt attempt = activeByPlayer.get(playerId); + if (attempt != null) { + cancelAttempt(player, "Attempt cancelled: returned to lobby."); + } + if (returnItemService != null) { + returnItemService.onPlayerLeaveTraining(player); + } + teleportToTrainingLobby(player); + log.sendInfo(player, ChatColor.YELLOW + "Returned to training lobby."); + } + + public boolean isInActiveAttempt(Player player) { + return activeByPlayer.containsKey(player.getUniqueId()); + } + + /** + * Cancels any active attempt and cleans up return-item state for the player. + * + * @param player player leaving the training area + */ + public void leaveTraining(Player player) { + cancelAttempt(player, "Attempt cancelled: left training."); + if (returnItemService != null) { + returnItemService.onPlayerLeaveTraining(player); + } + + Location returnLocation = returnLocations.remove(player.getUniqueId()); + if (returnLocation == null || returnLocation.getWorld() == null) { + World world = + Bukkit.getWorlds().isEmpty() ? null : Bukkit.getWorlds().get(0); + if (world == null) { + log.sendWarn(player, "No return location available."); + return; + } + returnLocation = world.getSpawnLocation().clone().add(0.5, 0.0, 0.5); + } + + player.teleport(returnLocation); + clearTrainingSidebar(player); + } + + private void showStats(Player player, String challengeKey) { + if (challengeKey == null) { + TrainingChallengeType regionType = + resolveChallengeByLocation(player.getLocation()).orElse(null); + if (regionType == null) { + log.sendInfo(player, ChatColor.YELLOW + "Current Mini-challenge: NONE"); + return; + } + sendStatsLineup(player, regionType); + return; + } + + TrainingChallengeType type = TrainingChallengeType.fromKey(challengeKey).orElse(null); + if (type == null) { + log.sendWarn(player, "Unknown challenge. Use portal|craft|chest|bridge."); + return; + } + + sendStatsLineup(player, type); + } + + private void sendStatsLineup(Player player, TrainingChallengeType type) { + TrainingStatsStore.PlayerChallengeStats stats = statsStore.getStats(player.getUniqueId(), type); + log.sendInfo(player, ChatColor.GOLD + type.displayName() + " stats:"); + log.sendInfo(player, ChatColor.GRAY + "Best: " + formatOptionalDuration(stats.bestTimeMs())); + if (stats.lastAttemptsMs().isEmpty()) { + log.sendInfo(player, ChatColor.DARK_GRAY + "No attempts yet."); + return; + } + + int index = 1; + for (Long attempt : stats.lastAttemptsMs()) { + log.sendInfo( + player, ChatColor.AQUA + "Try " + index + ": " + ChatColor.WHITE + formatDurationMmSsCc(attempt)); + index++; + } + } + + private void teleportToChallengeStartArea(Player player, String challengeKey) { + if (challengeKey == null) { + log.sendWarn(player, "Usage: /challenge train start "); + return; + } + + TrainingChallengeType type = TrainingChallengeType.fromKey(challengeKey).orElse(null); + if (type == null) { + log.sendWarn(player, "Unknown challenge. Use portal|craft|chest|bridge."); + return; + } + + ChallengeDefinition definition = definitions.get(type); + if (definition == null) { + log.sendWarn(player, "That challenge is not configured."); + return; + } + + player.teleport(definition.startButton().clone().add(0.5, 0.0, 0.5)); + log.sendInfo(player, ChatColor.YELLOW + "Press the start button to begin " + type.displayName() + "."); + } + + private void resetOwnAttempt(Player player) { + ActiveAttempt attempt = activeByPlayer.get(player.getUniqueId()); + if (attempt == null) { + log.sendWarn(player, "You are not currently in an active training attempt."); + return; + } + + cancelAttempt(player, "Attempt cancelled."); + if (returnItemService != null) { + returnItemService.onPlayerEnterTraining(player); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onStartButtonPress(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null) { + return; + } + + Optional match = + resolveChallengeByStartButton(event.getClickedBlock().getLocation()); + if (match.isEmpty()) { + return; + } + + event.setCancelled(true); + Player player = event.getPlayer(); + startAttempt(player, match.get()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBridgeCompletionPlate(PlayerInteractEvent event) { + if (event.getAction() != Action.PHYSICAL || event.getClickedBlock() == null) { + return; + } + + Player player = event.getPlayer(); + ActiveAttempt attempt = activeByPlayer.get(player.getUniqueId()); + if (attempt == null || attempt.type() != TrainingChallengeType.BRIDGE) { + return; + } + + Location destinationPlate = attempt.bridgeDestinationPlate(); + if (destinationPlate == null) { + ChallengeDefinition definition = definitions.get(TrainingChallengeType.BRIDGE); + if (definition == null || definition.completionPressurePlate() == null) { + return; + } + destinationPlate = definition.completionPressurePlate(); + } + + if (!sameBlock(event.getClickedBlock().getLocation(), destinationPlate)) { + return; + } + + completeAttempt(event.getPlayer(), attempt); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPortalCreate(PortalCreateEvent event) { + ActiveAttempt attempt = activeByChallenge.get(TrainingChallengeType.PORTAL); + if (attempt == null) { + return; + } + + ChallengeDefinition definition = definitions.get(TrainingChallengeType.PORTAL); + if (definition == null) { + return; + } + + boolean inside = + event.getBlocks().stream().anyMatch(state -> definition.region().contains(state.getLocation())); + if (!inside) { + return; + } + + Player player = Bukkit.getPlayer(attempt.playerId()); + if (player != null) { + completeAttempt(player, attempt); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPortalBlockForm(BlockFormEvent event) { + if (event.getNewState().getType() != Material.NETHER_PORTAL) { + return; + } + + ActiveAttempt attempt = activeByChallenge.get(TrainingChallengeType.PORTAL); + if (attempt == null) { + return; + } + + ChallengeDefinition definition = definitions.get(TrainingChallengeType.PORTAL); + if (definition == null || !definition.region().contains(event.getBlock().getLocation())) { + return; + } + + Player player = Bukkit.getPlayer(attempt.playerId()); + if (player != null) { + Bukkit.getScheduler().runTask(plugin, () -> { + ActiveAttempt latest = activeByChallenge.get(TrainingChallengeType.PORTAL); + if (latest == null || !latest.playerId().equals(player.getUniqueId())) { + return; + } + completeAttempt(player, latest); + }); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCraftItem(CraftItemEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + ActiveAttempt attempt = activeByPlayer.get(player.getUniqueId()); + if (attempt == null || attempt.type() != TrainingChallengeType.CRAFT || attempt.craftObjective() == null) { + return; + } + + CraftObjective objective = attempt.craftObjective(); + Material result = event.getRecipe().getResult().getType(); + int amount = Math.max(1, event.getRecipe().getResult().getAmount()); + if (event.isShiftClick()) { + amount *= computeShiftClickMultiplier(event.getInventory().getMatrix()); + } + + if (isBedMaterial(result)) { + objective.craftedBeds += amount; + } else if (result == Material.ENDER_EYE) { + objective.craftedEyes += amount; + } else if (isMetalAxe(result)) { + objective.craftedAxe = true; + } else if (isMetalShovel(result)) { + objective.craftedShovel = true; + } + + if (objective.isComplete()) { + event.setCancelled(true); + Bukkit.getScheduler().runTask(plugin, () -> { + ActiveAttempt latest = activeByPlayer.get(player.getUniqueId()); + if (latest == null || latest.type() != TrainingChallengeType.CRAFT) { + return; + } + completeAttempt(player, latest); + }); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChestClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + ActiveAttempt attempt = activeByPlayer.get(player.getUniqueId()); + if (attempt == null || attempt.type() != TrainingChallengeType.CHEST) { + return; + } + + Bukkit.getScheduler().runTask(plugin, () -> maybeCompleteChestAttempt(player)); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChestDrag(InventoryDragEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + ActiveAttempt attempt = activeByPlayer.get(player.getUniqueId()); + if (attempt == null || attempt.type() != TrainingChallengeType.CHEST) { + return; + } + + Bukkit.getScheduler().runTask(plugin, () -> maybeCompleteChestAttempt(player)); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onChestChallengeInventoryClose(InventoryCloseEvent event) { + if (!(event.getPlayer() instanceof Player player)) { + return; + } + + ActiveAttempt attempt = activeByPlayer.get(player.getUniqueId()); + if (attempt == null || attempt.type() != TrainingChallengeType.CHEST) { + return; + } + + Bukkit.getScheduler().runTask(plugin, () -> maybeCompleteChestAttempt(player)); + } + + @EventHandler(priority = EventPriority.NORMAL) + public void onChestChallengeBlockBreak(BlockBreakEvent event) { + ActiveAttempt attempt = activeByPlayer.get(event.getPlayer().getUniqueId()); + if (attempt == null || attempt.type() != TrainingChallengeType.CHEST) { + return; + } + + ChallengeDefinition definition = definitions.get(TrainingChallengeType.CHEST); + if (definition == null) { + return; + } + + if (definition.hopperLocation() != null + && sameBlock(event.getBlock().getLocation(), definition.hopperLocation())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAttemptPlayerMove(PlayerMoveEvent event) { + if (event.getTo() == null + || event.getFrom().getBlock().equals(event.getTo().getBlock())) { + return; + } + + if (activeByPlayer.get(event.getPlayer().getUniqueId()) == null) { + return; + } + + ActiveAttempt attempt = activeByPlayer.get(event.getPlayer().getUniqueId()); + + ChallengeDefinition definition = definitions.get(attempt.type()); + if (definition == null) { + cancelAttempt(event.getPlayer(), "Attempt cancelled: challenge unavailable."); + return; + } + + if (!definition.region().contains(event.getTo())) { + cancelAttempt(event.getPlayer(), "Attempt cancelled: you left the challenge area."); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAttemptTeleport(PlayerTeleportEvent event) { + ActiveAttempt attempt = activeByPlayer.get(event.getPlayer().getUniqueId()); + if (attempt == null || event.getTo() == null) { + return; + } + + ChallengeDefinition definition = definitions.get(attempt.type()); + if (definition == null) { + cancelAttempt(event.getPlayer(), "Attempt cancelled: challenge unavailable."); + return; + } + + if (event.getCause() == PlayerTeleportEvent.TeleportCause.PLUGIN + && sameBlock(event.getTo(), definition.startLocation())) { + return; + } + + if (!definition.region().contains(event.getTo())) { + cancelAttempt(event.getPlayer(), "Attempt cancelled: you left the challenge area."); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onAttemptQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + UUID playerId = player.getUniqueId(); + lastDeathWorldByPlayer.remove(playerId); + pendingAttemptRespawnByPlayer.remove(playerId); + if (activeByPlayer.containsKey(playerId)) { + clearAttemptInventoryAndForceSurvival(player); + } + cancelAttempt(player, null); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onAttemptKick(PlayerKickEvent event) { + Player player = event.getPlayer(); + UUID playerId = player.getUniqueId(); + lastDeathWorldByPlayer.remove(playerId); + pendingAttemptRespawnByPlayer.remove(playerId); + if (activeByPlayer.containsKey(playerId)) { + clearAttemptInventoryAndForceSurvival(player); + } + cancelAttempt(player, null); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerDeath(PlayerDeathEvent event) { + Player player = event.getPlayer(); + World world = player.getWorld(); + if (world != null && world.getName().equalsIgnoreCase(trainingWorldName)) { + event.setKeepInventory(false); + } + + ActiveAttempt attempt = activeByPlayer.get(player.getUniqueId()); + if (attempt != null) { + ChallengeDefinition definition = definitions.get(attempt.type()); + if (definition != null) { + Location buttonLocation = definition.startButton().clone().add(0.5D, 0.0D, 0.5D); + if (buttonLocation.getWorld() != null) { + pendingAttemptRespawnByPlayer.put(player.getUniqueId(), buttonLocation); + } + } + + event.getDrops().clear(); + event.setDroppedExp(0); + clearAttemptInventoryAndForceSurvival(player); + cancelAttempt(player, "Attempt cancelled: you died."); + } + + if (world == null) { + return; + } + lastDeathWorldByPlayer.put(player.getUniqueId(), world.getName()); + } + + @EventHandler(priority = EventPriority.HIGH) + public void onPlayerRespawn(PlayerRespawnEvent event) { + UUID playerId = event.getPlayer().getUniqueId(); + Location pendingAttemptRespawn = pendingAttemptRespawnByPlayer.remove(playerId); + if (pendingAttemptRespawn != null) { + event.setRespawnLocation(pendingAttemptRespawn); + Bukkit.getScheduler().runTask(plugin, () -> clearAttemptInventoryAndForceSurvival(event.getPlayer())); + return; + } + + String deathWorld = lastDeathWorldByPlayer.remove(playerId); + if (deathWorld == null || !deathWorld.equalsIgnoreCase(trainingWorldName)) { + return; + } + + Location respawn = resolveTrainingRespawnLocation(); + if (respawn != null) { + event.setRespawnLocation(respawn); + } + } + + private Location resolveTrainingRespawnLocation() { + World trainingWorld = Bukkit.getWorld(trainingWorldName); + if (trainingWorld == null) { + return null; + } + + Location base = trainingRespawnSpawn != null + ? trainingRespawnSpawn.clone() + : (trainingLobbySpawn != null ? trainingLobbySpawn.clone() : null); + + if (base == null) { + return trainingWorld.getSpawnLocation().clone().add(0.5, 0.0, 0.5); + } + + if (base.getWorld() == null || !base.getWorld().getName().equalsIgnoreCase(trainingWorldName)) { + base.setWorld(trainingWorld); + } + return base; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBridgePlace(BlockPlaceEvent event) { + ActiveAttempt attempt = activeByPlayer.get(event.getPlayer().getUniqueId()); + if (attempt == null || attempt.type() != TrainingChallengeType.BRIDGE) { + return; + } + + // Keep bridge arena constrained so players cannot pollute nearby regions. + ChallengeDefinition definition = definitions.get(TrainingChallengeType.BRIDGE); + if (definition != null + && !definition.region().contains(event.getBlockPlaced().getLocation())) { + event.setCancelled(true); + } + } + + private void startAttempt(Player player, TrainingChallengeType type) { + startAttempt(player, type, true); + } + + private boolean startAttempt(Player player, TrainingChallengeType type, boolean teleportToStartLocation) { + if (!isInTrainingWorld(player)) { + log.sendWarn(player, "Enter the training gym first with /challenge train."); + return false; + } + + ActiveAttempt existingByPlayer = activeByPlayer.get(player.getUniqueId()); + if (existingByPlayer != null) { + log.sendWarn(player, "You are already in an active attempt."); + return false; + } + + ActiveAttempt existingByChallenge = activeByChallenge.get(type); + if (existingByChallenge != null && !existingByChallenge.playerId().equals(player.getUniqueId())) { + Player occupant = Bukkit.getPlayer(existingByChallenge.playerId()); + String occupantName = occupant == null ? "another player" : occupant.getName(); + log.sendWarn(player, type.displayName() + " arena is currently in use by " + occupantName + "."); + return false; + } + + ChallengeDefinition definition = definitions.get(type); + if (definition == null) { + log.sendWarn(player, "That challenge is not configured."); + return false; + } + + if (returnItemService != null) { + returnItemService.onPlayerEnterTraining(player); + } + + if (type == TrainingChallengeType.CHEST) { + if (!arenaSnapshots.containsKey(type)) { + clearDynamicChestsInRegion(definition); + } else { + World chestWorld = Bukkit.getWorld(definition.region().worldName()); + if (chestWorld != null) { + clearHopperInventories(definition, chestWorld); + } + } + } + + if (type == TrainingChallengeType.PORTAL || !arenaSnapshots.containsKey(type)) { + arenaSnapshots.put(type, snapshotRegion(definition.region())); + } + if (type == TrainingChallengeType.PORTAL) { + trackedPortalLavaByChallenge.put(type, snapshotLavaBlocks(definition.region())); + } + + CraftObjective craftObjective = null; + if (type == TrainingChallengeType.CRAFT) { + ThreadLocalRandom rng = ThreadLocalRandom.current(); + List pool = new ArrayList<>(List.of("beds", "eyes", "axe", "shovel")); + Collections.shuffle(pool, rng); + Set selected = new HashSet<>(pool.subList(0, rng.nextInt(2, 4))); + boolean reqBeds = selected.contains("beds"); + boolean reqEyes = selected.contains("eyes"); + int bedsTarget = reqBeds ? rng.nextInt(definition.minBeds(), definition.maxBeds() + 1) : 0; + int eyesTarget = reqEyes ? rng.nextInt(definition.minEyes(), definition.maxEyes() + 1) : 0; + craftObjective = new CraftObjective( + reqBeds, reqEyes, selected.contains("axe"), selected.contains("shovel"), bedsTarget, eyesTarget); + } + + ChestObjective chestObjective = null; + if (type == TrainingChallengeType.CHEST) { + chestObjective = generateChestObjective(); + } + + Location bridgeDestinationPlate = null; + Location bridgeStartLocation = null; + if (type == TrainingChallengeType.BRIDGE) { + List platforms = definition.bridgePlatforms(); + if (platforms.size() >= 2) { + int startIdx = ThreadLocalRandom.current().nextInt(platforms.size()); + int destIdx; + do { + destIdx = ThreadLocalRandom.current().nextInt(platforms.size()); + } while (destIdx == startIdx); + bridgeStartLocation = platforms.get(startIdx).spawnLocation(); + bridgeDestinationPlate = platforms.get(destIdx).pressurePlate(); + } + } + + prepareLoadout(player, type, craftObjective, chestObjective, definition); + + ActiveAttempt attempt = new ActiveAttempt( + player.getUniqueId(), + type, + System.currentTimeMillis(), + craftObjective, + chestObjective, + bridgeDestinationPlate); + activeByChallenge.put(type, attempt); + activeByPlayer.put(player.getUniqueId(), attempt); + hideStartButton(definition, type); + + if (bridgeDestinationPlate != null) { + startBridgeVisuals(player, bridgeDestinationPlate); + } + + if (teleportToStartLocation) { + Location startLoc = bridgeStartLocation != null ? bridgeStartLocation : definition.startLocation(); + player.teleport(startLoc); + } + player.stopAllSounds(); + player.playSound(player.getLocation(), TRAINING_MUSIC_SOUND, SoundCategory.MUSIC, 1.0f, 1.0f); + log.sendInfo(player, ChatColor.GREEN + type.displayName() + " challenge started."); + return true; + } + + private void prepareLoadout( + Player player, + TrainingChallengeType type, + CraftObjective craftObjective, + ChestObjective chestObjective, + ChallengeDefinition definition) { + PlayerInventory inventory = player.getInventory(); + inventory.clear(); + + switch (type) { + case PORTAL -> { + inventory.addItem(new ItemStack(Material.WATER_BUCKET, 1)); + inventory.addItem(new ItemStack(Material.DIRT, 6)); + ItemStack flintAndSteel = new ItemStack(Material.FLINT_AND_STEEL, 1); + if (flintAndSteel.getItemMeta() instanceof Damageable damageable) { + damageable.setDamage(0); + flintAndSteel.setItemMeta(damageable); + } + inventory.addItem(flintAndSteel); + } + case CRAFT -> { + if (craftObjective == null) { + break; + } + ThreadLocalRandom rng = ThreadLocalRandom.current(); + List stacks = new ArrayList<>(); + + if (craftObjective.requireBeds) { + int wool = craftObjective.bedsTarget * 3 + rng.nextInt(0, craftObjective.bedsTarget / 2 + 2); + int planks = craftObjective.bedsTarget * 3 + rng.nextInt(0, craftObjective.bedsTarget / 2 + 2); + addScatteredStacks(stacks, Material.WHITE_WOOL, wool, rng); + addScatteredStacks(stacks, Material.OAK_PLANKS, planks, rng); + } + + if (craftObjective.requireEyes) { + int pearls = craftObjective.eyesTarget + rng.nextInt(0, craftObjective.eyesTarget / 3 + 2); + int blaze = craftObjective.eyesTarget + rng.nextInt(0, craftObjective.eyesTarget / 3 + 2); + addScatteredStacks(stacks, Material.ENDER_PEARL, pearls, rng); + addScatteredStacks(stacks, Material.BLAZE_POWDER, blaze, rng); + } + + int ironNeeded = (craftObjective.requireAxe ? 3 : 0) + (craftObjective.requireShovel ? 1 : 0); + int sticksNeeded = (craftObjective.requireAxe ? 2 : 0) + (craftObjective.requireShovel ? 2 : 0); + if (ironNeeded > 0) { + addScatteredStacks(stacks, Material.IRON_INGOT, ironNeeded + rng.nextInt(0, 3), rng); + addScatteredStacks(stacks, Material.STICK, sticksNeeded + rng.nextInt(0, 3), rng); + } + + Collections.shuffle(stacks, rng); + placeStacksRandomly( + inventory, stacks, Set.of(TrainingReturnItemService.RETURN_ITEM_SLOT, 36, 37, 38, 39, 40)); + } + case BRIDGE -> inventory.addItem(new ItemStack(Material.DIRT, 64)); + case CHEST -> { + if (chestObjective != null) { + spawnChestsWithLoot(definition, chestObjective); + } + } + } + } + + private String buildActiveActionBar(ActiveAttempt attempt) { + long elapsed = Math.max(0L, System.currentTimeMillis() - attempt.startedAtMillis()); + String objective = objectiveText(attempt); + return attempt.type().displayName() + " | Objective: " + objective + " | " + formatDurationMmSsCc(elapsed); + } + + private String objectiveText(ActiveAttempt attempt) { + return switch (attempt.type()) { + case PORTAL -> "Light a valid portal"; + case CHEST -> { + ChestObjective chest = attempt.chestObjective(); + if (chest == null) { + yield "Deposit required items in hopper"; + } + ChallengeDefinition def = definitions.get(TrainingChallengeType.CHEST); + if (def != null && def.hopperLocation() != null) { + BlockState bs = def.hopperLocation().getBlock().getState(); + if (bs instanceof Container c) { + yield "Hopper: " + chest.remainingText(c.getInventory()); + } + } + yield "Need: " + chest.requiredItemsText(); + } + case BRIDGE -> { + Location dest = attempt.bridgeDestinationPlate(); + if (dest != null) { + yield "Bridge to (" + dest.getBlockX() + ", " + dest.getBlockY() + ", " + dest.getBlockZ() + ")"; + } + yield "Reach destination pressure plate"; + } + case CRAFT -> { + CraftObjective craft = attempt.craftObjective(); + if (craft == null) { + yield "Craft objective"; + } + List parts = new ArrayList<>(); + if (craft.requireBeds) { + parts.add("Beds " + craft.craftedBeds + "/" + craft.bedsTarget); + } + if (craft.requireEyes) { + parts.add("Eyes " + craft.craftedEyes + "/" + craft.eyesTarget); + } + if (craft.requireAxe) { + parts.add(craft.craftedAxe ? "Axe" : "Axe?"); + } + if (craft.requireShovel) { + parts.add(craft.craftedShovel ? "Shovel" : "Shovel?"); + } + yield String.join(", ", parts); + } + }; + } + + private void maybeCompleteChestAttempt(Player player) { + ActiveAttempt attempt = activeByPlayer.get(player.getUniqueId()); + if (attempt == null || attempt.type() != TrainingChallengeType.CHEST || attempt.chestObjective() == null) { + return; + } + + ChallengeDefinition definition = definitions.get(TrainingChallengeType.CHEST); + if (definition == null || definition.hopperLocation() == null) { + return; + } + + BlockState state = definition.hopperLocation().getBlock().getState(); + if (!(state instanceof Container container)) { + return; + } + + if (attempt.chestObjective().isHopperComplete(container.getInventory())) { + completeAttempt(player, attempt); + } + } + + private void startBridgeVisuals(Player player, Location destinationPlate) { + player.setCompassTarget(destinationPlate); + + double cx = destinationPlate.getBlockX() + 0.5; + double cz = destinationPlate.getBlockZ() + 0.5; + double baseY = destinationPlate.getBlockY() + 1.0; + World world = destinationPlate.getWorld(); + + BukkitTask task = Bukkit.getScheduler() + .runTaskTimer( + plugin, + () -> { + if (world == null + || !world.isChunkLoaded( + destinationPlate.getBlockX() >> 4, destinationPlate.getBlockZ() >> 4)) { + return; + } + for (int i = 0; i < 6; i++) { + world.spawnParticle(Particle.END_ROD, cx, baseY + i, cz, 1, 0.0, 0.0, 0.0, 0.0); + } + }, + 0L, + 3L); + + BukkitTask old = bridgeParticleTaskByPlayer.put(player.getUniqueId(), task); + if (old != null) { + old.cancel(); + } + } + + private void stopBridgeVisuals(Player player) { + BukkitTask task = bridgeParticleTaskByPlayer.remove(player.getUniqueId()); + if (task != null) { + task.cancel(); + } + World world = player.getWorld(); + if (world != null) { + player.setCompassTarget(world.getSpawnLocation()); + } + } + + private void completeAttempt(Player player, ActiveAttempt attempt) { + long elapsedMs = Math.max(0L, System.currentTimeMillis() - attempt.startedAtMillis()); + statsStore.recordCompletedAttempt(player.getUniqueId(), attempt.type(), elapsedMs); + statsStore.save(); + + stopBridgeVisuals(player); + restoreAttemptArenaState(attempt.type()); + clearAttempt(player, attempt.type()); + + player.closeInventory(); + ItemStack slot8 = player.getInventory().getItem(TrainingReturnItemService.RETURN_ITEM_SLOT); + boolean hasReturnItem = returnItemService != null && returnItemService.isReturnItem(slot8); + clearAttemptInventoryAndForceSurvival(player); + if (hasReturnItem) { + player.getInventory().setItem(TrainingReturnItemService.RETURN_ITEM_SLOT, slot8); + } + + if (attempt.type() == TrainingChallengeType.PORTAL) { + clearNetherPortalBlocksInRegion(TrainingChallengeType.PORTAL); + refillTrackedPortalLava(TrainingChallengeType.PORTAL); + Bukkit.getScheduler().runTask(plugin, () -> { + clearNetherPortalBlocksInRegion(TrainingChallengeType.PORTAL); + refillTrackedPortalLava(TrainingChallengeType.PORTAL); + }); + } + player.stopSound(TRAINING_MUSIC_SOUND, SoundCategory.MUSIC); + teleportToStartButton(player, attempt.type()); + playCompletionJingle(player); + player.sendActionBar(Component.empty()); + + log.sendInfo( + player, + ChatColor.GREEN + attempt.type().displayName() + " complete in " + formatDurationMmSsCc(elapsedMs) + + "."); + } + + private void cancelAttempt(Player player, String message) { + ActiveAttempt attempt = activeByPlayer.get(player.getUniqueId()); + if (attempt == null) { + return; + } + + player.stopSound(TRAINING_MUSIC_SOUND, SoundCategory.MUSIC); + stopBridgeVisuals(player); + restoreAttemptArenaState(attempt.type()); + clearAttempt(player, attempt.type()); + player.sendActionBar(Component.empty()); + + if (returnItemService != null) { + returnItemService.onPlayerLeaveTraining(player); + } + + if (message != null && !message.isBlank()) { + log.sendWarn(player, message); + } + } + + private void restoreAttemptArenaState(TrainingChallengeType type) { + if (type == null) { + return; + } + + if (type == TrainingChallengeType.PORTAL) { + clearNetherPortalBlocksInRegion(type); + refillTrackedPortalLava(type); + } + + ChallengeDefinition chestDefinition = null; + World chestWorld = null; + if (type == TrainingChallengeType.CHEST) { + chestDefinition = definitions.get(type); + if (chestDefinition != null) { + chestWorld = Bukkit.getWorld(chestDefinition.region().worldName()); + if (chestWorld != null) { + clearHopperInventories(chestDefinition, chestWorld); + } + } + } + + restoreArena(type); + + // Snapshot restoration can repopulate hopper inventory, so clear again + // afterwards. + if (type == TrainingChallengeType.CHEST && chestDefinition != null && chestWorld != null) { + clearHopperInventories(chestDefinition, chestWorld); + ChallengeDefinition finalChestDefinition = chestDefinition; + Bukkit.getScheduler() + .runTaskLater( + plugin, + () -> { + World liveWorld = Bukkit.getWorld( + finalChestDefinition.region().worldName()); + if (liveWorld != null) { + clearHopperInventories(finalChestDefinition, liveWorld); + } + }, + 2L); + } + } + + private void clearAttempt(Player player, TrainingChallengeType type) { + activeByPlayer.remove(player.getUniqueId()); + ActiveAttempt byChallenge = activeByChallenge.get(type); + if (byChallenge != null && byChallenge.playerId().equals(player.getUniqueId())) { + activeByChallenge.remove(type); + } + restoreStartButton(type); + } + + private Optional resolveChallengeByStartButton(Location location) { + for (Map.Entry entry : definitions.entrySet()) { + if (sameBlock(location, entry.getValue().startButton())) { + return Optional.of(entry.getKey()); + } + } + return Optional.empty(); + } + + private Optional resolveChallengeByLocation(Location location) { + List> matches = definitions.entrySet().stream() + .filter(entry -> entry.getValue().containsSidebarLocation(location)) + .sorted(Comparator.comparing(entry -> entry.getKey().ordinal())) + .toList(); + if (matches.isEmpty()) { + return Optional.empty(); + } + return Optional.of(matches.get(0).getKey()); + } + + private boolean isInTrainingWorld(Player player) { + World world = player.getWorld(); + return world != null && world.getName().equalsIgnoreCase(trainingWorldName); + } + + private ArenaSnapshot snapshotRegion(Cuboid cuboid) { + World world = Bukkit.getWorld(cuboid.worldName()); + if (world == null) { + return new ArenaSnapshot(cuboid.worldName(), Map.of()); + } + + Map blocks = new LinkedHashMap<>(); + for (int x = cuboid.minX(); x <= cuboid.maxX(); x++) { + for (int y = cuboid.minY(); y <= cuboid.maxY(); y++) { + for (int z = cuboid.minZ(); z <= cuboid.maxZ(); z++) { + Block block = world.getBlockAt(x, y, z); + BlockState state = block.getState(); + + ItemStack[] inventory = null; + if (state instanceof Container container) { + inventory = cloneContents(container.getInventory().getContents()); + } + + blocks.put( + new BlockKey(x, y, z), + new BlockSnapshot( + block.getType(), block.getBlockData().clone(), inventory)); + } + } + } + + return new ArenaSnapshot(cuboid.worldName(), blocks); + } + + private void restoreArena(TrainingChallengeType type) { + ArenaSnapshot snapshot = arenaSnapshots.get(type); + if (snapshot == null) { + return; + } + + World world = Bukkit.getWorld(snapshot.worldName()); + if (world == null) { + return; + } + + for (Map.Entry entry : snapshot.blocks().entrySet()) { + BlockKey key = entry.getKey(); + BlockSnapshot value = entry.getValue(); + Block block = world.getBlockAt(key.x(), key.y(), key.z()); + + // Prevent container contents from dropping as entities when the block is + // replaced. + BlockState currentState = block.getState(); + if (currentState instanceof Container currentContainer) { + currentContainer.getInventory().clear(); + currentContainer.update(true, false); + } + + block.setType(value.material(), false); + block.setBlockData(value.blockData().clone(), false); + + if (value.inventoryContents() != null) { + BlockState state = block.getState(); + if (state instanceof Container container) { + container.getInventory().setContents(cloneContents(value.inventoryContents())); + container.update(true, false); + } + } + } + } + + private ItemStack[] cloneContents(ItemStack[] original) { + ItemStack[] copy = new ItemStack[original.length]; + for (int i = 0; i < original.length; i++) { + copy[i] = original[i] == null ? null : original[i].clone(); + } + return copy; + } + + private void clearAttemptInventoryAndForceSurvival(Player player) { + if (player == null) { + return; + } + + PlayerInventory inventory = player.getInventory(); + inventory.clear(); + inventory.setArmorContents(new ItemStack[4]); + inventory.setExtraContents(new ItemStack[inventory.getExtraContents().length]); + player.setItemOnCursor(null); + player.setGameMode(org.bukkit.GameMode.SURVIVAL); + player.updateInventory(); + } + + private void hideStartButton(ChallengeDefinition definition, TrainingChallengeType type) { + if (definition == null || definition.startButton() == null) { + return; + } + + Location buttonLocation = definition.startButton(); + World world = buttonLocation.getWorld(); + if (world == null) { + return; + } + + Block block = world.getBlockAt(buttonLocation); + if (!hiddenStartButtonSnapshots.containsKey(type)) { + hiddenStartButtonSnapshots.put(type, snapshotBlock(block)); + } + + // Run next tick so the pressed-button state update cannot immediately reapply. + Bukkit.getScheduler().runTask(plugin, () -> { + if (!activeByChallenge.containsKey(type)) { + return; + } + + Block liveBlock = world.getBlockAt(buttonLocation); + liveBlock.setType(Material.AIR, false); + }); + } + + private void checkPortalAttemptCompletion() { + ActiveAttempt attempt = activeByChallenge.get(TrainingChallengeType.PORTAL); + if (attempt == null) { + return; + } + + ChallengeDefinition definition = definitions.get(TrainingChallengeType.PORTAL); + if (definition == null || !regionHasPortalBlocks(definition.region())) { + return; + } + + Player player = Bukkit.getPlayer(attempt.playerId()); + if (player != null) { + completeAttempt(player, attempt); + } + } + + private boolean regionHasPortalBlocks(Cuboid region) { + World world = Bukkit.getWorld(region.worldName()); + if (world == null) { + return false; + } + + for (int x = region.minX(); x <= region.maxX(); x++) { + for (int y = region.minY(); y <= region.maxY(); y++) { + for (int z = region.minZ(); z <= region.maxZ(); z++) { + if (world.getBlockAt(x, y, z).getType() == Material.NETHER_PORTAL) { + return true; + } + } + } + } + + return false; + } + + private Set snapshotLavaBlocks(Cuboid region) { + World world = Bukkit.getWorld(region.worldName()); + if (world == null) { + return Set.of(); + } + + Set lavaBlocks = new HashSet<>(); + + for (int x = region.minX(); x <= region.maxX(); x++) { + for (int y = region.minY(); y <= region.maxY(); y++) { + for (int z = region.minZ(); z <= region.maxZ(); z++) { + Block block = world.getBlockAt(x, y, z); + if (block.getType() == Material.LAVA) { + lavaBlocks.add(new BlockKey(x, y, z)); + } + } + } + } + + return lavaBlocks; + } + + private void resetAllChallengeArenas() { + for (Map.Entry entry : definitions.entrySet()) { + TrainingChallengeType type = entry.getKey(); + ChallengeDefinition definition = entry.getValue(); + + switch (type) { + case PORTAL -> clearNetherPortalBlocksInRegion(type); + case CHEST -> clearDynamicChestsInRegion(definition); + default -> {} + } + + arenaSnapshots.put(type, snapshotRegion(definition.region())); + + if (definition.startButton() != null && definition.startButton().getWorld() != null) { + Block btn = definition.startButton().getBlock(); + if (btn.getType() == Material.AIR) { + log.warn("Start button for challenge '" + type.key() + + "' is missing at " + formatBlockLocation(definition.startButton()) + + " — replace it manually."); + } else { + hiddenStartButtonSnapshots.put( + type, + new BlockSnapshot(btn.getType(), btn.getBlockData().clone(), null)); + } + } + } + } + + private void clearDynamicChestsInRegion(ChallengeDefinition definition) { + World world = Bukkit.getWorld(definition.region().worldName()); + if (world == null) { + return; + } + clearHopperInventories(definition, world); + Cuboid region = definition.region(); + for (int x = region.minX(); x <= region.maxX(); x++) { + for (int y = region.minY(); y <= region.maxY(); y++) { + for (int z = region.minZ(); z <= region.maxZ(); z++) { + Block block = world.getBlockAt(x, y, z); + if (block.getType() != Material.CHEST) { + continue; + } + Location loc = block.getLocation(); + if (definition.hopperLocation() != null && sameBlock(loc, definition.hopperLocation())) { + continue; + } + block.setType(Material.AIR, false); + } + } + } + } + + private void clearHopperInventories(ChallengeDefinition definition, World world) { + if (definition == null || world == null) { + return; + } + + Cuboid region = definition.region(); + for (int x = region.minX(); x <= region.maxX(); x++) { + for (int y = region.minY(); y <= region.maxY(); y++) { + for (int z = region.minZ(); z <= region.maxZ(); z++) { + Block block = world.getBlockAt(x, y, z); + if (block.getType() != Material.HOPPER) { + continue; + } + BlockState state = block.getState(); + if (state instanceof Container container) { + container.getInventory().clear(); + } + } + } + } + + Location hopperLocation = definition.hopperLocation(); + if (hopperLocation == null || hopperLocation.getWorld() == null) { + return; + } + + BlockState configuredHopper = hopperLocation.getBlock().getState(); + if (configuredHopper instanceof Container container) { + container.getInventory().clear(); + container.update(true, false); + } + } + + private static long packXZ(int x, int z) { + return ((long) x << 32) | (z & 0xFFFFFFFFL); + } + + private static String formatBlockLocation(Location loc) { + return loc.getBlockX() + "," + loc.getBlockY() + "," + loc.getBlockZ(); + } + + private void refillTrackedPortalLava(TrainingChallengeType type) { + Set trackedLava = trackedPortalLavaByChallenge.get(type); + if (trackedLava == null || trackedLava.isEmpty()) { + return; + } + + ChallengeDefinition definition = definitions.get(type); + if (definition == null) { + return; + } + + World world = Bukkit.getWorld(definition.region().worldName()); + if (world == null) { + return; + } + + for (BlockKey key : trackedLava) { + Block block = world.getBlockAt(key.x(), key.y(), key.z()); + if (block.getType() != Material.LAVA) { + block.setType(Material.LAVA, false); + } + } + } + + private void clearNetherPortalBlocksInRegion(TrainingChallengeType type) { + ChallengeDefinition definition = definitions.get(type); + if (definition == null) { + return; + } + + World world = Bukkit.getWorld(definition.region().worldName()); + if (world == null) { + return; + } + + Cuboid region = definition.region(); + for (int x = region.minX(); x <= region.maxX(); x++) { + for (int y = region.minY(); y <= region.maxY(); y++) { + for (int z = region.minZ(); z <= region.maxZ(); z++) { + Block block = world.getBlockAt(x, y, z); + if (block.getType() == Material.NETHER_PORTAL) { + block.setType(Material.AIR, false); + } + } + } + } + } + + private ChestObjective generateChestObjective() { + record LootEntry(Material material, int min, int max) {} + List pool = new ArrayList<>(List.of( + new LootEntry(Material.IRON_INGOT, 4, 10), + new LootEntry(Material.OBSIDIAN, 2, 6), + new LootEntry(Material.ENDER_PEARL, 3, 8), + new LootEntry(Material.BLAZE_ROD, 2, 5), + new LootEntry(Material.FLINT, 5, 12), + new LootEntry(Material.GOLD_INGOT, 3, 8), + new LootEntry(Material.LEATHER, 2, 6), + new LootEntry(Material.STRING, 4, 10))); + Collections.shuffle(pool, ThreadLocalRandom.current()); + int count = ThreadLocalRandom.current().nextInt(2, 5); + Map required = new LinkedHashMap<>(); + for (int i = 0; i < count && i < pool.size(); i++) { + LootEntry e = pool.get(i); + required.put(e.material(), ThreadLocalRandom.current().nextInt(e.min(), e.max() + 1)); + } + return new ChestObjective(required); + } + + private void spawnChestsWithLoot(ChallengeDefinition definition, ChestObjective objective) { + World world = Bukkit.getWorld(definition.region().worldName()); + if (world == null) { + return; + } + + if (definition.hopperLocation() == null) { + return; + } + + int floorY = definition.hopperLocation().getBlockY(); + int hopperX = definition.hopperLocation().getBlockX(); + int hopperZ = definition.hopperLocation().getBlockZ(); + Cuboid region = definition.region(); + + // Flood-fill from the air tiles around the hopper outward at floorY. + // Solid wall blocks stop the fill, so only positions inside the ring are found. + List validPositions = new ArrayList<>(); + Set visited = new HashSet<>(); + java.util.Queue queue = new java.util.ArrayDeque<>(); + int[][] startTiles = { + {hopperX + 1, hopperZ}, + {hopperX - 1, hopperZ}, + {hopperX, hopperZ + 1}, + {hopperX, hopperZ - 1} + }; + for (int[] startTile : startTiles) { + long key = packXZ(startTile[0], startTile[1]); + if (visited.add(key)) { + queue.add(startTile); + } + } + + while (!queue.isEmpty()) { + int[] pos = queue.poll(); + int cx = pos[0]; + int cz = pos[1]; + + if (cx < region.minX() || cx > region.maxX() || cz < region.minZ() || cz > region.maxZ()) { + continue; + } + + if (!world.getBlockAt(cx, floorY, cz).getType().isAir()) { + continue; + } + + if (cx != hopperX || cz != hopperZ) { + validPositions.add(new int[] {cx, floorY, cz}); + } + + int[][] neighbours = {{cx + 1, cz}, {cx - 1, cz}, {cx, cz + 1}, {cx, cz - 1}}; + for (int[] n : neighbours) { + long key = packXZ(n[0], n[1]); + if (visited.add(key)) { + queue.add(n); + } + } + } + + if (validPositions.isEmpty()) { + return; + } + + int numChests = ThreadLocalRandom.current().nextInt(definition.minChests(), definition.maxChests() + 1); + numChests = Math.min(numChests, validPositions.size()); + Collections.shuffle(validPositions, ThreadLocalRandom.current()); + + Material[] fillerPool = { + Material.DIRT, Material.GRAVEL, Material.ROTTEN_FLESH, Material.ARROW, + Material.BONE, Material.COBBLESTONE, Material.SAND, Material.OAK_LOG, + Material.WHEAT, Material.GUNPOWDER + }; + + List requiredStacks = new ArrayList<>(); + for (Map.Entry entry : objective.requiredItems().entrySet()) { + requiredStacks.add(new ItemStack(entry.getKey(), entry.getValue())); + } + Collections.shuffle(requiredStacks, ThreadLocalRandom.current()); + + List chestInventories = new ArrayList<>(); + for (int i = 0; i < numChests; i++) { + int[] pos = validPositions.get(i); + Block block = world.getBlockAt(pos[0], pos[1], pos[2]); + block.setType(Material.CHEST, false); + BlockState state = block.getState(); + if (state instanceof Container container) { + chestInventories.add(container.getInventory()); + } + } + + for (int i = 0; i < requiredStacks.size() && !chestInventories.isEmpty(); i++) { + Inventory chestInventory = chestInventories.get(i % chestInventories.size()); + placeStacksRandomly(chestInventory, splitStackSometimes(requiredStacks.get(i)), Set.of()); + } + + for (Inventory inv : chestInventories) { + int fillerCount = ThreadLocalRandom.current().nextInt(2, 6); + List fillerStacks = new ArrayList<>(); + for (int i = 0; i < fillerCount; i++) { + Material filler = fillerPool[ThreadLocalRandom.current().nextInt(fillerPool.length)]; + fillerStacks.add( + new ItemStack(filler, ThreadLocalRandom.current().nextInt(1, 17))); + } + placeStacksRandomly(inv, fillerStacks, Set.of()); + } + } + + private void addScatteredStacks(List dest, Material material, int total, ThreadLocalRandom rng) { + int numStacks = total > 1 ? rng.nextInt(2, 4) : 1; + for (int i = 0; i < numStacks - 1 && total > 1; i++) { + int chunk = rng.nextInt(1, total); + dest.add(new ItemStack(material, chunk)); + total -= chunk; + } + dest.add(new ItemStack(material, total)); + } + + private List splitStackSometimes(ItemStack stack) { + if (stack == null + || stack.getAmount() <= 1 + || !ThreadLocalRandom.current().nextBoolean()) { + return List.of(stack == null ? null : stack.clone()); + } + + int totalAmount = stack.getAmount(); + int firstAmount = ThreadLocalRandom.current().nextInt(1, totalAmount); + ItemStack first = stack.clone(); + first.setAmount(firstAmount); + + ItemStack second = stack.clone(); + second.setAmount(totalAmount - firstAmount); + + return List.of(first, second); + } + + private void placeStacksRandomly(Inventory inventory, List stacks, Set excludedSlots) { + if (inventory == null || stacks == null || stacks.isEmpty()) { + return; + } + + List availableSlots = new ArrayList<>(); + for (int slot = 0; slot < inventory.getSize(); slot++) { + if (excludedSlots != null && excludedSlots.contains(slot)) { + continue; + } + ItemStack existing = inventory.getItem(slot); + if (existing != null && !existing.getType().isAir()) { + continue; + } + availableSlots.add(slot); + } + Collections.shuffle(availableSlots, ThreadLocalRandom.current()); + + int stackIndex = 0; + for (ItemStack stack : stacks) { + if (stack == null || stack.getType().isAir()) { + continue; + } + if (stackIndex >= availableSlots.size()) { + break; + } + inventory.setItem(availableSlots.get(stackIndex), stack); + stackIndex++; + } + } + + private static String formatMaterialName(Material material) { + String name = material.name().replace('_', ' ').toLowerCase(Locale.ROOT); + String[] words = name.split(" "); + StringBuilder sb = new StringBuilder(); + for (String word : words) { + if (!sb.isEmpty()) { + sb.append(' '); + } + sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + return sb.toString(); + } + + private int computeShiftClickMultiplier(ItemStack[] matrix) { + int min = Integer.MAX_VALUE; + for (ItemStack slot : matrix) { + if (slot != null && slot.getType() != Material.AIR) { + min = Math.min(min, slot.getAmount()); + } + } + return min == Integer.MAX_VALUE ? 1 : min; + } + + private void teleportToStartButton(Player player, TrainingChallengeType type) { + ChallengeDefinition definition = definitions.get(type); + Location destination = null; + if (definition != null && definition.startButton() != null) { + destination = definition.startButton().clone().add(0.5D, 0.0D, 0.5D); + } + + if (destination == null || destination.getWorld() == null) { + destination = resolveTrainingRespawnLocation(); + } + + if (destination != null && destination.getWorld() != null) { + player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN); + } + } + + private void playCompletionJingle(Player player) { + float[] pitches = {1.0f, 1.26f, 1.587f, 2.0f}; + int[] delayTicks = {0, 3, 6, 10}; + for (int i = 0; i < pitches.length; i++) { + final float pitch = pitches[i]; + Bukkit.getScheduler() + .runTaskLater( + plugin, + () -> player.playSound(player.getLocation(), Sound.BLOCK_NOTE_BLOCK_PLING, 1.0f, pitch), + delayTicks[i]); + } + } + + private void restoreStartButton(TrainingChallengeType type) { + BlockSnapshot snapshot = hiddenStartButtonSnapshots.remove(type); + if (snapshot == null) { + return; + } + + ChallengeDefinition definition = definitions.get(type); + if (definition == null + || definition.startButton() == null + || definition.startButton().getWorld() == null) { + return; + } + + Block block = definition.startButton().getWorld().getBlockAt(definition.startButton()); + block.setType(snapshot.material(), false); + block.setBlockData(snapshot.blockData().clone(), false); + } + + private BlockSnapshot snapshotBlock(Block block) { + BlockState state = block.getState(); + ItemStack[] inventory = null; + if (state instanceof Container container) { + inventory = cloneContents(container.getInventory().getContents()); + } + + return new BlockSnapshot(block.getType(), block.getBlockData().clone(), inventory); + } + + private List filterByPrefix(String typed, List options) { + String normalized = typed.toLowerCase(Locale.ROOT); + return options.stream() + .filter(option -> option.toLowerCase(Locale.ROOT).startsWith(normalized)) + .collect(Collectors.toList()); + } + + private String formatOptionalDuration(long durationMs) { + return durationMs < 0L ? "--:--.--" : formatDurationMmSsCc(durationMs); + } + + private String formatDurationMmSsCc(long durationMs) { + long clamped = Math.max(0L, durationMs); + long centiseconds = clamped / 10L; + long minutes = centiseconds / 6000L; + long seconds = (centiseconds / 100L) % 60L; + long cs = centiseconds % 100L; + return String.format(Locale.ROOT, "%02d:%02d.%02d", minutes, seconds, cs); + } + + private boolean sameBlock(Location a, Location b) { + if (a == null || b == null || a.getWorld() == null || b.getWorld() == null) { + return false; + } + return a.getWorld().getName().equalsIgnoreCase(b.getWorld().getName()) + && a.getBlockX() == b.getBlockX() + && a.getBlockY() == b.getBlockY() + && a.getBlockZ() == b.getBlockZ(); + } + + private Optional readLocation(FileConfiguration config, String path) { + ConfigurationSection section = config.getConfigurationSection(path); + if (section == null) { + return Optional.empty(); + } + + String worldName = section.getString("world", trainingWorldName); + World world = worldName == null ? null : Bukkit.getWorld(worldName); + if (world == null) { + return Optional.empty(); + } + + double x = section.getDouble("x"); + double y = section.getDouble("y"); + double z = section.getDouble("z"); + float yaw = (float) section.getDouble("yaw", 0.0); + float pitch = (float) section.getDouble("pitch", 0.0); + return Optional.of(new Location(world, x, y, z, yaw, pitch)); + } + + private Optional readCuboid(FileConfiguration config, String path) { + ConfigurationSection section = config.getConfigurationSection(path); + if (section == null) { + return Optional.empty(); + } + + String worldName = section.getString("world", trainingWorldName); + if (worldName == null || Bukkit.getWorld(worldName) == null) { + return Optional.empty(); + } + + ConfigurationSection minSection = section.getConfigurationSection("min"); + ConfigurationSection maxSection = section.getConfigurationSection("max"); + if (minSection == null || maxSection == null) { + return Optional.empty(); + } + + int minX = minSection.getInt("x"); + int minY = minSection.getInt("y"); + int minZ = minSection.getInt("z"); + int maxX = maxSection.getInt("x"); + int maxY = maxSection.getInt("y"); + int maxZ = maxSection.getInt("z"); + + return Optional.of(new Cuboid(worldName, minX, minY, minZ, maxX, maxY, maxZ)); + } + + private List readBridgePlatforms(FileConfiguration config, String basePath) { + ConfigurationSection section = config.getConfigurationSection(basePath + ".platforms"); + if (section == null) { + return List.of(); + } + List platforms = new ArrayList<>(); + for (String key : section.getKeys(false)) { + String path = basePath + ".platforms." + key; + Optional spawn = readLocation(config, path + ".spawn"); + Optional plate = readLocation(config, path + ".plate"); + if (spawn.isPresent() && plate.isPresent()) { + platforms.add(new BridgePlatform(spawn.get(), plate.get())); + } else { + log.warn("Bridge platform '" + key + "' missing spawn or plate location — skipped."); + } + } + return Collections.unmodifiableList(platforms); + } + + private void applyBlankSidebarNumberFormat(Objective objective) { + if (objective == null) { + return; + } + + try { + Class numberFormatClass = Class.forName("org.bukkit.scoreboard.NumberFormat"); + Object blankFormat = numberFormatClass.getMethod("blank").invoke(null); + objective.getClass().getMethod("setNumberFormat", numberFormatClass).invoke(objective, blankFormat); + } catch (ReflectiveOperationException ignored) { + // Older APIs may not support objective number formatting. + } + } + + private boolean isBedMaterial(Material material) { + return material.name().endsWith("_BED"); + } + + private boolean isMetalAxe(Material material) { + return Set.of(Material.IRON_AXE, Material.GOLDEN_AXE, Material.DIAMOND_AXE, Material.NETHERITE_AXE) + .contains(material); + } + + private boolean isMetalShovel(Material material) { + return Set.of(Material.IRON_SHOVEL, Material.GOLDEN_SHOVEL, Material.DIAMOND_SHOVEL, Material.NETHERITE_SHOVEL) + .contains(material); + } + + private static final class ChestObjective { + private final Map requiredItems; + + private ChestObjective(Map requiredItems) { + this.requiredItems = requiredItems; + } + + private Map requiredItems() { + return requiredItems; + } + + private boolean isHopperComplete(Inventory hopperInventory) { + for (Map.Entry entry : requiredItems.entrySet()) { + int found = 0; + for (ItemStack item : hopperInventory.getContents()) { + if (item != null && item.getType() == entry.getKey()) { + found += item.getAmount(); + } + } + if (found < entry.getValue()) { + return false; + } + } + return true; + } + + private String requiredItemsText() { + List parts = new ArrayList<>(); + for (Map.Entry entry : requiredItems.entrySet()) { + parts.add(entry.getValue() + "x " + formatMaterialName(entry.getKey())); + } + return String.join(", ", parts); + } + + private String remainingText(Inventory hopperInventory) { + List parts = new ArrayList<>(); + for (Map.Entry entry : requiredItems.entrySet()) { + int found = 0; + for (ItemStack item : hopperInventory.getContents()) { + if (item != null && item.getType() == entry.getKey()) { + found += item.getAmount(); + } + } + int remaining = Math.max(0, entry.getValue() - found); + if (remaining > 0) { + parts.add(remaining + "x " + formatMaterialName(entry.getKey())); + } + } + return parts.isEmpty() ? "All items deposited!" : String.join(", ", parts); + } + } + + private static final class CraftObjective { + private final boolean requireBeds; + private final boolean requireEyes; + private final boolean requireAxe; + private final boolean requireShovel; + private final int bedsTarget; + private final int eyesTarget; + private int craftedBeds; + private int craftedEyes; + private boolean craftedAxe; + private boolean craftedShovel; + + private CraftObjective( + boolean requireBeds, + boolean requireEyes, + boolean requireAxe, + boolean requireShovel, + int bedsTarget, + int eyesTarget) { + this.requireBeds = requireBeds; + this.requireEyes = requireEyes; + this.requireAxe = requireAxe; + this.requireShovel = requireShovel; + this.bedsTarget = bedsTarget; + this.eyesTarget = eyesTarget; + } + + private boolean isComplete() { + return (!requireBeds || craftedBeds >= bedsTarget) + && (!requireEyes || craftedEyes >= eyesTarget) + && (!requireAxe || craftedAxe) + && (!requireShovel || craftedShovel); + } + } + + private record ActiveAttempt( + UUID playerId, + TrainingChallengeType type, + long startedAtMillis, + CraftObjective craftObjective, + ChestObjective chestObjective, + Location bridgeDestinationPlate) {} + + private record BridgePlatform(Location spawnLocation, Location pressurePlate) {} + + private record ChallengeDefinition( + TrainingChallengeType type, + Cuboid region, + Cuboid sidebarRegion, + Location startButton, + Location startLocation, + Location completionPressurePlate, + Location hopperLocation, + int minChests, + int maxChests, + int minBeds, + int maxBeds, + int minEyes, + int maxEyes, + List bridgePlatforms) { + private ChallengeDefinition {} + + private boolean containsSidebarLocation(Location location) { + if (sidebarRegion != null) { + return sidebarRegion.contains(location); + } + return region.contains(location); + } + } + + private record Cuboid(String worldName, int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + private Cuboid { + int resolvedMinX = Math.min(minX, maxX); + int resolvedMinY = Math.min(minY, maxY); + int resolvedMinZ = Math.min(minZ, maxZ); + int resolvedMaxX = Math.max(minX, maxX); + int resolvedMaxY = Math.max(minY, maxY); + int resolvedMaxZ = Math.max(minZ, maxZ); + + minX = resolvedMinX; + minY = resolvedMinY; + minZ = resolvedMinZ; + maxX = resolvedMaxX; + maxY = resolvedMaxY; + maxZ = resolvedMaxZ; + worldName = Objects.requireNonNull(worldName, "worldName"); + } + + private boolean contains(Location location) { + if (location == null || location.getWorld() == null) { + return false; + } + + if (!location.getWorld().getName().equalsIgnoreCase(worldName)) { + return false; + } + + int x = location.getBlockX(); + int y = location.getBlockY(); + int z = location.getBlockZ(); + return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ; + } + } + + private record ArenaSnapshot(String worldName, Map blocks) {} + + private record BlockKey(int x, int y, int z) {} + + private record BlockSnapshot( + Material material, org.bukkit.block.data.BlockData blockData, ItemStack[] inventoryContents) {} +} diff --git a/src/main/java/dev/deepcore/challenge/training/TrainingReturnItemService.java b/src/main/java/dev/deepcore/challenge/training/TrainingReturnItemService.java new file mode 100644 index 0000000..cb23c86 --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/training/TrainingReturnItemService.java @@ -0,0 +1,266 @@ +package dev.deepcore.challenge.training; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +/** + * Manages the return-to-lobby hotbar item for training world players. + */ +public final class TrainingReturnItemService implements Listener { + static final int RETURN_ITEM_SLOT = 8; // 9th slot (0-indexed) + private static final Material RETURN_ITEM_MATERIAL = Material.FEATHER; + private static final String RETURN_ITEM_DISPLAY_NAME = ChatColor.AQUA + "Return to Lobby"; + private static final String RETURN_ITEM_MARKER = "training_return_item"; + + private final JavaPlugin plugin; + private final TrainingManager trainingManager; + private final NamespacedKey returnItemKey; + private final Set playersInTraining = new HashSet<>(); + private BukkitTask restoreTask; + + /** + * Creates a training return item service. + * + * @param plugin plugin instance for scheduling and events + * @param trainingManager training manager to call return-to-lobby action + * @param returnItemKey namespaced key for marking return items + */ + public TrainingReturnItemService(JavaPlugin plugin, TrainingManager trainingManager, NamespacedKey returnItemKey) { + this.plugin = plugin; + this.trainingManager = trainingManager; + this.returnItemKey = returnItemKey; + } + + /** + * Registers event listeners and starts the restore task. + */ + public void initialize() { + plugin.getServer().getPluginManager().registerEvents(this, plugin); + startRestoreTask(); + } + + /** + * Stops the restore task and cleans up. + */ + public void shutdown() { + if (restoreTask != null) { + restoreTask.cancel(); + restoreTask = null; + } + playersInTraining.clear(); + } + + /** + * Marks a player as being in training and provides them with the return item. + * + * @param player player entering training + */ + public void onPlayerEnterTraining(Player player) { + playersInTraining.add(player.getUniqueId()); + giveReturnItem(player); + } + + /** + * Unmarked a player as being in training and removes the return item. + * + * @param player player leaving training + */ + public void onPlayerLeaveTraining(Player player) { + playersInTraining.remove(player.getUniqueId()); + removeReturnItem(player); + } + + /** + * Handles right-click interactions with the return item. + * + * @param event interact event to check + */ + @EventHandler(priority = EventPriority.LOW) + public void onReturnItemClick(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) { + return; + } + + ItemStack item = event.getItem(); + if (item == null || !isReturnItem(item)) { + return; + } + + event.setCancelled(true); + trainingManager.leaveTraining(event.getPlayer()); + } + + /** + * Prevents dropping the return item. + * + * @param event drop event to check + */ + @EventHandler(priority = EventPriority.HIGHEST) + public void onReturnItemDrop(PlayerDropItemEvent event) { + if (!isReturnItem(event.getItemDrop().getItemStack())) { + return; + } + + event.setCancelled(true); + event.getPlayer().updateInventory(); + } + + /** + * Prevents moving the return item in inventory. + * + * @param event inventory click event to check + */ + @EventHandler(priority = EventPriority.HIGHEST) + public void onReturnItemInventoryClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + ItemStack currentItem = event.getCurrentItem(); + ItemStack cursor = event.getCursor(); + int hotbarButton = event.getHotbarButton(); + + boolean isCurrentItemReturn = isReturnItem(currentItem); + boolean isCursorReturn = isReturnItem(cursor); + boolean isHotbarReturn = + hotbarButton >= 0 && isReturnItem(player.getInventory().getItem(hotbarButton)); + + if (isCurrentItemReturn || isCursorReturn || isHotbarReturn) { + event.setCancelled(true); + player.updateInventory(); + } + } + + /** + * Prevents dragging the return item in inventory. + * + * @param event inventory drag event to check + */ + @EventHandler(priority = EventPriority.HIGHEST) + public void onReturnItemInventoryDrag(InventoryDragEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + if (isReturnItem(event.getOldCursor())) { + event.setCancelled(true); + player.updateInventory(); + return; + } + + for (ItemStack item : event.getNewItems().values()) { + if (isReturnItem(item)) { + event.setCancelled(true); + player.updateInventory(); + return; + } + } + } + + private void giveReturnItem(Player player) { + PlayerInventory inventory = player.getInventory(); + ItemStack existing = inventory.getItem(RETURN_ITEM_SLOT); + + if (isReturnItem(existing)) { + return; + } + + ItemStack returnItem = createReturnItem(); + if (existing != null && !existing.getType().isAir()) { + player.getWorld().dropItemNaturally(player.getLocation(), existing.clone()); + } + + inventory.setItem(RETURN_ITEM_SLOT, returnItem); + player.updateInventory(); + } + + private void removeReturnItem(Player player) { + PlayerInventory inventory = player.getInventory(); + ItemStack item = inventory.getItem(RETURN_ITEM_SLOT); + + if (isReturnItem(item)) { + inventory.setItem(RETURN_ITEM_SLOT, null); + player.updateInventory(); + } + } + + private ItemStack createReturnItem() { + ItemStack item = new ItemStack(RETURN_ITEM_MATERIAL); + ItemMeta meta = item.getItemMeta(); + + if (meta != null) { + meta.setDisplayName(RETURN_ITEM_DISPLAY_NAME); + meta.setLore(List.of(ChatColor.GRAY + "Right-click to return to lobby")); + meta.getPersistentDataContainer().set(returnItemKey, PersistentDataType.BYTE, (byte) 1); + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES); + item.setItemMeta(meta); + } + + return item; + } + + boolean isReturnItem(ItemStack item) { + if (item == null || item.getType() != RETURN_ITEM_MATERIAL) { + return false; + } + + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return false; + } + + Byte marker = meta.getPersistentDataContainer().get(returnItemKey, PersistentDataType.BYTE); + return marker != null && marker == (byte) 1; + } + + private void startRestoreTask() { + if (restoreTask != null) { + restoreTask.cancel(); + } + + restoreTask = plugin.getServer() + .getScheduler() + .runTaskTimer( + plugin, + () -> { + for (UUID playerId : new HashSet<>(playersInTraining)) { + Player player = plugin.getServer().getPlayer(playerId); + if (player == null) { + playersInTraining.remove(playerId); + continue; + } + + if (trainingManager.isInActiveAttempt(player)) { + continue; + } + ItemStack item = player.getInventory().getItem(RETURN_ITEM_SLOT); + if (!isReturnItem(item)) { + giveReturnItem(player); + } + } + }, + 0L, + 20L); + } +} diff --git a/src/main/java/dev/deepcore/challenge/training/TrainingStatsStore.java b/src/main/java/dev/deepcore/challenge/training/TrainingStatsStore.java new file mode 100644 index 0000000..15fa061 --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/training/TrainingStatsStore.java @@ -0,0 +1,162 @@ +package dev.deepcore.challenge.training; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +/** + * Persists per-player training challenge times and recent attempt history. + */ +public final class TrainingStatsStore { + private static final int MAX_ATTEMPT_HISTORY = 5; + + private final File file; + private final Map> statsByPlayer; + + /** + * Creates a stats store rooted in the plugin data directory. + * + * @param plugin plugin instance owning this store + */ + public TrainingStatsStore(JavaPlugin plugin) { + this.file = new File(plugin.getDataFolder(), "training-stats.yml"); + this.statsByPlayer = new java.util.HashMap<>(); + } + + /** Loads all persisted stats into memory. */ + public void load() { + statsByPlayer.clear(); + if (!file.exists()) { + return; + } + + YamlConfiguration configuration = YamlConfiguration.loadConfiguration(file); + ConfigurationSection playersSection = configuration.getConfigurationSection("players"); + if (playersSection == null) { + return; + } + + for (String uuidKey : playersSection.getKeys(false)) { + UUID playerId; + try { + playerId = UUID.fromString(uuidKey); + } catch (IllegalArgumentException ignored) { + continue; + } + + ConfigurationSection playerSection = playersSection.getConfigurationSection(uuidKey); + if (playerSection == null) { + continue; + } + + Map byChallenge = new EnumMap<>(TrainingChallengeType.class); + for (TrainingChallengeType challengeType : TrainingChallengeType.values()) { + ConfigurationSection challengeSection = playerSection.getConfigurationSection(challengeType.key()); + if (challengeSection == null) { + continue; + } + + long bestTimeMs = challengeSection.getLong("best_time_ms", -1L); + List attempts = new ArrayList<>(); + for (Long value : challengeSection.getLongList("attempts_ms")) { + attempts.add(value); + if (attempts.size() >= MAX_ATTEMPT_HISTORY) { + break; + } + } + + byChallenge.put(challengeType, new PlayerChallengeStats(bestTimeMs, attempts)); + } + + if (!byChallenge.isEmpty()) { + statsByPlayer.put(playerId, byChallenge); + } + } + } + + /** Saves the in-memory stats map to disk. */ + public void save() { + YamlConfiguration configuration = new YamlConfiguration(); + for (Map.Entry> playerEntry : statsByPlayer.entrySet()) { + String basePath = "players." + playerEntry.getKey(); + for (Map.Entry challengeEntry : + playerEntry.getValue().entrySet()) { + String challengePath = basePath + "." + challengeEntry.getKey().key(); + PlayerChallengeStats stats = challengeEntry.getValue(); + configuration.set(challengePath + ".best_time_ms", stats.bestTimeMs()); + configuration.set(challengePath + ".attempts_ms", stats.lastAttemptsMs()); + } + } + + try { + configuration.save(file); + } catch (IOException ignored) { + // Caller handles operational logging; save failures should not crash plugin + // runtime. + } + } + + /** + * Records a completed attempt time for one player and challenge type. + * + * @param playerId player UUID + * @param type challenge type + * @param elapsedMs completed elapsed time in milliseconds + */ + public void recordCompletedAttempt(UUID playerId, TrainingChallengeType type, long elapsedMs) { + Map byChallenge = + statsByPlayer.computeIfAbsent(playerId, ignored -> new EnumMap<>(TrainingChallengeType.class)); + + PlayerChallengeStats current = byChallenge.get(type); + + List allTimes = new ArrayList<>(); + if (current != null && current.bestTimeMs() >= 0L) { + allTimes.add(current.bestTimeMs()); + } + if (current != null) { + allTimes.addAll(current.lastAttemptsMs()); + } + allTimes.add(elapsedMs); + Collections.sort(allTimes); + + long bestTime = allTimes.get(0); + List top5After = new ArrayList<>(allTimes.subList(1, Math.min(1 + MAX_ATTEMPT_HISTORY, allTimes.size()))); + byChallenge.put(type, new PlayerChallengeStats(bestTime, top5After)); + } + + /** + * Returns immutable stats snapshot for one player/challenge. + * + * @param playerId player UUID + * @param type challenge type + * @return stats snapshot; empty values when no attempts exist + */ + public PlayerChallengeStats getStats(UUID playerId, TrainingChallengeType type) { + Map byChallenge = statsByPlayer.get(playerId); + if (byChallenge == null) { + return new PlayerChallengeStats(-1L, List.of()); + } + PlayerChallengeStats stats = byChallenge.get(type); + return stats == null ? new PlayerChallengeStats(-1L, List.of()) : stats; + } + + /** + * Immutable per-player stats for one challenge. + * + * @param bestTimeMs best completed attempt in milliseconds; -1 when unknown + * @param lastAttemptsMs 2nd through 6th best times sorted ascending (Try 1–5) + */ + public record PlayerChallengeStats(long bestTimeMs, List lastAttemptsMs) { + public PlayerChallengeStats { + lastAttemptsMs = Collections.unmodifiableList(new ArrayList<>(lastAttemptsMs)); + } + } +} diff --git a/src/main/java/dev/deepcore/challenge/training/package-info.java b/src/main/java/dev/deepcore/challenge/training/package-info.java new file mode 100644 index 0000000..1048094 --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/training/package-info.java @@ -0,0 +1,2 @@ +/** Training mini-challenge types, manager, and supporting services. */ +package dev.deepcore.challenge.training; diff --git a/src/main/java/dev/deepcore/challenge/ui/PrepGuiRenderer.java b/src/main/java/dev/deepcore/challenge/ui/PrepGuiRenderer.java index de75419..0660287 100644 --- a/src/main/java/dev/deepcore/challenge/ui/PrepGuiRenderer.java +++ b/src/main/java/dev/deepcore/challenge/ui/PrepGuiRenderer.java @@ -76,6 +76,7 @@ public void populateCategoriesPage( previewEnabled ? "Create a new run world and refresh pedestal preview" : "Create a new run world")); + inventory.setItem(53, createInfoItem(Material.ENDER_PEARL, "Training World", "Teleport to the training gym")); } /** @@ -126,14 +127,6 @@ public void populateInventoryPage( Material.CLOCK, "Ready: " + readyCount + "/" + onlineCount, "Countdown starts automatically when all are ready")); - inventory.setItem( - 51, - createInfoItem( - Material.RECOVERY_COMPASS, - "Regenerate World", - previewEnabled - ? "Create a new run world and refresh pedestal preview" - : "Create a new run world")); } /** @@ -191,14 +184,6 @@ public void populateHealthPage( Material.CLOCK, "Ready: " + readyCount + "/" + onlineCount, "Countdown starts automatically when all are ready")); - inventory.setItem( - 51, - createInfoItem( - Material.RECOVERY_COMPASS, - "Regenerate World", - previewEnabled - ? "Create a new run world and refresh pedestal preview" - : "Create a new run world")); } /** @@ -256,7 +241,7 @@ public int populateRunHistoryPage( "Previous Page", safePage > 0 ? "Go to newer runs" : "You are on the first page")); inventory.setItem( - 51, + 52, createInfoItem( Material.ARROW, "Next Page", diff --git a/src/main/java/dev/deepcore/challenge/world/WorldClassificationService.java b/src/main/java/dev/deepcore/challenge/world/WorldClassificationService.java index 1f62565..bcf966a 100644 --- a/src/main/java/dev/deepcore/challenge/world/WorldClassificationService.java +++ b/src/main/java/dev/deepcore/challenge/world/WorldClassificationService.java @@ -40,4 +40,32 @@ public boolean isLobbyOrLimboWorld(World world) { || world.getName().equalsIgnoreCase(configuredLobbyOverworld) || world.getName().equalsIgnoreCase(configuredLobbyNether); } + + /** + * Returns whether the world is the configured training world. + * + * @param world world to classify + * @return true when this is the configured training world + */ + public boolean isTrainingWorld(World world) { + return world != null && world.getName().equalsIgnoreCase(configView.trainingWorldName()); + } + + /** + * Returns whether prep borders should be suppressed in the given world. + * + * @param world world to classify + * @return true when prep borders should not be shown + */ + public boolean isPrepBorderExemptWorld(World world) { + if (world == null) { + return false; + } + + if (isLobbyOrLimboWorld(world)) { + return true; + } + + return isTrainingWorld(world); + } } diff --git a/src/main/java/dev/deepcore/challenge/world/WorldResetManager.java b/src/main/java/dev/deepcore/challenge/world/WorldResetManager.java index 8536b52..ba3339d 100644 --- a/src/main/java/dev/deepcore/challenge/world/WorldResetManager.java +++ b/src/main/java/dev/deepcore/challenge/world/WorldResetManager.java @@ -12,6 +12,7 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Properties; import java.util.Set; import java.util.UUID; @@ -29,6 +30,7 @@ import org.bukkit.WorldCreator; import org.bukkit.WorldType; import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.BlockDisplay; import org.bukkit.entity.Entity; import org.bukkit.entity.ItemDisplay; @@ -51,6 +53,12 @@ public final class WorldResetManager { private static final String LIMBO_WORLD_NAME_PATH = "reset.limbo-world-name"; private static final String LOBBY_OVERWORLD_WORLD_NAME_PATH = "reset.lobby-overworld-world-name"; private static final String LOBBY_NETHER_WORLD_NAME_PATH = "reset.lobby-nether-world-name"; + private static final String TRAINING_WORLD_NAME_PATH = "training.world"; + private static final String PREVIEW_ANCHOR_X_PATH = "challenge.preview_hologram_anchor.x"; + private static final String PREVIEW_ANCHOR_Y_PATH = "challenge.preview_hologram_anchor.y"; + private static final String PREVIEW_ANCHOR_Z_PATH = "challenge.preview_hologram_anchor.z"; + private static final String PREVIEW_ANCHOR_ENABLED_PATH = "challenge.preview_hologram_anchor.enabled"; + private static final String PREVIEW_ANCHOR_WORLDS_PATH = "challenge.preview_hologram_anchor.worlds"; private static final String LOBBY_SPAWN_IN_LIMBO_PATH = "challenge.lobby_spawn_in_limbo_by_default"; private static final String DISCO_WORLD_CHANCE_PATH = "reset.disco-world-chance"; private static final String DISCO_BALL_TEXTURE_URL = @@ -146,6 +154,10 @@ public void cleanupNonDefaultWorldsOnStartup() { defaultWorlds.add(activeOverworld + "_nether"); defaultWorlds.add(activeOverworld + "_the_end"); defaultWorlds.addAll(getLobbyWorldNames()); + String trainingWorldName = resolveConfiguredTrainingWorldName(); + if (!trainingWorldName.isBlank()) { + defaultWorlds.add(trainingWorldName); + } for (World world : new ArrayList<>(Bukkit.getWorlds())) { String worldName = world.getName(); @@ -451,6 +463,89 @@ public World selectRandomLobbyWorld() { return selected; } + /** + * Sets the active lobby world by selector key. + * + * @param selector one of limbo, overworld, or nether + * @return selected world, or null when selector is invalid or world could not + * be loaded + */ + public World selectLobbyWorld(String selector) { + if (selector == null) { + return null; + } + + String normalized = selector.trim().toLowerCase(); + World selected = + switch (normalized) { + case "limbo" -> getOrCreateLimboWorld( + plugin.getConfig().getString(LIMBO_WORLD_NAME_PATH, "deepcore_limbo")); + case "overworld" -> getOrCreateLobbyOverworldWorld( + plugin.getConfig().getString(LOBBY_OVERWORLD_WORLD_NAME_PATH, "deepcore_lobby_overworld")); + case "nether" -> getOrCreateLobbyNetherWorld( + plugin.getConfig().getString(LOBBY_NETHER_WORLD_NAME_PATH, "deepcore_lobby_nether")); + default -> null; + }; + + if (selected != null) { + activeLobbyWorldId = selected.getUID(); + } + return selected; + } + + /** + * Teleports online players to the currently active lobby world spawn. + * + * @return count of players teleported + */ + public int teleportOnlinePlayersToActiveLobby() { + World activeLobby = getActiveLobbyWorld(); + if (activeLobby == null) { + return 0; + } + + Location lobbySpawn = resolveConfiguredLobbyArrivalLocation(activeLobby); + int teleported = 0; + for (Player player : Bukkit.getOnlinePlayers()) { + player.teleport(lobbySpawn); + teleported++; + } + return teleported; + } + + private Location resolveConfiguredLobbyArrivalLocation(World lobbyWorld) { + Location fallback = lobbyWorld.getSpawnLocation().clone().add(0.5D, 1.0D, 0.5D); + + ConfigurationSection worldAnchors = plugin.getConfig().getConfigurationSection(PREVIEW_ANCHOR_WORLDS_PATH); + if (worldAnchors != null) { + ConfigurationSection worldAnchor = worldAnchors.getConfigurationSection(lobbyWorld.getName()); + if (worldAnchor == null) { + worldAnchor = worldAnchors.getConfigurationSection( + lobbyWorld.getName().toLowerCase(Locale.ROOT)); + } + + if (worldAnchor != null) { + boolean hasCoords = worldAnchor.contains("x") || worldAnchor.contains("y") || worldAnchor.contains("z"); + boolean enabled = worldAnchor.getBoolean("enabled", hasCoords); + if (enabled) { + double x = worldAnchor.getDouble("x", fallback.getX()); + double y = worldAnchor.getDouble("y", fallback.getY()); + double z = worldAnchor.getDouble("z", fallback.getZ()); + return new Location(lobbyWorld, x, y, z); + } + } + } + + if (plugin.getConfig().getBoolean(PREVIEW_ANCHOR_ENABLED_PATH, false)) { + double x = plugin.getConfig().getDouble(PREVIEW_ANCHOR_X_PATH, fallback.getX()); + double y = plugin.getConfig().getDouble(PREVIEW_ANCHOR_Y_PATH, fallback.getY()); + double z = plugin.getConfig().getDouble(PREVIEW_ANCHOR_Z_PATH, fallback.getZ()); + return new Location(lobbyWorld, x, y, z); + } + + return fallback; + } + private World createOverworld(String worldName) { WorldCreator creator = new WorldCreator(worldName); creator.environment(World.Environment.NORMAL); @@ -706,6 +801,14 @@ private String resolveConfiguredOverworldName() { return "world"; } + private String resolveConfiguredTrainingWorldName() { + String configuredName = plugin.getConfig().getString(TRAINING_WORLD_NAME_PATH, "deepcore_gym"); + if (configuredName == null) { + return ""; + } + return configuredName.trim(); + } + private void ensureWorldStorageDirectories(World world) throws IOException { Path worldPath = world.getWorldFolder().toPath(); Files.createDirectories(worldPath); diff --git a/src/main/java/dev/deepcore/records/RunRecordsService.java b/src/main/java/dev/deepcore/records/RunRecordsService.java index 5fd7703..ca6f86b 100644 --- a/src/main/java/dev/deepcore/records/RunRecordsService.java +++ b/src/main/java/dev/deepcore/records/RunRecordsService.java @@ -41,7 +41,7 @@ public void initialize() { try { openConnection(); createTablesIfNotExist(); - log.info("RunRecordsService initialized successfully."); + log.debug("RunRecordsService initialized successfully."); } catch (SQLException e) { log.error("Failed to initialize RunRecordsService: " + e.getMessage(), e); } @@ -104,7 +104,7 @@ private void recreateRunRecordsTable() throws SQLException { stmt.executeUpdate("DROP TABLE IF EXISTS run_records"); } createRunRecordsTableIfMissing(); - log.info("run_records table recreated with current schema."); + log.debug("run_records table recreated with current schema."); } /** @@ -144,7 +144,7 @@ public RunRecord recordRun( pstmt.setString(8, participantsCsv); pstmt.executeUpdate(); - log.info(String.format( + log.debug(String.format( "Recorded team speedrun: %.2fs (Overworld→Nether: %.2fs, Nether→Blaze Rods: %.2fs, Blaze Rods→End: %.2fs, Nether→End: %.2fs, End→Dragon: %.2fs)", overallTimeMs / 1000.0, overworldToNetherMs / 1000.0, @@ -267,7 +267,7 @@ public void shutdown() { try { if (connection != null && !connection.isClosed()) { connection.close(); - log.info("RunRecordsService database connection closed."); + log.debug("RunRecordsService database connection closed."); } } catch (SQLException e) { log.error("Failed to close database connection: " + e.getMessage(), e); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index cbbefed..867b57e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -68,3 +68,153 @@ logging: # player-levels: # "b0f6f1f4-0000-4000-9000-abcdef123456": WARN player-levels: {} + +training: + enabled: true + world: deepcore_gym + lobby-spawn: + world: deepcore_gym + x: 0.5 + y: 65.0 + z: 0.5 + yaw: 0.0 + pitch: 0.0 + respawn-spawn: + world: deepcore_gym + x: 0.5 + y: 65.0 + z: 0.5 + yaw: 0.0 + pitch: 0.0 + craft: + beds: + min: 5 + max: 8 + eyes-of-ender: + min: 7 + max: 9 + sidebar-region: + world: deepcore_gym + min: + x: -17 + y: 5 + z: -90 + max: + x: -1 + y: 27 + z: -72 + sidebar-region: + world: deepcore_gym + min: + x: 6 + y: 58 + z: -30 + max: + x: 30 + y: 90 + z: -6 + sidebar-region: + world: deepcore_gym + min: + x: -30 + y: 58 + z: 6 + max: + x: -6 + y: 90 + z: 30 + sidebar-region: + world: deepcore_gym + min: + x: 6 + y: 58 + z: 6 + max: + x: 30 + y: 90 + z: 30 + challenges: + portal: + enabled: true + region: + world: deepcore_gym + min: { x: -30, y: 58, z: -30 } + max: { x: -6, y: 90, z: -6 } + start-button: + world: deepcore_gym + x: -18.0 + y: 65.0 + z: -31.0 + start-location: + world: deepcore_gym + x: -18.5 + y: 65.0 + z: -18.5 + yaw: 0.0 + pitch: 0.0 + craft: + enabled: true + region: + world: deepcore_gym + min: { x: 6, y: 58, z: -30 } + max: { x: 30, y: 90, z: -6 } + start-button: + world: deepcore_gym + x: 18.0 + y: 65.0 + z: -31.0 + start-location: + world: deepcore_gym + x: 18.5 + y: 65.0 + z: -18.5 + yaw: 180.0 + pitch: 0.0 + chest: + enabled: true + region: + world: deepcore_gym + min: { x: -30, y: 58, z: 6 } + max: { x: -6, y: 90, z: 30 } + start-button: + world: deepcore_gym + x: -18.0 + y: 65.0 + z: 5.0 + start-location: + world: deepcore_gym + x: -18.5 + y: 65.0 + z: 18.5 + yaw: 0.0 + pitch: 0.0 + hopper: + world: deepcore_gym + x: -18.0 + y: 65.0 + z: 18.0 + min-chests: 4 + max-chests: 8 + bridge: + enabled: true + region: + world: deepcore_gym + min: { x: 6, y: 58, z: 6 } + max: { x: 30, y: 90, z: 30 } + start-button: + world: deepcore_gym + x: 18.0 + y: 65.0 + z: 5.0 + start-location: + world: deepcore_gym + x: 18.5 + y: 65.0 + z: 18.5 + yaw: 180.0 + pitch: 0.0 + completion-pressure-plate: + world: deepcore_gym + x: 24.0 + y: 65.0 + z: 24.0 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 65cbaa1..5bbcb2c 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,13 +8,20 @@ description: Speedrun challenge toggles for Paper servers. commands: challenge: description: Manage speedrun challenge modes. - usage: "/challenge " + usage: "/challenge " permission: deepcore.challenge aliases: [ch] + lobby: + description: Return to the DeepCore lobby from the training gym. + usage: "/lobby" + permission: deepcore.challenge permissions: deepcore.challenge: - description: Allows managing challenge modes. + description: Allows using challenge command base and training gym commands. + default: true + deepcore.challenge.admin: + description: Allows managing challenge enable/mode/component settings. default: op deepcore.challenge.reset: description: Allows resetting overworld, nether, and end via limbo. diff --git a/src/test/java/dev/deepcore/DeepCorePluginTest.java b/src/test/java/dev/deepcore/DeepCorePluginTest.java index 029475b..f6a6c30 100644 --- a/src/test/java/dev/deepcore/DeepCorePluginTest.java +++ b/src/test/java/dev/deepcore/DeepCorePluginTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.never; @@ -16,6 +17,7 @@ import dev.deepcore.challenge.ChallengeManager; import dev.deepcore.challenge.ChallengeRuntime; import dev.deepcore.challenge.ChallengeRuntimeInitializer; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.logging.DeepCoreLogger; import java.lang.reflect.Field; import org.junit.jupiter.api.Test; @@ -42,7 +44,7 @@ void onEnable_initializesRuntimeAndLogger_onSuccess() { DeepCoreLogger logger = loggerConstruction.constructed().get(0); verify(logger).loadFromConfig(); - verify(logger).info("DeepCore enabled."); + verify(logger).info("DeepCore loaded!"); assertSame(logger, plugin.getDeepCoreLogger()); assertSame(challengeManager, plugin.getChallengeManager()); assertSame(runtime, getField(plugin, "challengeRuntime")); @@ -68,7 +70,7 @@ void onEnable_logsAndReturnsWhenRuntimeInitializationFails() { DeepCoreLogger logger = loggerConstruction.constructed().get(0); verify(logger).loadFromConfig(); verify(logger).error("init failed"); - verify(logger, never()).info("DeepCore enabled."); + verify(logger, never()).info("DeepCore loaded!"); assertNull(plugin.getChallengeManager()); } } @@ -81,17 +83,49 @@ void onDisable_persistsAndShutsDownRuntimeServicesWhenPresent() { dev.deepcore.challenge.ChallengeSessionManager sessionService = mock(dev.deepcore.challenge.ChallengeSessionManager.class); dev.deepcore.records.RunRecordsService recordsService = mock(dev.deepcore.records.RunRecordsService.class); + TrainingManager trainingManager = mock(TrainingManager.class); when(runtime.getChallengeManager()).thenReturn(challengeManager); when(runtime.getChallengeSessionManager()).thenReturn(sessionService); when(runtime.getRunRecordsService()).thenReturn(recordsService); + when(runtime.getTrainingManager()).thenReturn(trainingManager); + when(sessionService.isRunningPhase()).thenReturn(true); + when(sessionService.isPausedPhase()).thenReturn(false); setField(plugin, "challengeRuntime", runtime); plugin.onDisable(); + org.mockito.InOrder order = inOrder(challengeManager, trainingManager, sessionService, recordsService); verify(challengeManager).saveToConfig(); + order.verify(challengeManager).saveToConfig(); + order.verify(trainingManager).shutdown(); + order.verify(sessionService).endChallengeAndReturnToPrep(); + order.verify(sessionService).shutdown(); + order.verify(recordsService).shutdown(); + } + + @Test + void onDisable_doesNotForceEndWhenSessionNotRunningOrPaused() { + DeepCorePlugin plugin = mock(DeepCorePlugin.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + ChallengeRuntime runtime = mock(ChallengeRuntime.class); + ChallengeManager challengeManager = mock(ChallengeManager.class); + dev.deepcore.challenge.ChallengeSessionManager sessionService = + mock(dev.deepcore.challenge.ChallengeSessionManager.class); + dev.deepcore.records.RunRecordsService recordsService = mock(dev.deepcore.records.RunRecordsService.class); + TrainingManager trainingManager = mock(TrainingManager.class); + + when(runtime.getChallengeManager()).thenReturn(challengeManager); + when(runtime.getChallengeSessionManager()).thenReturn(sessionService); + when(runtime.getRunRecordsService()).thenReturn(recordsService); + when(runtime.getTrainingManager()).thenReturn(trainingManager); + when(sessionService.isRunningPhase()).thenReturn(false); + when(sessionService.isPausedPhase()).thenReturn(false); + + setField(plugin, "challengeRuntime", runtime); + plugin.onDisable(); + + verify(sessionService, never()).endChallengeAndReturnToPrep(); verify(sessionService).shutdown(); - verify(recordsService).shutdown(); } @Test diff --git a/src/test/java/dev/deepcore/challenge/ChallengeAdminFacadeTest.java b/src/test/java/dev/deepcore/challenge/ChallengeAdminFacadeTest.java index 1fba8ed..6f56958 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeAdminFacadeTest.java +++ b/src/test/java/dev/deepcore/challenge/ChallengeAdminFacadeTest.java @@ -11,8 +11,10 @@ import static org.mockito.Mockito.when; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; +import org.bukkit.World; import org.bukkit.command.CommandSender; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,6 +25,7 @@ class ChallengeAdminFacadeTest { private ChallengeManager challengeManager; private ChallengeSessionManager sessionManager; private WorldResetManager worldResetManager; + private TrainingManager trainingManager; private DeepCoreLogger logger; private ChallengeAdminFacade facade; @@ -32,8 +35,10 @@ void setUp() { challengeManager = mock(ChallengeManager.class); sessionManager = mock(ChallengeSessionManager.class); worldResetManager = mock(WorldResetManager.class); + trainingManager = mock(TrainingManager.class); logger = mock(DeepCoreLogger.class); - facade = new ChallengeAdminFacade(plugin, challengeManager, sessionManager, worldResetManager, logger); + facade = new ChallengeAdminFacade( + plugin, challengeManager, sessionManager, worldResetManager, trainingManager, logger); } @Test @@ -85,6 +90,7 @@ void reloadConfigAndApply_returnsFalseOutsidePrep_andTrueInPrep() { verify(plugin, times(2)).reloadConfig(); verify(logger, times(2)).loadFromConfig(); + verify(trainingManager, times(2)).reloadFromConfig(); verify(challengeManager).loadFromConfig(); verify(worldResetManager).ensureThreeWorldsLoaded(); verify(sessionManager).refreshLobbyPreview(); @@ -102,4 +108,26 @@ void pauseResumeAndQueryMethods_delegateToBackingServices() { facade.resetWorlds(sender); verify(worldResetManager).resetThreeWorlds(sender); } + + @Test + void selectLobbyWorld_delegatesToWorldResetManager() { + World world = mock(World.class); + when(world.getName()).thenReturn("deepcore_lobby_nether"); + when(worldResetManager.selectLobbyWorld("nether")).thenReturn(world); + + String selected = facade.selectLobbyWorld("nether"); + + org.junit.jupiter.api.Assertions.assertEquals("deepcore_lobby_nether", selected); + verify(worldResetManager).selectLobbyWorld("nether"); + } + + @Test + void teleportOnlinePlayersToActiveLobby_delegatesToWorldResetManager() { + when(worldResetManager.teleportOnlinePlayersToActiveLobby()).thenReturn(4); + + int teleported = facade.teleportOnlinePlayersToActiveLobby(); + + org.junit.jupiter.api.Assertions.assertEquals(4, teleported); + verify(worldResetManager).teleportOnlinePlayersToActiveLobby(); + } } diff --git a/src/test/java/dev/deepcore/challenge/ChallengeCommandTest.java b/src/test/java/dev/deepcore/challenge/ChallengeCommandTest.java index 36340e0..b7b807b 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeCommandTest.java +++ b/src/test/java/dev/deepcore/challenge/ChallengeCommandTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; import java.util.EnumMap; @@ -24,6 +25,7 @@ class ChallengeCommandTest { private ChallengeManager challengeManager; private ChallengeSessionManager sessionManager; private WorldResetManager worldResetManager; + private TrainingManager trainingManager; private DeepCoreLogger logger; private CommandSender sender; private ChallengeCommand commandHandler; @@ -34,6 +36,7 @@ void setUp() { challengeManager = mock(ChallengeManager.class); sessionManager = mock(ChallengeSessionManager.class); worldResetManager = mock(WorldResetManager.class); + trainingManager = mock(TrainingManager.class); logger = mock(DeepCoreLogger.class); sender = mock(CommandSender.class); when(plugin.getDeepCoreLogger()).thenReturn(logger); @@ -52,7 +55,8 @@ void setUp() { } when(challengeManager.getComponentToggles()).thenReturn(toggles); - commandHandler = new ChallengeCommand(plugin, challengeManager, sessionManager, worldResetManager); + commandHandler = + new ChallengeCommand(plugin, challengeManager, sessionManager, worldResetManager, trainingManager); } @Test diff --git a/src/test/java/dev/deepcore/challenge/ChallengeCoreCommandHandlerTest.java b/src/test/java/dev/deepcore/challenge/ChallengeCoreCommandHandlerTest.java index b584778..5244f32 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeCoreCommandHandlerTest.java +++ b/src/test/java/dev/deepcore/challenge/ChallengeCoreCommandHandlerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.logging.DeepCoreLogger; import java.util.EnumMap; import java.util.List; @@ -20,6 +21,7 @@ class ChallengeCoreCommandHandlerTest { private ChallengeAdminFacade adminFacade; + private TrainingManager trainingManager; private DeepCoreLogger logger; private CommandSender sender; private ChallengeCoreCommandHandler handler; @@ -27,9 +29,10 @@ class ChallengeCoreCommandHandlerTest { @BeforeEach void setUp() { adminFacade = mock(ChallengeAdminFacade.class); + trainingManager = mock(TrainingManager.class); logger = mock(DeepCoreLogger.class); sender = mock(CommandSender.class); - handler = new ChallengeCoreCommandHandler(adminFacade, logger); + handler = new ChallengeCoreCommandHandler(adminFacade, trainingManager, logger); when(adminFacade.getMode()).thenReturn(ChallengeMode.KEEP_INVENTORY_UNLIMITED_DEATHS); when(adminFacade.getPhaseName()).thenReturn("prep"); @@ -60,6 +63,7 @@ void handle_emptyArgs_sendsStatus() { @Test void handle_enableWhenLocked_doesNotEnable() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); when(adminFacade.canEditSettings()).thenReturn(false); assertTrue(handler.handle(sender, new String[] {"enable"})); @@ -70,6 +74,7 @@ void handle_enableWhenLocked_doesNotEnable() { @Test void handle_modeUnknown_sendsUnknownMessage() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); when(adminFacade.canEditSettings()).thenReturn(true); assertTrue(handler.handle(sender, new String[] {"mode", "bad_mode"})); @@ -80,6 +85,7 @@ void handle_modeUnknown_sendsUnknownMessage() { @Test void handle_componentToggle_appliesOperation() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); when(adminFacade.canEditSettings()).thenReturn(true); when(adminFacade.isComponentEnabled(ChallengeComponent.SHARED_HEALTH)).thenReturn(true); @@ -108,6 +114,7 @@ void tabComplete_returnsExpectedTopLevelAndModeOptions() { @Test void handle_enable_disable_and_mode_valid_applyChanges() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); when(adminFacade.canEditSettings()).thenReturn(true); assertTrue(handler.handle(sender, new String[] {"enable"})); @@ -121,6 +128,7 @@ void handle_enable_disable_and_mode_valid_applyChanges() { @Test void handle_component_invalidAndReset_branchesAreHandled() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); when(adminFacade.canEditSettings()).thenReturn(true); assertTrue(handler.handle(sender, new String[] {"component", "bad_key", "on"})); @@ -230,6 +238,7 @@ void tabComplete_componentBranches_includeOperationsAndFallbackEmpty() { @Test void handle_list_and_modeWithoutArgument_showExpectedMessages() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); when(adminFacade.canEditSettings()).thenReturn(true); assertTrue(handler.handle(sender, new String[] {"list"})); @@ -241,6 +250,7 @@ void handle_list_and_modeWithoutArgument_showExpectedMessages() { @Test void handle_componentStatusUsageAndInvalidOperation_paths() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); when(adminFacade.canEditSettings()).thenReturn(true); assertTrue(handler.handle(sender, new String[] {"component"})); @@ -277,4 +287,67 @@ void tabComplete_componentSecondArg_offersControlTokensAndKeys() { assertTrue(options.contains("reset")); assertTrue(options.contains("shared_health")); } + + @Test + void handle_trainSubcommand_delegatesToTrainingManager() { + when(trainingManager.handleCommand(sender, new String[] {"train", "stats"})) + .thenReturn(true); + + assertTrue(handler.handle(sender, new String[] {"train", "stats"})); + + verify(trainingManager).handleCommand(sender, new String[] {"train", "stats"}); + } + + @Test + void handle_lobbySubcommand_setsLobbyWhenSelectorValid() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); + when(adminFacade.selectLobbyWorld("limbo")).thenReturn("deepcore_limbo"); + when(adminFacade.teleportOnlinePlayersToActiveLobby()).thenReturn(3); + + assertTrue(handler.handle(sender, new String[] {"lobby", "limbo"})); + + verify(adminFacade).selectLobbyWorld("limbo"); + verify(adminFacade).teleportOnlinePlayersToActiveLobby(); + verify(logger).sendInfo(any(CommandSender.class), contains("Active lobby world set to")); + verify(logger).sendInfo(any(CommandSender.class), contains("Teleported players to active lobby")); + } + + @Test + void handle_lobbySubcommand_reportsInvalidSelector() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); + when(adminFacade.selectLobbyWorld("bad")).thenReturn(null); + + assertTrue(handler.handle(sender, new String[] {"lobby", "bad"})); + + verify(logger).sendInfo(any(CommandSender.class), contains("Unknown lobby selector")); + verify(adminFacade, never()).teleportOnlinePlayersToActiveLobby(); + } + + @Test + void handle_lobbySubcommand_requiresAdminPermission() { + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(false); + + assertTrue(handler.handle(sender, new String[] {"lobby", "limbo"})); + + verify(adminFacade, never()).selectLobbyWorld(any()); + verify(logger).sendInfo(any(CommandSender.class), contains("do not have permission")); + } + + @Test + void tabComplete_trainBranch_delegatesToTrainingManager() { + when(trainingManager.tabComplete(new String[] {"train", "st"})).thenReturn(List.of("stats", "start")); + + List completion = handler.tabComplete(new String[] {"train", "st"}); + + assertTrue(completion.contains("stats")); + assertTrue(completion.contains("start")); + verify(trainingManager).tabComplete(new String[] {"train", "st"}); + } + + @Test + void tabComplete_lobbyBranch_suggestsLobbySelectors() { + List completion = handler.tabComplete(new String[] {"lobby", "n"}); + + assertTrue(completion.contains("nether")); + } } diff --git a/src/test/java/dev/deepcore/challenge/ChallengeRuntimeInitializerTest.java b/src/test/java/dev/deepcore/challenge/ChallengeRuntimeInitializerTest.java index 8b225ad..2560066 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeRuntimeInitializerTest.java +++ b/src/test/java/dev/deepcore/challenge/ChallengeRuntimeInitializerTest.java @@ -2,11 +2,13 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; import dev.deepcore.records.RunRecordsService; @@ -20,9 +22,11 @@ class ChallengeRuntimeInitializerTest { void initialize_wiresServicesAndRegistersChallengeCommand() { DeepCorePlugin plugin = mock(DeepCorePlugin.class); DeepCoreLogger logger = mock(DeepCoreLogger.class); - PluginCommand command = mock(PluginCommand.class); + PluginCommand challengeCommandBinding = mock(PluginCommand.class); + PluginCommand lobbyCommandBinding = mock(PluginCommand.class); - when(plugin.getCommand("challenge")).thenReturn(command); + when(plugin.getCommand("challenge")).thenReturn(challengeCommandBinding); + when(plugin.getCommand("lobby")).thenReturn(lobbyCommandBinding); when(plugin.getDeepCoreLogger()).thenReturn(logger); try (MockedConstruction managerConstruction = @@ -33,6 +37,8 @@ void initialize_wiresServicesAndRegistersChallengeCommand() { org.mockito.Mockito.mockConstruction(WorldResetManager.class); MockedConstruction recordsConstruction = org.mockito.Mockito.mockConstruction(RunRecordsService.class); + MockedConstruction trainingConstruction = + org.mockito.Mockito.mockConstruction(TrainingManager.class); MockedConstruction commandConstruction = org.mockito.Mockito.mockConstruction(ChallengeCommand.class)) { @@ -44,6 +50,7 @@ void initialize_wiresServicesAndRegistersChallengeCommand() { WorldResetManager worldResetManager = worldResetConstruction.constructed().get(0); RunRecordsService recordsService = recordsConstruction.constructed().get(0); + TrainingManager trainingManager = trainingConstruction.constructed().get(0); ChallengeCommand challengeCommand = commandConstruction.constructed().get(0); @@ -53,16 +60,20 @@ void initialize_wiresServicesAndRegistersChallengeCommand() { verify(sessionManager).setWorldResetManager(worldResetManager); verify(recordsService).initialize(); verify(sessionManager).setRecordsService(recordsService); + verify(trainingManager).initialize(); verify(sessionManager).registerEventListeners(); verify(sessionManager).initialize(); - verify(command).setExecutor(challengeCommand); - verify(command).setTabCompleter(challengeCommand); + verify(challengeCommandBinding).setExecutor(challengeCommand); + verify(challengeCommandBinding).setTabCompleter(challengeCommand); + verify(lobbyCommandBinding).setExecutor(any()); verify(logger).debug("Challenge command registered."); + verify(logger).debug("Lobby command registered."); assertSame(manager, runtime.getChallengeManager()); assertSame(sessionManager, runtime.getChallengeSessionManager()); assertSame(worldResetManager, runtime.getWorldResetManager()); assertSame(recordsService, runtime.getRunRecordsService()); + assertSame(trainingManager, runtime.getTrainingManager()); } } @@ -72,6 +83,7 @@ void initialize_throwsWhenChallengeCommandMissing() { DeepCoreLogger logger = mock(DeepCoreLogger.class); when(plugin.getCommand("challenge")).thenReturn(null); + when(plugin.getCommand("lobby")).thenReturn(mock(PluginCommand.class)); try (MockedConstruction managerConstruction = org.mockito.Mockito.mockConstruction(ChallengeManager.class); @@ -80,7 +92,33 @@ void initialize_throwsWhenChallengeCommandMissing() { MockedConstruction worldResetConstruction = org.mockito.Mockito.mockConstruction(WorldResetManager.class); MockedConstruction recordsConstruction = - org.mockito.Mockito.mockConstruction(RunRecordsService.class)) { + org.mockito.Mockito.mockConstruction(RunRecordsService.class); + MockedConstruction trainingConstruction = + org.mockito.Mockito.mockConstruction(TrainingManager.class)) { + + assertThrows( + IllegalStateException.class, () -> new ChallengeRuntimeInitializer().initialize(plugin, logger)); + } + } + + @Test + void initialize_throwsWhenLobbyCommandMissing() { + DeepCorePlugin plugin = mock(DeepCorePlugin.class); + DeepCoreLogger logger = mock(DeepCoreLogger.class); + + when(plugin.getCommand("challenge")).thenReturn(mock(PluginCommand.class)); + when(plugin.getCommand("lobby")).thenReturn(null); + + try (MockedConstruction managerConstruction = + org.mockito.Mockito.mockConstruction(ChallengeManager.class); + MockedConstruction sessionConstruction = + org.mockito.Mockito.mockConstruction(ChallengeSessionManager.class); + MockedConstruction worldResetConstruction = + org.mockito.Mockito.mockConstruction(WorldResetManager.class); + MockedConstruction recordsConstruction = + org.mockito.Mockito.mockConstruction(RunRecordsService.class); + MockedConstruction trainingConstruction = + org.mockito.Mockito.mockConstruction(TrainingManager.class)) { assertThrows( IllegalStateException.class, () -> new ChallengeRuntimeInitializer().initialize(plugin, logger)); diff --git a/src/test/java/dev/deepcore/challenge/LobbyCommandTest.java b/src/test/java/dev/deepcore/challenge/LobbyCommandTest.java new file mode 100644 index 0000000..8f8191e --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/LobbyCommandTest.java @@ -0,0 +1,61 @@ +package dev.deepcore.challenge; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.deepcore.challenge.training.TrainingManager; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class LobbyCommandTest { + + @Test + void onCommand_rejectsNonPlayers() { + TrainingManager trainingManager = mock(TrainingManager.class); + ChallengeSessionManager challengeSessionManager = mock(ChallengeSessionManager.class); + LobbyCommand commandHandler = new LobbyCommand(trainingManager, challengeSessionManager); + + CommandSender sender = mock(CommandSender.class); + boolean handled = commandHandler.onCommand(sender, mock(Command.class), "lobby", new String[0]); + + assertTrue(handled); + verify(sender).sendMessage("Only players can use /lobby."); + verify(trainingManager, never()).leaveTraining(org.mockito.ArgumentMatchers.any()); + } + + @Test + void onCommand_blocksDuringActiveChallenge() { + TrainingManager trainingManager = mock(TrainingManager.class); + ChallengeSessionManager challengeSessionManager = mock(ChallengeSessionManager.class); + when(challengeSessionManager.isRunningPhase()).thenReturn(true); + + LobbyCommand commandHandler = new LobbyCommand(trainingManager, challengeSessionManager); + Player player = mock(Player.class); + + boolean handled = commandHandler.onCommand(player, mock(Command.class), "lobby", new String[0]); + + assertTrue(handled); + verify(player).sendMessage(org.mockito.ArgumentMatchers.contains("You cannot use /lobby")); + verify(trainingManager, never()).leaveTraining(player); + } + + @Test + void onCommand_leavesTrainingWhenIdle() { + TrainingManager trainingManager = mock(TrainingManager.class); + ChallengeSessionManager challengeSessionManager = mock(ChallengeSessionManager.class); + when(challengeSessionManager.isRunningPhase()).thenReturn(false); + + LobbyCommand commandHandler = new LobbyCommand(trainingManager, challengeSessionManager); + Player player = mock(Player.class); + + boolean handled = commandHandler.onCommand(player, mock(Command.class), "lobby", new String[0]); + + assertTrue(handled); + verify(trainingManager).leaveTraining(player); + } +} diff --git a/src/test/java/dev/deepcore/challenge/config/ChallengeConfigViewTest.java b/src/test/java/dev/deepcore/challenge/config/ChallengeConfigViewTest.java index 5d28fdd..7c466db 100644 --- a/src/test/java/dev/deepcore/challenge/config/ChallengeConfigViewTest.java +++ b/src/test/java/dev/deepcore/challenge/config/ChallengeConfigViewTest.java @@ -27,6 +27,7 @@ void accessors_returnDefaultsWhenConfigKeysMissing() { assertEquals("deepcore_limbo", view.limboWorldName()); assertEquals("deepcore_lobby_overworld", view.lobbyOverworldWorldName()); assertEquals("deepcore_lobby_nether", view.lobbyNetherWorldName()); + assertEquals("deepcore_gym", view.trainingWorldName()); } @Test @@ -42,6 +43,7 @@ void accessors_returnConfiguredValuesWhenPresent() { config.set("reset.limbo-world-name", "limbo_custom"); config.set("reset.lobby-overworld-world-name", "lobby_custom_overworld"); config.set("reset.lobby-nether-world-name", "lobby_custom_nether"); + config.set("training.world", "training_custom"); ChallengeConfigView view = newView(config); @@ -55,6 +57,7 @@ void accessors_returnConfiguredValuesWhenPresent() { assertEquals("limbo_custom", view.limboWorldName()); assertEquals("lobby_custom_overworld", view.lobbyOverworldWorldName()); assertEquals("lobby_custom_nether", view.lobbyNetherWorldName()); + assertEquals("training_custom", view.trainingWorldName()); } private static ChallengeConfigView newView(YamlConfiguration config) { diff --git a/src/test/java/dev/deepcore/challenge/portal/PortalTransitCoordinatorServiceTest.java b/src/test/java/dev/deepcore/challenge/portal/PortalTransitCoordinatorServiceTest.java index c55abd7..e300f90 100644 --- a/src/test/java/dev/deepcore/challenge/portal/PortalTransitCoordinatorServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/portal/PortalTransitCoordinatorServiceTest.java @@ -185,7 +185,7 @@ void handlePlayerMove_runningPhaseDelegatesEndPortalTransit() { } @Test - void handlePlayerMove_nonRunningClampsOutsidePrepArea() { + void handlePlayerMove_nonRunningDoesNotClampOutsidePrepArea() { SessionState state = new SessionState(); state.setPhase(SessionState.Phase.PREP); @@ -208,7 +208,7 @@ void handlePlayerMove_nonRunningClampsOutsidePrepArea() { service.handlePlayerMove(event); - verify(event).setTo(clamped); + verify(event, never()).setTo(any(Location.class)); } @Test diff --git a/src/test/java/dev/deepcore/challenge/session/PrepAreaServiceTest.java b/src/test/java/dev/deepcore/challenge/session/PrepAreaServiceTest.java index 91419b3..0820580 100644 --- a/src/test/java/dev/deepcore/challenge/session/PrepAreaServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/PrepAreaServiceTest.java @@ -12,7 +12,6 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; -import org.bukkit.WorldBorder; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -50,16 +49,8 @@ void applyBorder_clearsInRunningOrLobby_andSetsBorderOtherwise() { service.applyBorder(player, false, w -> true); verify(player, times(2)).setWorldBorder(null); - Location spawn = new Location(world, 0.0D, 64.0D, 0.0D); - when(world.getSpawnLocation()).thenReturn(spawn); - WorldBorder border = mock(WorldBorder.class); - - try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { - bukkit.when(Bukkit::createWorldBorder).thenReturn(border); - service.applyBorder(player, false, w -> false); - verify(border).setSize(30.0D); - verify(player).setWorldBorder(border); - } + service.applyBorder(player, false, w -> false); + verify(player, times(3)).setWorldBorder(null); } @Test @@ -74,13 +65,12 @@ void applyBordersToOnlinePlayers_andClearBorders_iterateAllPlayers() { try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getOnlinePlayers).thenReturn(Set.of(p1, p2)); - bukkit.when(Bukkit::createWorldBorder).thenReturn(mock(WorldBorder.class)); service.applyBordersToOnlinePlayers(false, w -> false); service.clearBorders(); - verify(p1).setWorldBorder(null); - verify(p2).setWorldBorder(null); + verify(p1, times(2)).setWorldBorder(null); + verify(p2, times(2)).setWorldBorder(null); } } } diff --git a/src/test/java/dev/deepcore/challenge/session/PrepGuiCoordinatorServiceTest.java b/src/test/java/dev/deepcore/challenge/session/PrepGuiCoordinatorServiceTest.java index 7c8e6d6..ac64a6d 100644 --- a/src/test/java/dev/deepcore/challenge/session/PrepGuiCoordinatorServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/PrepGuiCoordinatorServiceTest.java @@ -117,7 +117,17 @@ void handlePrepGuiClick_executesReadyAndResetFlows() { return true; }) .when(f.prepGuiFlowService) - .handleClick(eq(player), eq(47), eq(PrepGuiPage.CATEGORIES), any(), any(), any(), any(), any(), any()); + .handleClick( + eq(player), + eq(47), + eq(PrepGuiPage.CATEGORIES), + any(), + any(), + any(), + any(), + any(), + any(), + any()); Inventory categoryInventory = mock(Inventory.class); Inventory historyInventory = mock(Inventory.class); @@ -215,7 +225,7 @@ void prepHandlers_coverGuardBranches_andPreviewDestroyingResetFlow() { f.service.handlePrepGuiClick(outOfRangeClick); verify(outOfRangeClick).setCancelled(true); verify(f.prepGuiFlowService, never()) - .handleClick(eq(player), eq(99), any(), any(), any(), any(), any(), any(), any()); + .handleClick(eq(player), eq(99), any(), any(), any(), any(), any(), any(), any(), any()); InventoryClickEvent resetClick = mock(InventoryClickEvent.class); InventoryView resetView = mock(InventoryView.class); @@ -235,7 +245,17 @@ void prepHandlers_coverGuardBranches_andPreviewDestroyingResetFlow() { return true; }) .when(f.prepGuiFlowService) - .handleClick(eq(player), eq(47), eq(PrepGuiPage.CATEGORIES), any(), any(), any(), any(), any(), any()); + .handleClick( + eq(player), + eq(47), + eq(PrepGuiPage.CATEGORIES), + any(), + any(), + any(), + any(), + any(), + any(), + any()); f.service.handlePrepGuiClick(resetClick); verify(f.log).sendWarn(player, "Preview destroy animation is already running."); diff --git a/src/test/java/dev/deepcore/challenge/session/PrepGuiFlowServiceTest.java b/src/test/java/dev/deepcore/challenge/session/PrepGuiFlowServiceTest.java index 5d18fd8..eeae599 100644 --- a/src/test/java/dev/deepcore/challenge/session/PrepGuiFlowServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/PrepGuiFlowServiceTest.java @@ -43,30 +43,98 @@ void categoriesNavigationAndReadyResetActions_areHandled() { Consumer open = mock(Consumer.class); Runnable close = mock(Runnable.class); Runnable reset = mock(Runnable.class); + Runnable trainingTeleport = mock(Runnable.class); assertTrue(service.handleClick( - player, 47, PrepGuiPage.CATEGORIES, history, readyToggle, refresh, open, close, reset)); + player, + 47, + PrepGuiPage.CATEGORIES, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); verify(readyToggle).run(); assertTrue(service.handleClick( - player, 51, PrepGuiPage.CATEGORIES, history, readyToggle, refresh, open, close, reset)); + player, + 51, + PrepGuiPage.CATEGORIES, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); verify(reset).run(); assertTrue(service.handleClick( - player, 24, PrepGuiPage.CATEGORIES, history, readyToggle, refresh, open, close, reset)); + player, + 53, + PrepGuiPage.CATEGORIES, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); + verify(trainingTeleport).run(); + + assertTrue(service.handleClick( + player, + 24, + PrepGuiPage.CATEGORIES, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); verify(open).accept(PrepGuiPage.HEALTH); assertTrue(service.handleClick( - player, 22, PrepGuiPage.CATEGORIES, history, readyToggle, refresh, open, close, reset)); + player, + 22, + PrepGuiPage.CATEGORIES, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); assertEquals(0, history.get(id)); verify(open).accept(PrepGuiPage.RUN_HISTORY); assertTrue(service.handleClick( - player, 20, PrepGuiPage.CATEGORIES, history, readyToggle, refresh, open, close, reset)); + player, + 20, + PrepGuiPage.CATEGORIES, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); verify(open).accept(PrepGuiPage.INVENTORY); assertTrue(service.handleClick( - player, 45, PrepGuiPage.CATEGORIES, history, readyToggle, refresh, open, close, reset)); + player, + 45, + PrepGuiPage.CATEGORIES, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); verify(close).run(); } @@ -86,36 +154,64 @@ void inventoryAndHealthToggleSlots_invokeSettingsAndRefresh() { Consumer open = mock(Consumer.class); Runnable close = mock(Runnable.class); Runnable reset = mock(Runnable.class); + Runnable trainingTeleport = mock(Runnable.class); assertTrue(service.handleClick( - player, 20, PrepGuiPage.INVENTORY, history, readyToggle, refresh, open, close, reset)); + player, + 20, + PrepGuiPage.INVENTORY, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); verify(settings).toggleComponent(ChallengeComponent.KEEP_INVENTORY); assertTrue(service.handleClick( - player, 22, PrepGuiPage.INVENTORY, history, readyToggle, refresh, open, close, reset)); + player, + 22, + PrepGuiPage.INVENTORY, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); verify(settings).toggleComponent(ChallengeComponent.SHARED_INVENTORY); assertTrue(service.handleClick( - player, 24, PrepGuiPage.INVENTORY, history, readyToggle, refresh, open, close, reset)); + player, + 24, + PrepGuiPage.INVENTORY, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); verify(settings).toggleComponent(ChallengeComponent.DEGRADING_INVENTORY); when(manager.isComponentEnabled(ChallengeComponent.HEALTH_REFILL)).thenReturn(false); when(manager.isComponentEnabled(ChallengeComponent.INITIAL_HALF_HEART)).thenReturn(true); - assertTrue( - service.handleClick(player, 20, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset)); + assertTrue(service.handleClick( + player, 20, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset, trainingTeleport)); verify(settings).setHealthRefill(true); - assertTrue( - service.handleClick(player, 22, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset)); + assertTrue(service.handleClick( + player, 22, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset, trainingTeleport)); verify(settings).toggleComponent(ChallengeComponent.SHARED_HEALTH); - assertTrue( - service.handleClick(player, 24, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset)); + assertTrue(service.handleClick( + player, 24, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset, trainingTeleport)); verify(settings).setInitialHalfHeart(false); - assertTrue( - service.handleClick(player, 31, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset)); + assertTrue(service.handleClick( + player, 31, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset, trainingTeleport)); verify(settings).toggleComponent(ChallengeComponent.HARDCORE); verify(refresh, org.mockito.Mockito.atLeastOnce()).run(); @@ -144,14 +240,33 @@ void runHistoryPaging_usesPrevAndNextGuards() { Consumer open = mock(Consumer.class); Runnable close = mock(Runnable.class); Runnable reset = mock(Runnable.class); + Runnable trainingTeleport = mock(Runnable.class); assertTrue(service.handleClick( - player, 47, PrepGuiPage.RUN_HISTORY, history, readyToggle, refresh, open, close, reset)); + player, + 47, + PrepGuiPage.RUN_HISTORY, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); assertEquals(0, history.get(id)); when(renderer.hasRunHistoryNextPage(0, 2)).thenReturn(true); assertTrue(service.handleClick( - player, 51, PrepGuiPage.RUN_HISTORY, history, readyToggle, refresh, open, close, reset)); + player, + 52, + PrepGuiPage.RUN_HISTORY, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport)); assertEquals(1, history.get(id)); verify(open, org.mockito.Mockito.atLeastOnce()).accept(PrepGuiPage.RUN_HISTORY); @@ -171,6 +286,7 @@ void returnsFalseWhenNoSlotActionMatches() { mock(Runnable.class), page -> {}, mock(Runnable.class), + mock(Runnable.class), mock(Runnable.class)); assertFalse(handled); diff --git a/src/test/java/dev/deepcore/challenge/session/PrepReadinessServiceTest.java b/src/test/java/dev/deepcore/challenge/session/PrepReadinessServiceTest.java index 8bd3af0..e2bb192 100644 --- a/src/test/java/dev/deepcore/challenge/session/PrepReadinessServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/PrepReadinessServiceTest.java @@ -217,6 +217,6 @@ void cancelCountdownIfNoPlayersOnline_resetsToPrepAndCancelsTimer() { org.junit.jupiter.api.Assertions.assertEquals(SessionState.Phase.PREP, sessionState.getPhase()); org.junit.jupiter.api.Assertions.assertTrue(participants.isEmpty()); verify(prepCountdownService).cancel(); - verify(log).info("Countdown canceled because all players left."); + verify(log).info("Countdown canceled - players left."); } } diff --git a/src/test/java/dev/deepcore/challenge/session/RunCompletionServiceTest.java b/src/test/java/dev/deepcore/challenge/session/RunCompletionServiceTest.java index e468c56..dd766e9 100644 --- a/src/test/java/dev/deepcore/challenge/session/RunCompletionServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/RunCompletionServiceTest.java @@ -120,8 +120,8 @@ void handleEntityDeath_dragonKillStartsReturnCountdownAndFallbackCompletes() { verify(runProgressService).markDragonKilled(any(Long.class)); verify(completionReturnService).start(anyInt(), any(), any(), any()); - verify(log).info("Challenge complete: Ender Dragon defeated!"); - verify(log).info("Returning to lobby in 10 seconds."); + verify(log).info("Victory! Ender Dragon defeated!"); + verify(log).info("Lobby in 10s..."); onComplete[0].run(); verify(fallback).run(); diff --git a/src/test/java/dev/deepcore/challenge/session/RunPauseResumeServiceTest.java b/src/test/java/dev/deepcore/challenge/session/RunPauseResumeServiceTest.java index 7522674..8c60f00 100644 --- a/src/test/java/dev/deepcore/challenge/session/RunPauseResumeServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/RunPauseResumeServiceTest.java @@ -172,7 +172,7 @@ void pause_snapshotsPlayersTransitionsPhaseAndCancelsDegradingTask() { verify(degradingTask).cancel(); verify(clearActionBar).run(); verify(prepArea).clearBorders(); - verify(log).info("Challenge paused by admin."); + verify(log).info("Run paused by admin"); } @Test @@ -248,7 +248,7 @@ void resume_restoresSnapshotsAndRestartsTasksWithComponentGate() { verify(clearPausedSnapshots).run(); verify(startActionBarTask).run(); verify(resumeDegradingTask).run(); - verify(log).info("Challenge resumed by host."); + verify(log).info("Run resumed by host"); state.setPhase(SessionState.Phase.PAUSED); boolean resumedSecond = service.resume(sender); diff --git a/src/test/java/dev/deepcore/challenge/session/RunStartServiceTest.java b/src/test/java/dev/deepcore/challenge/session/RunStartServiceTest.java index 0d4b09c..9df5013 100644 --- a/src/test/java/dev/deepcore/challenge/session/RunStartServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/RunStartServiceTest.java @@ -203,7 +203,7 @@ void startRun_transitionsAndLaunchesAllConfiguredSystems() { verify(syncHunger).run(); verify(applyHalfHeart).run(); - verify(log).info("DeepCore run started."); + verify(log).info("Run started!"); } } diff --git a/src/test/java/dev/deepcore/challenge/session/RunStatusServiceTest.java b/src/test/java/dev/deepcore/challenge/session/RunStatusServiceTest.java index 538e1a1..0790d88 100644 --- a/src/test/java/dev/deepcore/challenge/session/RunStatusServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/RunStatusServiceTest.java @@ -88,7 +88,7 @@ void tickProgressFromParticipants_updatesProgressAndBlazeObjective() { service.tickProgressFromParticipants(players, 2_000L, true); verify(progress).updateMilestonesFromParticipants(players, 2_000L); - verify(log).info(any()); + verify(log).debug(any()); } @Test @@ -100,7 +100,7 @@ void tickProgressFromParticipants_withoutSplit_doesNotLogObjectiveCompletion() { service.tickProgressFromParticipants(players, 2_100L, true); verify(progress).updateMilestonesFromParticipants(players, 2_100L); - verify(log, never()).info(any()); + verify(log, never()).debug(any()); } @Test diff --git a/src/test/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorServiceTest.java b/src/test/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorServiceTest.java index e70afda..0183c27 100644 --- a/src/test/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorServiceTest.java @@ -314,7 +314,7 @@ void resetAndShutdownAndEndChallenge_coverStateTransitions() { service.endChallengeAndReturnToPrep(); verify(resetManager).selectRandomLobbyWorld(); - verify(log).info("DeepCore is now back in prep mode."); + verify(log).info("Waiting for players..."); } } } diff --git a/src/test/java/dev/deepcore/challenge/training/TrainingChallengeTypeTest.java b/src/test/java/dev/deepcore/challenge/training/TrainingChallengeTypeTest.java new file mode 100644 index 0000000..bff3af4 --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/training/TrainingChallengeTypeTest.java @@ -0,0 +1,38 @@ +package dev.deepcore.challenge.training; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class TrainingChallengeTypeTest { + + @Test + void fromKey_isCaseInsensitiveForKnownValues() { + assertTrue(TrainingChallengeType.fromKey("portal").isPresent()); + assertTrue(TrainingChallengeType.fromKey("CRAFT").isPresent()); + assertTrue(TrainingChallengeType.fromKey("Chest").isPresent()); + assertTrue(TrainingChallengeType.fromKey("bRiDgE").isPresent()); + } + + @Test + void fromKey_returnsEmptyForUnknownValue() { + assertFalse(TrainingChallengeType.fromKey("unknown").isPresent()); + } + + @Test + void keyAndDisplayName_matchConfiguredValues() { + assertEquals("portal", TrainingChallengeType.PORTAL.key()); + assertEquals("Portal", TrainingChallengeType.PORTAL.displayName()); + + assertEquals("craft", TrainingChallengeType.CRAFT.key()); + assertEquals("Craft", TrainingChallengeType.CRAFT.displayName()); + + assertEquals("chest", TrainingChallengeType.CHEST.key()); + assertEquals("Chest", TrainingChallengeType.CHEST.displayName()); + + assertEquals("bridge", TrainingChallengeType.BRIDGE.key()); + assertEquals("Bridge", TrainingChallengeType.BRIDGE.displayName()); + } +} diff --git a/src/test/java/dev/deepcore/challenge/training/TrainingManagerTest.java b/src/test/java/dev/deepcore/challenge/training/TrainingManagerTest.java new file mode 100644 index 0000000..f54d01b --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/training/TrainingManagerTest.java @@ -0,0 +1,1372 @@ +package dev.deepcore.challenge.training; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import be.seeseemelk.mockbukkit.MockBukkit; +import be.seeseemelk.mockbukkit.ServerMock; +import be.seeseemelk.mockbukkit.entity.PlayerMock; +import dev.deepcore.DeepCorePlugin; +import dev.deepcore.logging.DeepCoreLogger; +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.Container; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.world.PortalCreateEvent; +import org.bukkit.inventory.CraftingInventory; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.Recipe; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.scoreboard.Objective; +import org.bukkit.scoreboard.Score; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.ScoreboardManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class TrainingManagerTest { + + private ServerMock server; + private DeepCoreLogger logger; + private DeepCorePlugin plugin; + private YamlConfiguration config; + private File dataFolder; + private World trainingWorld; + + @BeforeEach + void setUp() throws Exception { + server = MockBukkit.mock(); + trainingWorld = server.addSimpleWorld("deepcore_gym"); + server.addSimpleWorld("world"); + + logger = mock(DeepCoreLogger.class); + plugin = mock(DeepCorePlugin.class); + config = new YamlConfiguration(); + dataFolder = Files.createTempDirectory("training-manager-test").toFile(); + + when(plugin.getDeepCoreLogger()).thenReturn(logger); + when(plugin.getConfig()).thenReturn(config); + when(plugin.getServer()).thenReturn(Bukkit.getServer()); + when(plugin.getDataFolder()).thenReturn(dataFolder); + + configureBaseTrainingConfig(config, true); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void tabComplete_andNonPlayerPath_areHandled() { + TrainingManager manager = new TrainingManager(plugin); + + List firstArg = manager.tabComplete(new String[] {"train", "s"}); + List secondArg = manager.tabComplete(new String[] {"train", "stats", "c"}); + + assertTrue(firstArg.contains("start")); + assertTrue(firstArg.contains("stats")); + assertTrue(secondArg.contains("craft")); + + CommandSender sender = mock(CommandSender.class); + assertTrue(manager.handleCommand(sender, new String[] {"train"})); + verify(logger).sendWarn(sender, "Only players can use training commands."); + } + + @Test + void disabledTraining_rejectsPlayerCommand() { + configureBaseTrainingConfig(config, false); + TrainingManager manager = new TrainingManager(plugin); + + PlayerMock player = server.addPlayer("trainer-disabled"); + assertTrue(manager.handleCommand(player, new String[] {"train"})); + + verify(logger).sendWarn(player, "Training gym is currently disabled."); + } + + @Test + void crossingChallengeEntranceBlock_doesNotTeleportWithoutActiveAttempt() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + PlayerMock player = server.addPlayer("trainer-entrance"); + player.teleport(new Location(trainingWorld, -18, 65, -33)); + + PlayerMoveEvent moveEvent = new PlayerMoveEvent( + player, new Location(trainingWorld, -18, 65, -33), new Location(trainingWorld, -18, 65, -31)); + manager.onAttemptPlayerMove(moveEvent); + + Map activeByPlayer = getActiveByPlayer(manager); + assertFalse(activeByPlayer.containsKey(player.getUniqueId())); + assertEquals("deepcore_gym", player.getWorld().getName()); + assertEquals(-18.0D, player.getLocation().getX(), 0.01D); + assertEquals(65.0D, player.getLocation().getY(), 0.01D); + assertEquals(-33.0D, player.getLocation().getZ(), 0.01D); + } + + @Test + void playerRespawnInTrainingWorld_usesConfiguredTrainingRespawnSpawn() { + config.set("training.respawn-spawn.world", "deepcore_gym"); + config.set("training.respawn-spawn.x", 12.5D); + config.set("training.respawn-spawn.y", 70.0D); + config.set("training.respawn-spawn.z", -8.5D); + config.set("training.respawn-spawn.yaw", 90.0D); + config.set("training.respawn-spawn.pitch", 0.0D); + + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + PlayerMock player = server.addPlayer("trainer-respawn"); + player.teleport(new Location(trainingWorld, 0, 65, 0)); + + PlayerDeathEvent deathEvent = mock(PlayerDeathEvent.class); + when(deathEvent.getPlayer()).thenReturn(player); + manager.onPlayerDeath(deathEvent); + + PlayerRespawnEvent respawnEvent = mock(PlayerRespawnEvent.class); + when(respawnEvent.getPlayer()).thenReturn(player); + manager.onPlayerRespawn(respawnEvent); + + verify(respawnEvent) + .setRespawnLocation(argThat(location -> location != null + && location.getWorld() != null + && "deepcore_gym".equals(location.getWorld().getName()) + && Math.abs(location.getX() - 12.5D) < 0.001D + && Math.abs(location.getY() - 70.0D) < 0.001D + && Math.abs(location.getZ() - -8.5D) < 0.001D)); + } + + @Test + void shutdown_withScheduledTask_cancelsAndSaves() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + + org.bukkit.scheduler.BukkitTask task = mock(org.bukkit.scheduler.BukkitTask.class); + setField(manager, "hudTask", task); + manager.shutdown(); + + verify(task).cancel(); + assertTrue(dataFolder.exists()); + } + + @Test + void handleCommand_flowCoversLobbyStatsStartResetLeave() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + PlayerMock player = server.addPlayer("trainer-flow"); + player.teleport(new Location(server.getWorld("world"), 1, 65, 1)); + + assertTrue(manager.handleCommand(player, new String[] {"train"})); + assertTrue(player.getWorld().getName().equalsIgnoreCase("deepcore_gym")); + + assertTrue(manager.handleCommand(player, new String[] {"train", "stats"})); + assertTrue(manager.handleCommand(player, new String[] {"train", "start", "portal"})); + assertTrue(manager.handleCommand(player, new String[] {"train", "reset"})); + assertTrue(manager.handleCommand(player, new String[] {"train", "leave"})); + assertTrue(manager.handleCommand(player, new String[] {"train", "unknown"})); + + verify(logger, atLeastOnce()).sendInfo(any(Player.class), contains("Welcome to Training Gym")); + verify(logger, atLeastOnce()).sendWarn(any(Player.class), contains("Usage: /challenge train")); + + Object definition = getDefinition(manager, TrainingChallengeType.PORTAL); + assertNotNull(definition); + } + + @Test + void startAttempt_cancelAttempt_completeAttempt_andEventHandlers_coverCoreFlows() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + PlayerMock player = server.addPlayer("trainer-events"); + player.teleport(new Location(trainingWorld, 0, 65, 0)); + + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class}, + player, + TrainingChallengeType.PORTAL); + + Map activeByPlayer = getActiveByPlayer(manager); + assertFalse(activeByPlayer.isEmpty()); + + Object portalAttempt = activeByPlayer.get(player.getUniqueId()); + assertNotNull(portalAttempt); + + PlayerInteractEvent portalStartEvent = mock(PlayerInteractEvent.class); + when(portalStartEvent.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); + when(portalStartEvent.getClickedBlock()).thenReturn(trainingWorld.getBlockAt(-18, 65, -31)); + when(portalStartEvent.getPlayer()).thenReturn(player); + manager.onStartButtonPress(portalStartEvent); + verify(portalStartEvent).setCancelled(true); + + PortalCreateEvent portalCreateEvent = mock(PortalCreateEvent.class); + doReturn(List.of(trainingWorld.getBlockAt(-18, 65, -18).getState())) + .when(portalCreateEvent) + .getBlocks(); + manager.onPortalCreate(portalCreateEvent); + + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class}, + player, + TrainingChallengeType.BRIDGE); + + BlockPlaceEvent placeEvent = mock(BlockPlaceEvent.class); + when(placeEvent.getPlayer()).thenReturn(player); + when(placeEvent.getBlockPlaced()).thenReturn(trainingWorld.getBlockAt(100, 65, 100)); + manager.onBridgePlace(placeEvent); + verify(placeEvent).setCancelled(true); + + PlayerMoveEvent moveEvent = mock(PlayerMoveEvent.class); + when(moveEvent.getPlayer()).thenReturn(player); + when(moveEvent.getFrom()).thenReturn(new Location(trainingWorld, 18, 65, 18)); + when(moveEvent.getTo()).thenReturn(new Location(trainingWorld, 100, 65, 100)); + manager.onAttemptPlayerMove(moveEvent); + + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class}, + player, + TrainingChallengeType.CRAFT); + Object craftAttempt = getActiveByPlayer(manager).get(player.getUniqueId()); + + Object craftObjective = invoke(craftAttempt, "craftObjective", new Class[0]); + setField(craftObjective, "craftedAxe", true); + setField(craftObjective, "craftedShovel", true); + setField(craftObjective, "craftedBeds", 64); + setField(craftObjective, "craftedEyes", 64); + + org.bukkit.event.inventory.CraftItemEvent craftEvent = mock(org.bukkit.event.inventory.CraftItemEvent.class); + CraftingInventory craftingInventory = mock(CraftingInventory.class); + Recipe recipe = mock(Recipe.class); + when(craftEvent.getWhoClicked()).thenReturn(player); + when(craftEvent.getInventory()).thenReturn(craftingInventory); + when(craftEvent.getRecipe()).thenReturn(recipe); + when(recipe.getResult()).thenReturn(new ItemStack(Material.STICK, 1)); + manager.onCraftItem(craftEvent); + + PlayerTeleportEvent teleportEvent = mock(PlayerTeleportEvent.class); + when(teleportEvent.getPlayer()).thenReturn(player); + when(teleportEvent.getTo()).thenReturn(new Location(trainingWorld, 100, 65, 100)); + manager.onAttemptTeleport(teleportEvent); + + TrainingStatsStore statsStore = (TrainingStatsStore) getField(manager, "statsStore"); + TrainingStatsStore.PlayerChallengeStats stats = + statsStore.getStats(player.getUniqueId(), TrainingChallengeType.CRAFT); + assertTrue(stats.bestTimeMs() >= 0L || stats.bestTimeMs() == -1L); + + verify(logger, atLeastOnce()).sendInfo(any(Player.class), contains("challenge started")); + } + + @Test + void utilityMethods_coverFormattingAndMaterialChecks() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + + String formatted = (String) invoke(manager, "formatDurationMmSsCc", new Class[] {long.class}, 125_678L); + String optionalUnknown = (String) invoke(manager, "formatOptionalDuration", new Class[] {long.class}, -1L); + boolean sameBlock = (boolean) invoke( + manager, + "sameBlock", + new Class[] {Location.class, Location.class}, + new Location(trainingWorld, 1, 2, 3), + new Location(trainingWorld, 1, 2, 3)); + boolean bed = (boolean) invoke(manager, "isBedMaterial", new Class[] {Material.class}, Material.RED_BED); + boolean axe = (boolean) invoke(manager, "isMetalAxe", new Class[] {Material.class}, Material.IRON_AXE); + boolean shovel = + (boolean) invoke(manager, "isMetalShovel", new Class[] {Material.class}, Material.DIAMOND_SHOVEL); + + assertEquals("02:05.67", formatted); + assertEquals("--:--.--", optionalUnknown); + assertTrue(sameBlock); + assertTrue(bed); + assertTrue(axe); + assertTrue(shovel); + + ItemStack[] original = new ItemStack[] {new ItemStack(Material.STONE, 2), null}; + ItemStack[] clone = + (ItemStack[]) invoke(manager, "cloneContents", new Class[] {ItemStack[].class}, (Object) original); + assertNotNull(clone[0]); + assertTrue(clone[0] != original[0]); + } + + @Test + void chestObjectiveText_readsHopperInventoryState() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + @SuppressWarnings("unchecked") + Map definitions = + (Map) getField(manager, "definitions"); + + Class cuboidClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$Cuboid"); + java.lang.reflect.Constructor cuboidCtor = cuboidClass.getDeclaredConstructors()[0]; + cuboidCtor.setAccessible(true); + Object region = cuboidCtor.newInstance("deepcore_gym", 4, 65, 4, 6, 65, 6); + + Class definitionClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChallengeDefinition"); + java.lang.reflect.Constructor definitionCtor = definitionClass.getDeclaredConstructors()[0]; + definitionCtor.setAccessible(true); + Location hopperLocation = new Location(trainingWorld, 5, 65, 5); + Object chestDefinition = definitionCtor.newInstance( + TrainingChallengeType.CHEST, region, null, null, null, null, hopperLocation, 1, 1, 1, 1, 1, 1, null); + definitions.put(TrainingChallengeType.CHEST, chestDefinition); + trainingWorld.getBlockAt(hopperLocation).setType(Material.HOPPER); + + Class chestObjectiveClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChestObjective"); + java.lang.reflect.Constructor chestObjectiveCtor = + chestObjectiveClass.getDeclaredConstructors()[0]; + chestObjectiveCtor.setAccessible(true); + Object chestObjective = chestObjectiveCtor.newInstance(Map.of(Material.DIRT, 1)); + + Class activeAttemptClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ActiveAttempt"); + Class craftObjectiveClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$CraftObjective"); + java.lang.reflect.Constructor activeAttemptCtor = activeAttemptClass.getDeclaredConstructor( + UUID.class, + TrainingChallengeType.class, + long.class, + craftObjectiveClass, + chestObjectiveClass, + Location.class); + activeAttemptCtor.setAccessible(true); + Object attempt = activeAttemptCtor.newInstance( + UUID.randomUUID(), TrainingChallengeType.CHEST, System.currentTimeMillis(), null, chestObjective, null); + + String objectiveText = (String) invoke(manager, "objectiveText", new Class[] {activeAttemptClass}, attempt); + assertTrue(objectiveText.startsWith("Hopper:")); + } + + @Test + void tickHud_andBridgeCompletion_pathsAreCovered() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + PlayerMock player = server.addPlayer("trainer-hud"); + player.teleport(new Location(trainingWorld, 18, 65, 18)); + + setField(manager, "enabled", false); + invoke(manager, "tickHud", new Class[0]); + + setField(manager, "enabled", true); + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class}, + player, + TrainingChallengeType.BRIDGE); + invoke(manager, "tickHud", new Class[0]); + + PlayerInteractEvent event = mock(PlayerInteractEvent.class); + when(event.getAction()).thenReturn(Action.PHYSICAL); + when(event.getClickedBlock()).thenReturn(trainingWorld.getBlockAt(20, 65, 20)); + when(event.getPlayer()).thenReturn(player); + manager.onBridgeCompletionPlate(event); + + Map activeByPlayer = getActiveByPlayer(manager); + assertTrue(activeByPlayer.isEmpty()); + } + + @Test + void onChestClickAndDrag_scheduleChecks_whenAttemptIsChest() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + PlayerMock player = server.addPlayer("trainer-chest"); + player.teleport(new Location(trainingWorld, -18, 65, 18)); + trainingWorld.getBlockAt(-18, 65, 18).setType(Material.CHEST); + + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class}, + player, + TrainingChallengeType.CHEST); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask task = mock(BukkitTask.class); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + Runnable runnable = invocation.getArgument(1); + runnable.run(); + return task; + }); + + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + + org.bukkit.event.inventory.InventoryClickEvent clickEvent = + mock(org.bukkit.event.inventory.InventoryClickEvent.class); + when(clickEvent.getWhoClicked()).thenReturn(player); + manager.onChestClick(clickEvent); + + org.bukkit.event.inventory.InventoryDragEvent dragEvent = + mock(org.bukkit.event.inventory.InventoryDragEvent.class); + when(dragEvent.getWhoClicked()).thenReturn(player); + manager.onChestDrag(dragEvent); + } + + verify(logger, atLeastOnce()).sendInfo(any(Player.class), contains("challenge started")); + } + + @Test + void clearDynamicChestsInRegion_alsoClearsHopperInventory() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + Object region = createCuboid("deepcore_gym", -1, 65, -1, 1, 65, 1); + Class definitionClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChallengeDefinition"); + java.lang.reflect.Constructor definitionCtor = definitionClass.getDeclaredConstructors()[0]; + definitionCtor.setAccessible(true); + Object definition = definitionCtor.newInstance( + TrainingChallengeType.CHEST, + region, + null, + null, + null, + null, + new Location(trainingWorld, 0, 65, 0), + 1, + 1, + 1, + 1, + 1, + 1, + null); + + trainingWorld.getBlockAt(0, 65, 0).setType(Material.HOPPER); + Container hopper = (Container) trainingWorld.getBlockAt(0, 65, 0).getState(); + hopper.getInventory().setItem(0, new ItemStack(Material.BLAZE_ROD, 4)); + hopper.update(true, false); + + trainingWorld.getBlockAt(1, 65, 0).setType(Material.CHEST); + + invoke(manager, "clearDynamicChestsInRegion", new Class[] {definition.getClass()}, definition); + + Container clearedHopper = (Container) trainingWorld.getBlockAt(0, 65, 0).getState(); + assertTrue(clearedHopper.getInventory().isEmpty()); + assertEquals(Material.HOPPER, trainingWorld.getBlockAt(0, 65, 0).getType()); + assertEquals(Material.AIR, trainingWorld.getBlockAt(1, 65, 0).getType()); + } + + @Test + void clearDynamicChestsInRegion_clearsRegionHopperEvenWhenConfigHopperMissing() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + Class cuboidClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$Cuboid"); + java.lang.reflect.Constructor cuboidCtor = cuboidClass.getDeclaredConstructors()[0]; + cuboidCtor.setAccessible(true); + Object region = cuboidCtor.newInstance("deepcore_gym", -1, 65, -1, 1, 65, 1); + + Class definitionClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChallengeDefinition"); + java.lang.reflect.Constructor definitionCtor = definitionClass.getDeclaredConstructors()[0]; + definitionCtor.setAccessible(true); + Object definition = definitionCtor.newInstance( + TrainingChallengeType.CHEST, region, null, null, null, null, null, 1, 1, 1, 1, 1, 1, null); + + trainingWorld.getBlockAt(0, 65, 0).setType(Material.HOPPER); + Container hopper = (Container) trainingWorld.getBlockAt(0, 65, 0).getState(); + hopper.getInventory().setItem(0, new ItemStack(Material.STRING, 9)); + hopper.update(true, false); + + invoke(manager, "clearDynamicChestsInRegion", new Class[] {definitionClass}, definition); + + Container clearedHopper = (Container) trainingWorld.getBlockAt(0, 65, 0).getState(); + assertTrue(clearedHopper.getInventory().isEmpty()); + } + + @Test + void startAttempt_chestSnapshotDoesNotCaptureStaleHopperContents() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + Class cuboidClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$Cuboid"); + java.lang.reflect.Constructor cuboidCtor = cuboidClass.getDeclaredConstructors()[0]; + cuboidCtor.setAccessible(true); + Object region = cuboidCtor.newInstance("deepcore_gym", -1, 65, -1, 1, 65, 1); + + Class definitionClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChallengeDefinition"); + java.lang.reflect.Constructor definitionCtor = definitionClass.getDeclaredConstructors()[0]; + definitionCtor.setAccessible(true); + Object definition = definitionCtor.newInstance( + TrainingChallengeType.CHEST, + region, + null, + new Location(trainingWorld, 0, 65, -1), + new Location(trainingWorld, 0, 65, -1), + null, + new Location(trainingWorld, 0, 65, 0), + 1, + 1, + 1, + 1, + 1, + 1, + null); + + @SuppressWarnings("unchecked") + Map definitions = + (Map) getField(manager, "definitions"); + definitions.put(TrainingChallengeType.CHEST, definition); + + @SuppressWarnings("unchecked") + Map arenaSnapshots = + (Map) getField(manager, "arenaSnapshots"); + arenaSnapshots.clear(); + + trainingWorld.getBlockAt(0, 65, 0).setType(Material.HOPPER); + Container hopper = (Container) trainingWorld.getBlockAt(0, 65, 0).getState(); + hopper.getInventory().setItem(0, new ItemStack(Material.STRING, 9)); + hopper.update(true, false); + + PlayerMock player = server.addPlayer("trainer-snapshot"); + player.teleport(new Location(trainingWorld, 0, 65, 0)); + + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class, boolean.class}, + player, + TrainingChallengeType.CHEST, + false); + + invoke(manager, "cancelAttempt", new Class[] {Player.class, String.class}, player, "test"); + + Container clearedHopper = (Container) trainingWorld.getBlockAt(0, 65, 0).getState(); + assertTrue(clearedHopper.getInventory().isEmpty()); + } + + @Test + void restoreAttemptArenaState_chestClearsHopperAfterSnapshotRestore() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + Class cuboidClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$Cuboid"); + java.lang.reflect.Constructor cuboidCtor = cuboidClass.getDeclaredConstructors()[0]; + cuboidCtor.setAccessible(true); + Object region = cuboidCtor.newInstance("deepcore_gym", -1, 65, -1, 1, 65, 1); + + Class definitionClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChallengeDefinition"); + java.lang.reflect.Constructor definitionCtor = definitionClass.getDeclaredConstructors()[0]; + definitionCtor.setAccessible(true); + Object definition = definitionCtor.newInstance( + TrainingChallengeType.CHEST, + region, + null, + new Location(trainingWorld, 0, 65, -1), + new Location(trainingWorld, 0, 65, -1), + null, + new Location(trainingWorld, 0, 65, 0), + 1, + 1, + 1, + 1, + 1, + 1, + null); + + @SuppressWarnings("unchecked") + Map definitions = + (Map) getField(manager, "definitions"); + definitions.put(TrainingChallengeType.CHEST, definition); + + trainingWorld.getBlockAt(0, 65, 0).setType(Material.HOPPER); + + Object blockKey = createBlockKey(0, 65, 0); + Object blockSnapshot = createBlockSnapshot(Material.HOPPER, Material.HOPPER.createBlockData(), new ItemStack[] { + new ItemStack(Material.STRING, 9), null, null, null, null + }); + + Map blocks = new LinkedHashMap<>(); + blocks.put(blockKey, blockSnapshot); + Object arenaSnapshot = createArenaSnapshot("deepcore_gym", blocks); + + @SuppressWarnings("unchecked") + Map arenaSnapshots = + (Map) getField(manager, "arenaSnapshots"); + arenaSnapshots.put(TrainingChallengeType.CHEST, arenaSnapshot); + + invoke( + manager, + "restoreAttemptArenaState", + new Class[] {TrainingChallengeType.class}, + TrainingChallengeType.CHEST); + + Container restoredHopper = + (Container) trainingWorld.getBlockAt(0, 65, 0).getState(); + assertTrue(restoredHopper.getInventory().isEmpty()); + } + + @Test + void shutdown_restoresHiddenStartButtonsForActiveTraining() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + @SuppressWarnings("unchecked") + Map definitions = + (Map) getField(manager, "definitions"); + Object definition = definitions.get(TrainingChallengeType.PORTAL); + assertNotNull(definition); + + Location startButton = new Location(trainingWorld, -18, 65, -31); + trainingWorld.getBlockAt(startButton).setType(Material.AIR); + + Class snapshotClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$BlockSnapshot"); + java.lang.reflect.Constructor snapshotCtor = snapshotClass.getDeclaredConstructor( + Material.class, org.bukkit.block.data.BlockData.class, ItemStack[].class); + snapshotCtor.setAccessible(true); + Object blockSnapshot = snapshotCtor.newInstance(Material.STONE, Material.STONE.createBlockData(), null); + + @SuppressWarnings("unchecked") + Map hiddenSnapshots = + (Map) getField(manager, "hiddenStartButtonSnapshots"); + hiddenSnapshots.put(TrainingChallengeType.PORTAL, blockSnapshot); + + Object activeAttempt = + createActiveAttempt(UUID.randomUUID(), TrainingChallengeType.PORTAL, System.currentTimeMillis(), null); + @SuppressWarnings("unchecked") + Map activeByPlayer = (Map) getField(manager, "activeByPlayer"); + @SuppressWarnings("unchecked") + Map activeByChallenge = + (Map) getField(manager, "activeByChallenge"); + activeByPlayer.put(UUID.randomUUID(), activeAttempt); + activeByChallenge.put(TrainingChallengeType.PORTAL, activeAttempt); + + manager.shutdown(); + + assertEquals(Material.STONE, trainingWorld.getBlockAt(startButton).getType()); + assertTrue(hiddenSnapshots.isEmpty()); + assertTrue(activeByPlayer.isEmpty()); + assertTrue(activeByChallenge.isEmpty()); + } + + @Test + void cancelAttempt_restoresStartButtonAfterArenaRestore() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + Location startButton = new Location(trainingWorld, -18, 65, -31); + trainingWorld.getBlockAt(startButton).setType(Material.AIR); + + Class blockSnapshotClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$BlockSnapshot"); + java.lang.reflect.Constructor blockSnapshotCtor = blockSnapshotClass.getDeclaredConstructors()[0]; + blockSnapshotCtor.setAccessible(true); + + Object startButtonSnapshot = + blockSnapshotCtor.newInstance(Material.STONE_BUTTON, Material.STONE_BUTTON.createBlockData(), null); + + Object arenaButtonSnapshot = blockSnapshotCtor.newInstance(Material.AIR, Material.AIR.createBlockData(), null); + + Object buttonKey = createBlockKey(startButton.getBlockX(), startButton.getBlockY(), startButton.getBlockZ()); + java.util.Map blocks = new java.util.LinkedHashMap<>(); + blocks.put(buttonKey, arenaButtonSnapshot); + Object arenaSnapshot = createArenaSnapshot("deepcore_gym", blocks); + + @SuppressWarnings("unchecked") + Map hiddenSnapshots = + (Map) getField(manager, "hiddenStartButtonSnapshots"); + @SuppressWarnings("unchecked") + Map arenaSnapshots = + (Map) getField(manager, "arenaSnapshots"); + @SuppressWarnings("unchecked") + Map activeByPlayer = (Map) getField(manager, "activeByPlayer"); + + hiddenSnapshots.put(TrainingChallengeType.PORTAL, startButtonSnapshot); + arenaSnapshots.put(TrainingChallengeType.PORTAL, arenaSnapshot); + + PlayerMock player = server.addPlayer("trainer-button-cancel"); + player.teleport(new Location(trainingWorld, -18, 65, -18)); + Object attempt = createActiveAttempt( + player.getUniqueId(), TrainingChallengeType.PORTAL, System.currentTimeMillis(), null); + activeByPlayer.put(player.getUniqueId(), attempt); + + invoke(manager, "cancelAttempt", new Class[] {Player.class, String.class}, player, "test"); + + assertEquals( + Material.STONE_BUTTON, trainingWorld.getBlockAt(startButton).getType()); + } + + @Test + void spawnChestsWithLoot_placesChestsAroundHopperInsteadOfSkippingTheRing() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + Class cuboidClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$Cuboid"); + java.lang.reflect.Constructor cuboidCtor = cuboidClass.getDeclaredConstructors()[0]; + cuboidCtor.setAccessible(true); + Object region = cuboidCtor.newInstance("deepcore_gym", -1, 65, -1, 1, 65, 1); + + Class definitionClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChallengeDefinition"); + java.lang.reflect.Constructor definitionCtor = definitionClass.getDeclaredConstructors()[0]; + definitionCtor.setAccessible(true); + Object definition = definitionCtor.newInstance( + TrainingChallengeType.CHEST, + region, + null, + null, + null, + null, + new Location(trainingWorld, 0, 65, 0), + 1, + 1, + 1, + 1, + 1, + 1, + null); + + Class chestObjectiveClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChestObjective"); + java.lang.reflect.Constructor chestObjectiveCtor = + chestObjectiveClass.getDeclaredConstructors()[0]; + chestObjectiveCtor.setAccessible(true); + Object chestObjective = chestObjectiveCtor.newInstance(Map.of(Material.DIRT, 1)); + + trainingWorld.getBlockAt(0, 65, 0).setType(Material.HOPPER); + trainingWorld.getBlockAt(1, 65, 1).setType(Material.STONE); + trainingWorld.getBlockAt(1, 65, -1).setType(Material.STONE); + trainingWorld.getBlockAt(-1, 65, 1).setType(Material.STONE); + trainingWorld.getBlockAt(-1, 65, -1).setType(Material.STONE); + + invoke( + manager, + "spawnChestsWithLoot", + new Class[] {definitionClass, chestObjectiveClass}, + definition, + chestObjective); + + int spawnedChests = 0; + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + if (x == 0 && z == 0) { + continue; + } + if (trainingWorld.getBlockAt(x, 65, z).getType() == Material.CHEST) { + spawnedChests++; + } + } + } + + assertEquals(Material.HOPPER, trainingWorld.getBlockAt(0, 65, 0).getType()); + assertTrue(spawnedChests >= 1); + } + + @Test + void nestedTypesAndResolvers_coverRemainingUtilityBranches() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + Object cuboid = createCuboid("deepcore_gym", 10, 70, 10, 2, 60, 2); + Method contains = cuboid.getClass().getDeclaredMethod("contains", Location.class); + contains.setAccessible(true); + assertTrue((boolean) contains.invoke(cuboid, new Location(trainingWorld, 5, 65, 5))); + assertFalse((boolean) contains.invoke(cuboid, new Location(trainingWorld, 100, 65, 100))); + + @SuppressWarnings("unchecked") + Optional startButtonMatch = (Optional) invoke( + manager, + "resolveChallengeByStartButton", + new Class[] {Location.class}, + new Location(trainingWorld, -18, 65, -31)); + assertTrue(startButtonMatch.isPresent()); + + @SuppressWarnings("unchecked") + Optional locationMatch = (Optional) invoke( + manager, + "resolveChallengeByLocation", + new Class[] {Location.class}, + new Location(trainingWorld, -18, 65, -18)); + assertTrue(locationMatch.isPresent()); + + Class craftObjectiveClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$CraftObjective"); + java.lang.reflect.Constructor objectiveCtor = craftObjectiveClass.getDeclaredConstructor( + boolean.class, boolean.class, boolean.class, boolean.class, int.class, int.class); + objectiveCtor.setAccessible(true); + Object craftObjective = objectiveCtor.newInstance(true, true, true, true, 1, 1); + setField(craftObjective, "craftedBeds", 1); + setField(craftObjective, "craftedEyes", 1); + setField(craftObjective, "craftedAxe", true); + setField(craftObjective, "craftedShovel", true); + Method isComplete = craftObjectiveClass.getDeclaredMethod("isComplete"); + isComplete.setAccessible(true); + assertTrue((boolean) isComplete.invoke(craftObjective)); + + Object bridgeAttempt = createActiveAttempt( + UUID.randomUUID(), TrainingChallengeType.BRIDGE, System.currentTimeMillis() - 50L, null); + String text = (String) invoke(manager, "objectiveText", new Class[] {bridgeAttempt.getClass()}, bridgeAttempt); + assertTrue(text.contains("pressure plate")); + } + + @Test + void additionalBranches_coverStartAttemptCraftAndRestoreCases() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + PlayerMock player = server.addPlayer("trainer-branches"); + World nonTrainingWorld = server.getWorld("world"); + player.teleport(new Location(nonTrainingWorld, 0, 65, 0)); + + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class}, + player, + TrainingChallengeType.PORTAL); + verify(logger, atLeastOnce()).sendWarn(player, "Enter the training gym first with /challenge train."); + + player.teleport(new Location(trainingWorld, 0, 65, 0)); + @SuppressWarnings("unchecked") + Map activeByPlayer = (Map) getField(manager, "activeByPlayer"); + @SuppressWarnings("unchecked") + Map activeByChallenge = + (Map) getField(manager, "activeByChallenge"); + @SuppressWarnings("unchecked") + Map definitions = + (Map) getField(manager, "definitions"); + + Object existingAttempt = createActiveAttempt( + player.getUniqueId(), TrainingChallengeType.PORTAL, System.currentTimeMillis(), null); + activeByPlayer.put(player.getUniqueId(), existingAttempt); + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class}, + player, + TrainingChallengeType.PORTAL); + activeByPlayer.clear(); + + activeByChallenge.put( + TrainingChallengeType.PORTAL, + createActiveAttempt(UUID.randomUUID(), TrainingChallengeType.PORTAL, System.currentTimeMillis(), null)); + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class}, + player, + TrainingChallengeType.PORTAL); + activeByChallenge.clear(); + + Object savedPortal = definitions.remove(TrainingChallengeType.PORTAL); + invoke( + manager, + "startAttempt", + new Class[] {Player.class, TrainingChallengeType.class}, + player, + TrainingChallengeType.PORTAL); + definitions.put(TrainingChallengeType.PORTAL, savedPortal); + + Class craftObjectiveClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$CraftObjective"); + java.lang.reflect.Constructor objectiveCtor = craftObjectiveClass.getDeclaredConstructor( + boolean.class, boolean.class, boolean.class, boolean.class, int.class, int.class); + objectiveCtor.setAccessible(true); + Object craftObjective = objectiveCtor.newInstance(true, true, true, true, 2, 2); + + Class chestObjClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChestObjective"); + Class challengeDefClass = + Class.forName("dev.deepcore.challenge.training.TrainingManager$ChallengeDefinition"); + invoke( + manager, + "prepareLoadout", + new Class[] { + Player.class, TrainingChallengeType.class, craftObjectiveClass, chestObjClass, challengeDefClass + }, + player, + TrainingChallengeType.CRAFT, + craftObjective, + null, + null); + invoke( + manager, + "prepareLoadout", + new Class[] { + Player.class, TrainingChallengeType.class, craftObjectiveClass, chestObjClass, challengeDefClass + }, + player, + TrainingChallengeType.BRIDGE, + craftObjective, + null, + null); + + Object craftAttempt = createActiveAttempt( + player.getUniqueId(), TrainingChallengeType.CRAFT, System.currentTimeMillis(), craftObjective); + activeByPlayer.put(player.getUniqueId(), craftAttempt); + + org.bukkit.event.inventory.CraftItemEvent bedEvent = mock(org.bukkit.event.inventory.CraftItemEvent.class); + Recipe bedRecipe = mock(Recipe.class); + when(bedEvent.getWhoClicked()).thenReturn(player); + when(bedEvent.getRecipe()).thenReturn(bedRecipe); + when(bedRecipe.getResult()).thenReturn(new ItemStack(Material.RED_BED, 1)); + manager.onCraftItem(bedEvent); + + org.bukkit.event.inventory.CraftItemEvent eyeEvent = mock(org.bukkit.event.inventory.CraftItemEvent.class); + Recipe eyeRecipe = mock(Recipe.class); + when(eyeEvent.getWhoClicked()).thenReturn(player); + when(eyeEvent.getRecipe()).thenReturn(eyeRecipe); + when(eyeRecipe.getResult()).thenReturn(new ItemStack(Material.ENDER_EYE, 1)); + manager.onCraftItem(eyeEvent); + + org.bukkit.event.inventory.CraftItemEvent axeEvent = mock(org.bukkit.event.inventory.CraftItemEvent.class); + Recipe axeRecipe = mock(Recipe.class); + when(axeEvent.getWhoClicked()).thenReturn(player); + when(axeEvent.getRecipe()).thenReturn(axeRecipe); + when(axeRecipe.getResult()).thenReturn(new ItemStack(Material.IRON_AXE, 1)); + manager.onCraftItem(axeEvent); + + org.bukkit.event.inventory.CraftItemEvent shovelEvent = mock(org.bukkit.event.inventory.CraftItemEvent.class); + Recipe shovelRecipe = mock(Recipe.class); + when(shovelEvent.getWhoClicked()).thenReturn(player); + when(shovelEvent.getRecipe()).thenReturn(shovelRecipe); + when(shovelRecipe.getResult()).thenReturn(new ItemStack(Material.IRON_SHOVEL, 1)); + manager.onCraftItem(shovelEvent); + + Object cuboid = createCuboid("deepcore_gym", 5, 65, 5, 5, 65, 5); + trainingWorld.getBlockAt(5, 65, 5).setType(Material.CHEST); + org.bukkit.block.Container container = + (org.bukkit.block.Container) trainingWorld.getBlockAt(5, 65, 5).getState(); + container.getInventory().setContents(new ItemStack[] {new ItemStack(Material.STONE, 1)}); + + Object blockKey = createBlockKey(5, 65, 5); + Object blockSnapshot = createBlockSnapshot( + Material.CHEST, + trainingWorld.getBlockAt(5, 65, 5).getBlockData(), + new ItemStack[] {new ItemStack(Material.STONE, 1)}); + java.util.Map blockMap = new java.util.LinkedHashMap<>(); + blockMap.put(blockKey, blockSnapshot); + Object arenaSnapshot = createArenaSnapshot("deepcore_gym", blockMap); + + @SuppressWarnings("unchecked") + Map arenaSnapshots = + (Map) getField(manager, "arenaSnapshots"); + arenaSnapshots.put(TrainingChallengeType.CHEST, arenaSnapshot); + invoke(manager, "restoreArena", new Class[] {TrainingChallengeType.class}, TrainingChallengeType.CHEST); + + Object objectiveNullAttempt = createActiveAttempt( + player.getUniqueId(), TrainingChallengeType.CRAFT, System.currentTimeMillis(), null); + String objectiveText = (String) + invoke(manager, "objectiveText", new Class[] {objectiveNullAttempt.getClass()}, objectiveNullAttempt); + assertTrue(objectiveText.contains("Craft objective")); + assertNotNull(cuboid); + } + + @Test + void sidebarStatsAndTeleportEdgeCases_coverRemainingBranches() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + PlayerMock commandPlayer = server.addPlayer("trainer-sidebar"); + commandPlayer.teleport(new Location(trainingWorld, -18, 65, -18)); + + UUID playerId = UUID.randomUUID(); + Player sidebarPlayer = mock(Player.class); + when(sidebarPlayer.getUniqueId()).thenReturn(playerId); + when(sidebarPlayer.getLocation()).thenReturn(new Location(trainingWorld, -18, 65, -18)); + + TrainingStatsStore store = (TrainingStatsStore) getField(manager, "statsStore"); + store.recordCompletedAttempt(playerId, TrainingChallengeType.PORTAL, 3_000L); + store.recordCompletedAttempt(playerId, TrainingChallengeType.PORTAL, 4_000L); + store.recordCompletedAttempt(commandPlayer.getUniqueId(), TrainingChallengeType.PORTAL, 3_200L); + store.recordCompletedAttempt(commandPlayer.getUniqueId(), TrainingChallengeType.PORTAL, 4_200L); + + ScoreboardManager scoreboardManager = mock(ScoreboardManager.class); + Scoreboard scoreboard = mock(Scoreboard.class); + Objective objective = mock(Objective.class); + Score score = mock(Score.class); + when(scoreboardManager.getNewScoreboard()).thenReturn(scoreboard); + when(scoreboard.registerNewObjective(any(String.class), any(String.class), any(Component.class))) + .thenReturn(objective); + when(scoreboard.registerNewObjective(any(String.class), any(String.class), any(String.class))) + .thenReturn(objective); + when(scoreboard.getObjective(any(String.class))).thenReturn(objective); + when(scoreboardManager.getMainScoreboard()).thenReturn(mock(Scoreboard.class)); + when(objective.getScore(any(String.class))).thenReturn(score); + when(sidebarPlayer.getScoreboard()).thenReturn(scoreboard); + + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getScoreboardManager).thenReturn(scoreboardManager); + invoke(manager, "showIdleSidebar", new Class[] {Player.class}, sidebarPlayer); + invoke(manager, "clearTrainingSidebar", new Class[] {Player.class}, sidebarPlayer); + } + + assertTrue(manager.handleCommand(commandPlayer, new String[] {"train", "stats", "portal"})); + invoke( + manager, + "sendStatsLineup", + new Class[] {Player.class, TrainingChallengeType.class}, + commandPlayer, + TrainingChallengeType.PORTAL); + + setField(manager, "trainingWorldName", "missing_world"); + assertTrue(manager.handleCommand(commandPlayer, new String[] {"train"})); + + @SuppressWarnings("unchecked") + Map returnLocations = (Map) getField(manager, "returnLocations"); + returnLocations.put(commandPlayer.getUniqueId(), new Location((World) null, 0, 65, 0)); + commandPlayer.teleport(new Location(trainingWorld, 0, 65, 0)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getWorlds).thenReturn(List.of()); + assertTrue(manager.handleCommand(commandPlayer, new String[] {"train", "leave"})); + } + + verify(logger, atLeastOnce()).sendWarn(any(Player.class), contains("No return location available")); + verify(logger, atLeastOnce()).sendError(any(Player.class), contains("is not loaded")); + } + + @Test + void schedulerAndNumberFormatHelpers_coverPrivateBranches() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask firstTask = mock(BukkitTask.class); + BukkitTask secondTask = mock(BukkitTask.class); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L))) + .thenReturn(firstTask) + .thenReturn(secondTask); + + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + + invoke(manager, "startHudTask", new Class[0]); + invoke(manager, "startHudTask", new Class[0]); + } + + verify(firstTask, atLeastOnce()).cancel(); + + invoke(manager, "applyBlankSidebarNumberFormat", new Class[] {Objective.class}, new Object[] {null}); + + Objective objective = mock(Objective.class); + invoke(manager, "applyBlankSidebarNumberFormat", new Class[] {Objective.class}, objective); + } + + @Test + void movementTeleportBridgeAndRestore_coverLateBranches() throws Exception { + TrainingManager manager = new TrainingManager(plugin); + manager.reloadFromConfig(); + + PlayerMock player = server.addPlayer("trainer-events"); + player.teleport(new Location(trainingWorld, 11, 65, 11)); + + Object chestAttempt = createActiveAttempt( + player.getUniqueId(), TrainingChallengeType.CHEST, System.currentTimeMillis(), null); + @SuppressWarnings("unchecked") + Map activeByPlayer = (Map) getField(manager, "activeByPlayer"); + activeByPlayer.put(player.getUniqueId(), chestAttempt); + + InventoryClickEvent clickEvent = mock(InventoryClickEvent.class); + when(clickEvent.getWhoClicked()).thenReturn(player); + InventoryDragEvent dragEvent = mock(InventoryDragEvent.class); + when(dragEvent.getWhoClicked()).thenReturn(player); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(invocation -> { + Runnable runnable = invocation.getArgument(1); + runnable.run(); + return mock(BukkitTask.class); + }); + + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + manager.onChestClick(clickEvent); + manager.onChestDrag(dragEvent); + } + + Object portalAttempt = createActiveAttempt( + player.getUniqueId(), TrainingChallengeType.PORTAL, System.currentTimeMillis(), null); + activeByPlayer.put(player.getUniqueId(), portalAttempt); + + @SuppressWarnings("unchecked") + Map definitions = + (Map) getField(manager, "definitions"); + Object portalDefinition = definitions.remove(TrainingChallengeType.PORTAL); + + PlayerMoveEvent moveEvent = new PlayerMoveEvent( + player, new Location(trainingWorld, 11, 65, 11), new Location(trainingWorld, 12, 65, 12)); + manager.onAttemptPlayerMove(moveEvent); + + PlayerTeleportEvent teleportEvent = new PlayerTeleportEvent( + player, new Location(trainingWorld, 11, 65, 11), new Location(trainingWorld, 12, 65, 12)); + manager.onAttemptTeleport(teleportEvent); + + definitions.put(TrainingChallengeType.PORTAL, portalDefinition); + manager.onAttemptPlayerMove(new PlayerMoveEvent( + player, new Location(trainingWorld, 11, 65, 11), new Location(trainingWorld, -200, 65, -200))); + manager.onAttemptTeleport(new PlayerTeleportEvent( + player, new Location(trainingWorld, 11, 65, 11), new Location(trainingWorld, -200, 65, -200))); + + Object bridgeAttempt = createActiveAttempt( + player.getUniqueId(), TrainingChallengeType.BRIDGE, System.currentTimeMillis(), null); + activeByPlayer.put(player.getUniqueId(), bridgeAttempt); + BlockPlaceEvent placeEvent = mock(BlockPlaceEvent.class); + when(placeEvent.getPlayer()).thenReturn(player); + Block placedBlock = mock(Block.class); + when(placedBlock.getLocation()).thenReturn(new Location(trainingWorld, -150, 64, -150)); + when(placeEvent.getBlockPlaced()).thenReturn(placedBlock); + manager.onBridgePlace(placeEvent); + verify(placeEvent, atLeastOnce()).setCancelled(true); + + Class blockKeyClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$BlockKey"); + java.lang.reflect.Constructor blockKeyConstructor = blockKeyClass.getDeclaredConstructors()[0]; + blockKeyConstructor.setAccessible(true); + Object key = blockKeyConstructor.newInstance(5, 65, 5); + + Class blockSnapshotClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$BlockSnapshot"); + java.lang.reflect.Constructor blockSnapshotConstructor = + blockSnapshotClass.getDeclaredConstructors()[0]; + blockSnapshotConstructor.setAccessible(true); + Object blockSnapshot = blockSnapshotConstructor.newInstance( + Material.CHEST, Material.CHEST.createBlockData(), new ItemStack[] {new ItemStack(Material.STONE, 1)}); + + Map blockMap = Map.of(key, blockSnapshot); + Class arenaSnapshotClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ArenaSnapshot"); + java.lang.reflect.Constructor arenaSnapshotConstructor = + arenaSnapshotClass.getDeclaredConstructors()[0]; + arenaSnapshotConstructor.setAccessible(true); + Object arenaSnapshot = arenaSnapshotConstructor.newInstance(trainingWorld.getName(), blockMap); + + @SuppressWarnings("unchecked") + Map arenaSnapshots = + (Map) getField(manager, "arenaSnapshots"); + arenaSnapshots.put(TrainingChallengeType.CHEST, arenaSnapshot); + + World worldMock = mock(World.class); + Block worldBlock = mock(Block.class); + Container container = mock(Container.class); + Inventory inventory = mock(Inventory.class); + when(worldMock.getBlockAt(5, 65, 5)).thenReturn(worldBlock); + when(worldBlock.getState()).thenReturn(container); + when(container.getInventory()).thenReturn(inventory); + + try (MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> Bukkit.getWorld(trainingWorld.getName())).thenReturn(worldMock); + invoke(manager, "restoreArena", new Class[] {TrainingChallengeType.class}, TrainingChallengeType.CHEST); + } + + verify(inventory, atLeastOnce()).clear(); + verify(inventory, atLeastOnce()).setContents(any(ItemStack[].class)); + verify(container, atLeastOnce()).update(true, false); + } + + private void configureBaseTrainingConfig(YamlConfiguration yaml, boolean enabled) { + yaml.set("training.enabled", enabled); + yaml.set("training.world", "deepcore_gym"); + yaml.set("training.lobby-spawn.world", "deepcore_gym"); + yaml.set("training.lobby-spawn.x", 0.5D); + yaml.set("training.lobby-spawn.y", 65.0D); + yaml.set("training.lobby-spawn.z", 0.5D); + yaml.set("training.lobby-spawn.yaw", 0.0D); + yaml.set("training.lobby-spawn.pitch", 0.0D); + yaml.set("training.respawn-spawn.world", "deepcore_gym"); + yaml.set("training.respawn-spawn.x", 0.5D); + yaml.set("training.respawn-spawn.y", 65.0D); + yaml.set("training.respawn-spawn.z", 0.5D); + yaml.set("training.respawn-spawn.yaw", 0.0D); + yaml.set("training.respawn-spawn.pitch", 0.0D); + + yaml.set("training.craft.beds.min", 5); + yaml.set("training.craft.beds.max", 8); + yaml.set("training.craft.eyes-of-ender.min", 7); + yaml.set("training.craft.eyes-of-ender.max", 9); + + setChallengeConfig(yaml, "portal", -20, 64, -20, -16, 66, -16, -18, 65, -31, -18.5D, 65D, -18.5D); + setChallengeConfig(yaml, "craft", 16, 64, -20, 20, 66, -16, 18, 65, -31, 18.5D, 65D, -18.5D); + setChallengeConfig(yaml, "chest", -20, 64, 16, -16, 66, 20, -18, 65, 5, -18.5D, 65D, 18.5D); + setChallengeConfig(yaml, "bridge", 16, 64, 16, 20, 66, 20, 18, 65, 5, 18.5D, 65D, 18.5D); + + yaml.set("training.challenges.bridge.completion-pressure-plate.world", "deepcore_gym"); + yaml.set("training.challenges.bridge.completion-pressure-plate.x", 20D); + yaml.set("training.challenges.bridge.completion-pressure-plate.y", 65D); + yaml.set("training.challenges.bridge.completion-pressure-plate.z", 20D); + + yaml.set("training.challenges.chest.tracked-chests.c1.world", "deepcore_gym"); + yaml.set("training.challenges.chest.tracked-chests.c1.x", -18D); + yaml.set("training.challenges.chest.tracked-chests.c1.y", 65D); + yaml.set("training.challenges.chest.tracked-chests.c1.z", 18D); + } + + private void setChallengeConfig( + YamlConfiguration yaml, + String key, + int minX, + int minY, + int minZ, + int maxX, + int maxY, + int maxZ, + int startButtonX, + int startButtonY, + int startButtonZ, + double startLocX, + double startLocY, + double startLocZ) { + String base = "training.challenges." + key; + yaml.set(base + ".enabled", true); + yaml.set(base + ".region.world", "deepcore_gym"); + yaml.set(base + ".region.min.x", minX); + yaml.set(base + ".region.min.y", minY); + yaml.set(base + ".region.min.z", minZ); + yaml.set(base + ".region.max.x", maxX); + yaml.set(base + ".region.max.y", maxY); + yaml.set(base + ".region.max.z", maxZ); + + yaml.set(base + ".start-button.world", "deepcore_gym"); + yaml.set(base + ".start-button.x", startButtonX); + yaml.set(base + ".start-button.y", startButtonY); + yaml.set(base + ".start-button.z", startButtonZ); + + yaml.set(base + ".start-location.world", "deepcore_gym"); + yaml.set(base + ".start-location.x", startLocX); + yaml.set(base + ".start-location.y", startLocY); + yaml.set(base + ".start-location.z", startLocZ); + yaml.set(base + ".start-location.yaw", 0.0D); + yaml.set(base + ".start-location.pitch", 0.0D); + } + + private Object getDefinition(TrainingManager manager, TrainingChallengeType type) throws Exception { + @SuppressWarnings("unchecked") + Map definitions = + (Map) getField(manager, "definitions"); + return definitions.get(type); + } + + @SuppressWarnings("unchecked") + private Map getActiveByPlayer(TrainingManager manager) throws Exception { + return (Map) getField(manager, "activeByPlayer"); + } + + private Object createCuboid(String world, int minX, int minY, int minZ, int maxX, int maxY, int maxZ) + throws Exception { + Class cuboidClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$Cuboid"); + java.lang.reflect.Constructor constructor = cuboidClass.getDeclaredConstructor( + String.class, int.class, int.class, int.class, int.class, int.class, int.class); + constructor.setAccessible(true); + return constructor.newInstance(world, minX, minY, minZ, maxX, maxY, maxZ); + } + + private Object createChallengeDefinition( + TrainingChallengeType type, + Object cuboid, + Location startButton, + Location startLocation, + Location completionPlate, + List tracked) + throws Exception { + Class defClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChallengeDefinition"); + java.lang.reflect.Constructor constructor = defClass.getDeclaredConstructor( + TrainingChallengeType.class, + cuboid.getClass(), + cuboid.getClass(), + Location.class, + Location.class, + Location.class, + List.class, + int.class, + int.class, + int.class, + int.class); + constructor.setAccessible(true); + return constructor.newInstance( + type, cuboid, null, startButton, startLocation, completionPlate, tracked, 5, 8, 7, 9); + } + + private Object createActiveAttempt(UUID playerId, TrainingChallengeType type, long startedAt, Object craftObjective) + throws Exception { + Class activeAttemptClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ActiveAttempt"); + Class craftObjectiveClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$CraftObjective"); + Class chestObjectiveClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ChestObjective"); + java.lang.reflect.Constructor constructor = activeAttemptClass.getDeclaredConstructor( + UUID.class, + TrainingChallengeType.class, + long.class, + craftObjectiveClass, + chestObjectiveClass, + Location.class); + constructor.setAccessible(true); + return constructor.newInstance(playerId, type, startedAt, craftObjective, null, null); + } + + private Object createBlockKey(int x, int y, int z) throws Exception { + Class blockKeyClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$BlockKey"); + java.lang.reflect.Constructor constructor = + blockKeyClass.getDeclaredConstructor(int.class, int.class, int.class); + constructor.setAccessible(true); + return constructor.newInstance(x, y, z); + } + + private Object createBlockSnapshot( + Material material, org.bukkit.block.data.BlockData blockData, ItemStack[] inventoryContents) + throws Exception { + Class snapshotClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$BlockSnapshot"); + java.lang.reflect.Constructor constructor = snapshotClass.getDeclaredConstructor( + Material.class, org.bukkit.block.data.BlockData.class, ItemStack[].class); + constructor.setAccessible(true); + return constructor.newInstance(material, blockData, inventoryContents); + } + + private Object createArenaSnapshot(String worldName, java.util.Map blocks) throws Exception { + Class snapshotClass = Class.forName("dev.deepcore.challenge.training.TrainingManager$ArenaSnapshot"); + java.lang.reflect.Constructor constructor = + snapshotClass.getDeclaredConstructor(String.class, java.util.Map.class); + constructor.setAccessible(true); + return constructor.newInstance(worldName, blocks); + } + + private static Object getField(Object target, String fieldName) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(target); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private static Object invoke(Object target, String methodName, Class[] parameterTypes, Object... args) + throws Exception { + Method method = target.getClass().getDeclaredMethod(methodName, parameterTypes); + method.setAccessible(true); + return method.invoke(target, args); + } +} diff --git a/src/test/java/dev/deepcore/challenge/training/TrainingReturnItemServiceTest.java b/src/test/java/dev/deepcore/challenge/training/TrainingReturnItemServiceTest.java new file mode 100644 index 0000000..fc38618 --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/training/TrainingReturnItemServiceTest.java @@ -0,0 +1,276 @@ +package dev.deepcore.challenge.training; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import be.seeseemelk.mockbukkit.MockBukkit; +import java.lang.reflect.Field; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class TrainingReturnItemServiceTest { + + private JavaPlugin plugin; + private TrainingManager trainingManager; + private TrainingReturnItemService service; + private NamespacedKey returnKey; + private Server server; + private PluginManager pluginManager; + private BukkitScheduler scheduler; + + @BeforeEach + void setUp() { + MockBukkit.mock(); + + plugin = mock(JavaPlugin.class); + trainingManager = mock(TrainingManager.class); + server = mock(Server.class); + pluginManager = mock(PluginManager.class); + scheduler = mock(BukkitScheduler.class); + + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(pluginManager); + when(server.getScheduler()).thenReturn(scheduler); + + returnKey = new NamespacedKey("deepcore", "training_return_item"); + service = new TrainingReturnItemService(plugin, trainingManager, returnKey); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void isReturnItem_validatesMaterialMetaAndMarker() { + assertFalse(service.isReturnItem(null)); + assertFalse(service.isReturnItem(new ItemStack(org.bukkit.Material.STONE))); + + ItemStack featherWithoutMarker = new ItemStack(org.bukkit.Material.FEATHER); + assertFalse(service.isReturnItem(featherWithoutMarker)); + + ItemStack marked = new ItemStack(org.bukkit.Material.FEATHER); + ItemMeta markedMeta = marked.getItemMeta(); + markedMeta.getPersistentDataContainer().set(returnKey, PersistentDataType.BYTE, (byte) 1); + marked.setItemMeta(markedMeta); + assertTrue(service.isReturnItem(marked)); + + ItemStack wrongMarker = new ItemStack(org.bukkit.Material.FEATHER); + ItemMeta wrongMeta = wrongMarker.getItemMeta(); + wrongMeta.getPersistentDataContainer().set(returnKey, PersistentDataType.BYTE, (byte) 0); + wrongMarker.setItemMeta(wrongMeta); + assertFalse(service.isReturnItem(wrongMarker)); + } + + @Test + void onPlayerEnterAndLeaveTraining_manageReturnItemSlot() { + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + World world = mock(World.class); + + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getInventory()).thenReturn(inventory); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(inventory.getItem(TrainingReturnItemService.RETURN_ITEM_SLOT)).thenReturn(null); + + service.onPlayerEnterTraining(player); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ItemStack.class); + verify(inventory).setItem(eq(TrainingReturnItemService.RETURN_ITEM_SLOT), captor.capture()); + assertTrue(service.isReturnItem(captor.getValue())); + verify(player).updateInventory(); + + when(inventory.getItem(TrainingReturnItemService.RETURN_ITEM_SLOT)).thenReturn(captor.getValue()); + service.onPlayerLeaveTraining(player); + + verify(inventory).setItem(TrainingReturnItemService.RETURN_ITEM_SLOT, null); + verify(player, atLeastOnce()).updateInventory(); + } + + @Test + void onReturnItemClick_onlyHandlesRightClickWithReturnItem() { + Player player = mock(Player.class); + ItemStack returnItem = markedReturnItem(); + + PlayerInteractEvent leftClick = mock(PlayerInteractEvent.class); + when(leftClick.getAction()).thenReturn(Action.LEFT_CLICK_AIR); + service.onReturnItemClick(leftClick); + verify(leftClick, never()).setCancelled(true); + + PlayerInteractEvent rightClickNonReturn = mock(PlayerInteractEvent.class); + when(rightClickNonReturn.getAction()).thenReturn(Action.RIGHT_CLICK_AIR); + when(rightClickNonReturn.getItem()).thenReturn(new ItemStack(org.bukkit.Material.STONE)); + service.onReturnItemClick(rightClickNonReturn); + verify(rightClickNonReturn, never()).setCancelled(true); + + PlayerInteractEvent rightClickReturn = mock(PlayerInteractEvent.class); + when(rightClickReturn.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); + when(rightClickReturn.getItem()).thenReturn(returnItem); + when(rightClickReturn.getPlayer()).thenReturn(player); + + service.onReturnItemClick(rightClickReturn); + + verify(rightClickReturn).setCancelled(true); + verify(trainingManager).leaveTraining(player); + } + + @Test + void inventoryAndDropGuards_blockMovingOrDroppingReturnItem() { + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + + ItemStack returnItem = markedReturnItem(); + + org.bukkit.entity.Item itemEntity = mock(org.bukkit.entity.Item.class); + PlayerDropItemEvent drop = mock(PlayerDropItemEvent.class); + when(drop.getItemDrop()).thenReturn(itemEntity); + when(itemEntity.getItemStack()).thenReturn(returnItem); + when(drop.getPlayer()).thenReturn(player); + + service.onReturnItemDrop(drop); + verify(drop).setCancelled(true); + verify(player).updateInventory(); + + InventoryClickEvent click = mock(InventoryClickEvent.class); + when(click.getWhoClicked()).thenReturn(player); + when(click.getCurrentItem()).thenReturn(returnItem); + when(click.getCursor()).thenReturn(null); + when(click.getHotbarButton()).thenReturn(-1); + + service.onReturnItemInventoryClick(click); + verify(click).setCancelled(true); + verify(player, atLeastOnce()).updateInventory(); + + InventoryClickEvent hotbarSwap = mock(InventoryClickEvent.class); + when(hotbarSwap.getWhoClicked()).thenReturn(player); + when(hotbarSwap.getCurrentItem()).thenReturn(null); + when(hotbarSwap.getCursor()).thenReturn(null); + when(hotbarSwap.getHotbarButton()).thenReturn(2); + when(inventory.getItem(2)).thenReturn(returnItem); + + service.onReturnItemInventoryClick(hotbarSwap); + verify(hotbarSwap).setCancelled(true); + + InventoryDragEvent dragOldCursor = mock(InventoryDragEvent.class); + when(dragOldCursor.getWhoClicked()).thenReturn(player); + when(dragOldCursor.getOldCursor()).thenReturn(returnItem); + + service.onReturnItemInventoryDrag(dragOldCursor); + verify(dragOldCursor).setCancelled(true); + + InventoryDragEvent dragNewItems = mock(InventoryDragEvent.class); + when(dragNewItems.getWhoClicked()).thenReturn(player); + when(dragNewItems.getOldCursor()).thenReturn(null); + when(dragNewItems.getNewItems()).thenReturn(Map.of(0, returnItem)); + + service.onReturnItemInventoryDrag(dragNewItems); + verify(dragNewItems).setCancelled(true); + } + + @Test + void initializeAndRestoreTask_coverSchedulerAndCleanupBranches() throws Exception { + BukkitTask scheduledTask = mock(BukkitTask.class); + AtomicReference tick = new AtomicReference<>(); + when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(20L))) + .thenAnswer(invocation -> { + tick.set(invocation.getArgument(1)); + return scheduledTask; + }); + + UUID missingPlayer = UUID.randomUUID(); + UUID activePlayerId = UUID.randomUUID(); + UUID needsItemPlayerId = UUID.randomUUID(); + + Player activePlayer = mock(Player.class); + Player needsItemPlayer = mock(Player.class); + PlayerInventory activeInventory = mock(PlayerInventory.class); + PlayerInventory needsItemInventory = mock(PlayerInventory.class); + + when(activePlayer.getInventory()).thenReturn(activeInventory); + when(needsItemPlayer.getInventory()).thenReturn(needsItemInventory); + when(activePlayer.getUniqueId()).thenReturn(activePlayerId); + when(needsItemPlayer.getUniqueId()).thenReturn(needsItemPlayerId); + + when(activeInventory.getItem(TrainingReturnItemService.RETURN_ITEM_SLOT)) + .thenReturn(markedReturnItem()); + when(needsItemInventory.getItem(TrainingReturnItemService.RETURN_ITEM_SLOT)) + .thenReturn(null); + + World world = mock(World.class); + when(needsItemPlayer.getWorld()).thenReturn(world); + when(needsItemPlayer.getLocation()).thenReturn(new Location(world, 0, 64, 0)); + + when(server.getPlayer(missingPlayer)).thenReturn(null); + when(server.getPlayer(activePlayerId)).thenReturn(activePlayer); + when(server.getPlayer(needsItemPlayerId)).thenReturn(needsItemPlayer); + + when(trainingManager.isInActiveAttempt(activePlayer)).thenReturn(true); + when(trainingManager.isInActiveAttempt(needsItemPlayer)).thenReturn(false); + + @SuppressWarnings("unchecked") + Set playersInTraining = (Set) getField(service, "playersInTraining"); + playersInTraining.add(missingPlayer); + playersInTraining.add(activePlayerId); + playersInTraining.add(needsItemPlayerId); + + service.initialize(); + verify(pluginManager).registerEvents(service, plugin); + + tick.get().run(); + + assertFalse(playersInTraining.contains(missingPlayer)); + verify(needsItemInventory).setItem(eq(TrainingReturnItemService.RETURN_ITEM_SLOT), any(ItemStack.class)); + verify(activeInventory, never()).setItem(eq(TrainingReturnItemService.RETURN_ITEM_SLOT), any(ItemStack.class)); + + service.shutdown(); + verify(scheduledTask).cancel(); + assertTrue(playersInTraining.isEmpty()); + } + + private ItemStack markedReturnItem() { + ItemStack item = new ItemStack(org.bukkit.Material.FEATHER); + ItemMeta meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(returnKey, PersistentDataType.BYTE, (byte) 1); + item.setItemMeta(meta); + return item; + } + + private static Object getField(Object target, String fieldName) throws ReflectiveOperationException { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(target); + } +} diff --git a/src/test/java/dev/deepcore/challenge/training/TrainingStatsStoreTest.java b/src/test/java/dev/deepcore/challenge/training/TrainingStatsStoreTest.java new file mode 100644 index 0000000..f3477fa --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/training/TrainingStatsStoreTest.java @@ -0,0 +1,110 @@ +package dev.deepcore.challenge.training; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.Test; + +class TrainingStatsStoreTest { + + @Test + void recordCompletedAttempt_tracksBestAndKeepsLastFiveMostRecent() throws Exception { + Path tempDir = Files.createTempDirectory("training-stats-record"); + JavaPlugin plugin = pluginWithDataFolder(tempDir.toFile()); + TrainingStatsStore store = new TrainingStatsStore(plugin); + + UUID player = UUID.randomUUID(); + store.recordCompletedAttempt(player, TrainingChallengeType.PORTAL, 5000L); + store.recordCompletedAttempt(player, TrainingChallengeType.PORTAL, 4200L); + store.recordCompletedAttempt(player, TrainingChallengeType.PORTAL, 6100L); + store.recordCompletedAttempt(player, TrainingChallengeType.PORTAL, 3900L); + store.recordCompletedAttempt(player, TrainingChallengeType.PORTAL, 4500L); + store.recordCompletedAttempt(player, TrainingChallengeType.PORTAL, 4700L); + + TrainingStatsStore.PlayerChallengeStats stats = store.getStats(player, TrainingChallengeType.PORTAL); + assertEquals(3900L, stats.bestTimeMs()); + assertEquals(List.of(4200L, 4500L, 4700L, 5000L, 6100L), stats.lastAttemptsMs()); + } + + @Test + void saveAndLoad_roundTripsStatsPerPlayerAndChallenge() throws Exception { + Path tempDir = Files.createTempDirectory("training-stats-roundtrip"); + JavaPlugin plugin = pluginWithDataFolder(tempDir.toFile()); + + TrainingStatsStore writer = new TrainingStatsStore(plugin); + UUID playerA = UUID.randomUUID(); + UUID playerB = UUID.randomUUID(); + writer.recordCompletedAttempt(playerA, TrainingChallengeType.CRAFT, 10000L); + writer.recordCompletedAttempt(playerA, TrainingChallengeType.CRAFT, 8500L); + writer.recordCompletedAttempt(playerB, TrainingChallengeType.BRIDGE, 3000L); + writer.save(); + + TrainingStatsStore reader = new TrainingStatsStore(plugin); + reader.load(); + + TrainingStatsStore.PlayerChallengeStats aStats = reader.getStats(playerA, TrainingChallengeType.CRAFT); + assertEquals(8500L, aStats.bestTimeMs()); + assertEquals(List.of(10000L), aStats.lastAttemptsMs()); + + TrainingStatsStore.PlayerChallengeStats bStats = reader.getStats(playerB, TrainingChallengeType.BRIDGE); + assertEquals(3000L, bStats.bestTimeMs()); + assertTrue(bStats.lastAttemptsMs().isEmpty()); + + TrainingStatsStore.PlayerChallengeStats missing = + reader.getStats(UUID.randomUUID(), TrainingChallengeType.CHEST); + assertEquals(-1L, missing.bestTimeMs()); + assertTrue(missing.lastAttemptsMs().isEmpty()); + } + + @Test + void load_ignoresInvalidPlayerIdsAndKeepsFirstFiveAttemptEntries() throws Exception { + Path tempDir = Files.createTempDirectory("training-stats-load"); + File dataFolder = tempDir.toFile(); + JavaPlugin plugin = pluginWithDataFolder(dataFolder); + String validPlayer = UUID.randomUUID().toString(); + + Path yamlPath = dataFolder.toPath().resolve("training-stats.yml"); + String yaml = "players:\n" + + " not-a-uuid:\n" + + " portal:\n" + + " best_time_ms: 1\n" + + " attempts_ms: [1, 2]\n" + + " " + validPlayer + ":\n" + + " chest:\n" + + " best_time_ms: 2500\n" + + " attempts_ms: [10, 20, 30, 40, 50, 60, 70]\n"; + Files.writeString(yamlPath, yaml); + + TrainingStatsStore store = new TrainingStatsStore(plugin); + store.load(); + + TrainingStatsStore.PlayerChallengeStats stats = + store.getStats(UUID.fromString(validPlayer), TrainingChallengeType.CHEST); + assertEquals(2500L, stats.bestTimeMs()); + assertEquals(List.of(10L, 20L, 30L, 40L, 50L), stats.lastAttemptsMs()); + } + + @Test + void playerChallengeStats_exposesImmutableAttemptView() { + TrainingStatsStore.PlayerChallengeStats stats = + new TrainingStatsStore.PlayerChallengeStats(100L, List.of(100L, 200L)); + + assertThrows(UnsupportedOperationException.class, () -> stats.lastAttemptsMs() + .add(300L)); + } + + private static JavaPlugin pluginWithDataFolder(File dataFolder) { + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + return plugin; + } +} diff --git a/src/test/java/dev/deepcore/challenge/ui/PrepGuiRendererTest.java b/src/test/java/dev/deepcore/challenge/ui/PrepGuiRendererTest.java index daddb1d..cef5ce5 100644 --- a/src/test/java/dev/deepcore/challenge/ui/PrepGuiRendererTest.java +++ b/src/test/java/dev/deepcore/challenge/ui/PrepGuiRendererTest.java @@ -107,6 +107,7 @@ void populatePages_renderExpectedControlsAndHistoryPaging() { renderer.populateCategoriesPage(categories, true, 2, 3, true); assertEquals(Material.EMERALD_BLOCK, categories.getItem(47).getType()); assertTrue(categories.getItem(51).getItemMeta().getLore().get(0).contains("refresh pedestal preview")); + assertEquals(Material.ENDER_PEARL, categories.getItem(53).getType()); Inventory inventoryPage = Bukkit.createInventory(null, 54, "inventory"); renderer.populateInventoryPage(inventoryPage, challengeManager, false, 1, 3, false); diff --git a/src/test/java/dev/deepcore/challenge/world/WorldClassificationServiceTest.java b/src/test/java/dev/deepcore/challenge/world/WorldClassificationServiceTest.java new file mode 100644 index 0000000..895399e --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/world/WorldClassificationServiceTest.java @@ -0,0 +1,81 @@ +package dev.deepcore.challenge.world; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import dev.deepcore.challenge.config.ChallengeConfigView; +import java.util.function.Supplier; +import org.bukkit.World; +import org.junit.jupiter.api.Test; + +class WorldClassificationServiceTest { + + @Test + void isLobbyOrLimboWorld_handlesNullAndResetManagerLobby() { + ChallengeConfigView config = mock(ChallengeConfigView.class); + WorldResetManager resetManager = mock(WorldResetManager.class); + WorldClassificationService service = new WorldClassificationService(config, () -> resetManager); + + assertFalse(service.isLobbyOrLimboWorld(null)); + + World world = mock(World.class); + when(resetManager.isLobbyWorld(world)).thenReturn(true); + assertTrue(service.isLobbyOrLimboWorld(world)); + } + + @Test + void isLobbyOrLimboWorld_fallsBackToConfiguredNames() { + ChallengeConfigView config = mock(ChallengeConfigView.class); + when(config.limboWorldName()).thenReturn("deepcore_limbo"); + when(config.lobbyOverworldWorldName()).thenReturn("deepcore_lobby_overworld"); + when(config.lobbyNetherWorldName()).thenReturn("deepcore_lobby_nether"); + + Supplier noResetManager = () -> null; + WorldClassificationService service = new WorldClassificationService(config, noResetManager); + + World limbo = mock(World.class); + when(limbo.getName()).thenReturn("DEEPCORE_LIMBO"); + assertTrue(service.isLobbyOrLimboWorld(limbo)); + + World lobbyOverworld = mock(World.class); + when(lobbyOverworld.getName()).thenReturn("deepcore_lobby_overworld"); + assertTrue(service.isLobbyOrLimboWorld(lobbyOverworld)); + + World lobbyNether = mock(World.class); + when(lobbyNether.getName()).thenReturn("deepcore_lobby_nether"); + assertTrue(service.isLobbyOrLimboWorld(lobbyNether)); + + World runWorld = mock(World.class); + when(runWorld.getName()).thenReturn("deepcore_run"); + assertFalse(service.isLobbyOrLimboWorld(runWorld)); + } + + @Test + void trainingAndPrepBorderExemption_coverBranches() { + ChallengeConfigView config = mock(ChallengeConfigView.class); + when(config.trainingWorldName()).thenReturn("deepcore_gym"); + when(config.limboWorldName()).thenReturn("deepcore_limbo"); + when(config.lobbyOverworldWorldName()).thenReturn("deepcore_lobby_overworld"); + when(config.lobbyNetherWorldName()).thenReturn("deepcore_lobby_nether"); + + WorldResetManager resetManager = mock(WorldResetManager.class); + WorldClassificationService service = new WorldClassificationService(config, () -> resetManager); + + World training = mock(World.class); + when(training.getName()).thenReturn("DEEPCORE_GYM"); + when(resetManager.isLobbyWorld(training)).thenReturn(false); + + World runWorld = mock(World.class); + when(runWorld.getName()).thenReturn("deepcore_run"); + when(resetManager.isLobbyWorld(runWorld)).thenReturn(false); + + assertTrue(service.isTrainingWorld(training)); + assertFalse(service.isTrainingWorld(null)); + + assertTrue(service.isPrepBorderExemptWorld(training)); + assertFalse(service.isPrepBorderExemptWorld(runWorld)); + assertFalse(service.isPrepBorderExemptWorld(null)); + } +} diff --git a/src/test/java/dev/deepcore/challenge/world/WorldResetManagerBehaviorTest.java b/src/test/java/dev/deepcore/challenge/world/WorldResetManagerBehaviorTest.java index 808d5d4..789423d 100644 --- a/src/test/java/dev/deepcore/challenge/world/WorldResetManagerBehaviorTest.java +++ b/src/test/java/dev/deepcore/challenge/world/WorldResetManagerBehaviorTest.java @@ -377,6 +377,47 @@ void cleanupNonDefaultWorldsOnStartup_deletesLikelyNonDefaultWorldDirectories() assertFalse(Files.exists(staleWorld)); } + @Test + void cleanupNonDefaultWorldsOnStartup_keepsConfiguredTrainingWorldDirectory() throws Exception { + Path worldContainer = Files.createTempDirectory("deepcore-worlds-"); + Files.writeString(worldContainer.resolve("server.properties"), "level-name=world\n"); + + Path defaultWorld = worldContainer.resolve("world"); + Path trainingWorld = worldContainer.resolve("deepcore_gym"); + Path staleWorld = worldContainer.resolve("old_run_world"); + Files.createDirectories(defaultWorld); + Files.createDirectories(trainingWorld); + Files.writeString(trainingWorld.resolve("level.dat"), "dummy"); + Files.createDirectories(staleWorld); + Files.writeString(staleWorld.resolve("level.dat"), "dummy"); + + DeepCorePlugin plugin = mock(DeepCorePlugin.class); + YamlConfiguration config = new YamlConfiguration(); + config.set("training.world", "deepcore_gym"); + when(plugin.getConfig()).thenReturn(config); + when(plugin.getDeepCoreLogger()).thenReturn(mock(DeepCoreLogger.class)); + Server server = mock(Server.class); + when(plugin.getServer()).thenReturn(server); + when(server.getWorldContainer()).thenReturn(worldContainer.toFile()); + + World defaultWorldObj = mock(World.class); + when(defaultWorldObj.getName()).thenReturn("world"); + + WorldResetManager manager = new WorldResetManager(plugin, mock(ChallengeSessionWorldBridge.class)); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getWorlds).thenReturn(List.of(defaultWorldObj)); + bukkit.when(() -> Bukkit.getWorld("world")).thenReturn(defaultWorldObj); + bukkit.when(() -> Bukkit.getWorld("old_run_world")).thenReturn(null); + bukkit.when(() -> Bukkit.getWorld("deepcore_gym")).thenReturn(null); + + manager.cleanupNonDefaultWorldsOnStartup(); + } + + assertTrue(Files.exists(defaultWorld)); + assertTrue(Files.exists(trainingWorld)); + assertFalse(Files.exists(staleWorld)); + } + @Test void cleanupNonDefaultWorldsOnStartup_keepsNonWorldDirectories() throws Exception { Path worldContainer = Files.createTempDirectory("deepcore-worlds-"); @@ -524,6 +565,42 @@ void getConfiguredLimboSpawn_returnsCenteredSpawnFromActiveLobbyWorld() throws E } } + @Test + void teleportOnlinePlayersToActiveLobby_usesConfiguredWorldAnchorCoordinates() throws Exception { + DeepCorePlugin plugin = mock(DeepCorePlugin.class); + YamlConfiguration config = new YamlConfiguration(); + config.set("challenge.preview_hologram_anchor.worlds.deepcore_lobby_overworld.enabled", true); + config.set("challenge.preview_hologram_anchor.worlds.deepcore_lobby_overworld.x", 21.5D); + config.set("challenge.preview_hologram_anchor.worlds.deepcore_lobby_overworld.y", 94.0D); + config.set("challenge.preview_hologram_anchor.worlds.deepcore_lobby_overworld.z", -41.5D); + when(plugin.getConfig()).thenReturn(config); + when(plugin.getDeepCoreLogger()).thenReturn(mock(DeepCoreLogger.class)); + + WorldResetManager manager = new WorldResetManager(plugin, mock(ChallengeSessionWorldBridge.class)); + + World lobby = mock(World.class); + UUID lobbyId = UUID.randomUUID(); + when(lobby.getUID()).thenReturn(lobbyId); + when(lobby.getName()).thenReturn("deepcore_lobby_overworld"); + when(lobby.getSpawnLocation()).thenReturn(new Location(lobby, 0.0D, 64.0D, 0.0D)); + setField(manager, "activeLobbyWorldId", lobbyId); + + Player player = mock(Player.class); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getWorld(lobbyId)).thenReturn(lobby); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(Set.of(player)); + + int moved = manager.teleportOnlinePlayersToActiveLobby(); + org.junit.jupiter.api.Assertions.assertEquals(1, moved); + } + + verify(player).teleport((Location) org.mockito.ArgumentMatchers.argThat((Location location) -> location != null + && Math.abs(location.getX() - 21.5D) < 0.001D + && Math.abs(location.getY() - 94.0D) < 0.001D + && Math.abs(location.getZ() - -41.5D) < 0.001D)); + } + @Test void getCurrentOverworld_prefersConfiguredThenFallsBackToFirstNormalNonLobbyWorld() { DeepCorePlugin plugin = mock(DeepCorePlugin.class); diff --git a/src/test/java/dev/deepcore/records/RunRecordsServiceTest.java b/src/test/java/dev/deepcore/records/RunRecordsServiceTest.java index 7c59532..976bd6b 100644 --- a/src/test/java/dev/deepcore/records/RunRecordsServiceTest.java +++ b/src/test/java/dev/deepcore/records/RunRecordsServiceTest.java @@ -47,7 +47,7 @@ void initialize_successfullyOpensConnectionAndCreatesSchema() throws Exception { service.initialize(); } - verify(log).info(org.mockito.ArgumentMatchers.contains("initialized successfully")); + verify(log).debug(org.mockito.ArgumentMatchers.contains("initialized successfully")); } @Test @@ -301,7 +301,7 @@ void initialize_recreatesLegacySchemaWhenPlayerUuidColumnExists() throws Excepti verify(dropStmt).executeUpdate("DROP TABLE IF EXISTS run_records"); verify(log).warn(org.mockito.ArgumentMatchers.contains("Legacy run_records schema detected")); - verify(log).info(org.mockito.ArgumentMatchers.contains("run_records table recreated")); + verify(log).debug(org.mockito.ArgumentMatchers.contains("run_records table recreated")); verify(createStmt) .executeUpdate(org.mockito.ArgumentMatchers.contains("CREATE TABLE IF NOT EXISTS run_records")); verify(recreateStmt)