From 1dd424c54912a8fec23bfea31374a51bf732a157 Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Sun, 10 May 2026 20:02:20 -0400 Subject: [PATCH] feat: add difficulty setting, shared inventory fixes, completed run config #3 #4 #5 #6 --- .github/ISSUE_TEMPLATE/task.md | 18 + .github/pull_request_template.md | 15 + .../challenge/ChallengeDifficulty.java | 45 ++ .../deepcore/challenge/ChallengeManager.java | 18 +- .../deepcore/challenge/ChallengeRuntime.java | 2 +- .../ChallengeRuntimeInitializer.java | 8 +- .../challenge/ChallengeSessionManager.java | 172 +++++++- .../{ => command}/ChallengeAdminFacade.java | 27 +- .../{ => command}/ChallengeCommand.java | 4 +- .../ChallengeCoreCommandHandler.java | 39 +- .../ChallengeLogsCommandHandler.java | 2 +- .../challenge/{ => command}/LobbyCommand.java | 3 +- .../challenge/command/package-info.java | 2 + .../events/InventoryMechanicsListener.java | 6 + .../InventoryMechanicsCoordinatorService.java | 127 +++++- .../inventory/SharedInventorySyncService.java | 175 +++++++- .../preview/PreviewOrchestratorService.java | 3 + .../{ => challenge}/records/RunRecord.java | 43 +- .../records/RunRecordsService.java | 75 +++- .../{ => challenge}/records/package-info.java | 2 +- .../session/PlayerLobbyStateService.java | 8 +- .../session/PrepGuiCoordinatorService.java | 29 +- .../challenge/session/PrepGuiFlowService.java | 30 +- .../session/PrepSettingsService.java | 5 + .../session/RunCompletionService.java | 24 +- .../session/RunHealthCoordinatorService.java | 3 +- .../challenge/session/RunProgressService.java | 63 +++ .../challenge/session/RunSaveVoteService.java | 124 ++++++ .../session/SavedRunStateService.java | 413 ++++++++++++++++++ .../session/SessionFailureService.java | 4 + .../SessionPlayerLifecycleService.java | 57 ++- .../SessionRulesCoordinatorService.java | 5 + .../challenge/session/SessionTimingState.java | 14 + .../SessionTransitionOrchestratorService.java | 4 +- .../session/SessionUiCoordinatorService.java | 4 +- .../session/SidebarModelFactory.java | 2 +- .../challenge/training/TrainingManager.java | 44 +- .../challenge/{ => ui}/PrepGuiPage.java | 2 +- .../challenge/ui/PrepGuiRenderer.java | 98 ++++- .../challenge/vitals/SharedVitalsService.java | 28 +- src/main/resources/config.yml | 3 + .../java/dev/deepcore/DeepCorePluginTest.java | 6 +- .../ChallengeCoreCommandHandlerTest.java | 37 ++ .../ChallengeLogsCommandHandlerTest.java | 1 + .../ChallengeRuntimeInitializerTest.java | 6 +- .../ChallengeSessionManagerSmokeTest.java | 5 +- .../ChallengeAdminFacadeTest.java | 6 +- .../{ => command}/ChallengeCommandTest.java | 6 +- .../{ => command}/LobbyCommandTest.java | 3 +- .../InventoryMechanicsListenerTest.java | 42 ++ ...entoryMechanicsCoordinatorServiceTest.java | 224 +++++++++- .../SharedInventorySyncServiceTest.java | 252 +++++++++-- ...reviewOrchestratorServiceBehaviorTest.java | 4 + .../challenge/records/RunRecordTest.java | 75 ++++ .../records/RunRecordsServiceTest.java | 97 +++- .../{ => challenge}/records/package-info.java | 2 +- .../session/PlayerLobbyStateServiceTest.java | 23 +- .../PrepGuiCoordinatorServiceTest.java | 17 +- .../session/PrepGuiFlowServiceTest.java | 97 +++- .../session/PrepSettingsServiceTest.java | 49 +-- .../session/RunCompletionServiceTest.java | 72 ++- .../RunHealthCoordinatorServiceTest.java | 33 ++ .../session/RunProgressServiceTest.java | 57 +++ .../session/RunSaveVoteServiceTest.java | 246 +++++++++++ .../session/SavedRunStateServiceTest.java | 340 ++++++++++++++ .../session/SessionFailureServiceTest.java | 31 ++ .../SessionPlayerLifecycleServiceTest.java | 314 ++++++++++++- .../SessionRulesCoordinatorServiceTest.java | 2 + .../session/SessionTimingStateTest.java | 23 + ...sionTransitionOrchestratorServiceTest.java | 1 + .../session/SidebarModelFactoryTest.java | 2 +- .../training/TrainingManagerTest.java | 11 +- .../challenge/ui/PrepGuiRendererTest.java | 20 +- .../vitals/SharedVitalsServiceTest.java | 73 ++++ .../dev/deepcore/records/RunRecordTest.java | 43 -- 75 files changed, 3659 insertions(+), 311 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/task.md create mode 100644 .github/pull_request_template.md create mode 100644 src/main/java/dev/deepcore/challenge/ChallengeDifficulty.java rename src/main/java/dev/deepcore/challenge/{ => command}/ChallengeAdminFacade.java (91%) rename src/main/java/dev/deepcore/challenge/{ => command}/ChallengeCommand.java (94%) rename src/main/java/dev/deepcore/challenge/{ => command}/ChallengeCoreCommandHandler.java (91%) rename src/main/java/dev/deepcore/challenge/{ => command}/ChallengeLogsCommandHandler.java (99%) rename src/main/java/dev/deepcore/challenge/{ => command}/LobbyCommand.java (94%) create mode 100644 src/main/java/dev/deepcore/challenge/command/package-info.java rename src/main/java/dev/deepcore/{ => challenge}/records/RunRecord.java (70%) rename src/main/java/dev/deepcore/{ => challenge}/records/RunRecordsService.java (78%) rename src/main/java/dev/deepcore/{ => challenge}/records/package-info.java (68%) create mode 100644 src/main/java/dev/deepcore/challenge/session/RunSaveVoteService.java create mode 100644 src/main/java/dev/deepcore/challenge/session/SavedRunStateService.java rename src/main/java/dev/deepcore/challenge/{ => ui}/PrepGuiPage.java (89%) rename src/test/java/dev/deepcore/challenge/{ => command}/ChallengeAdminFacadeTest.java (95%) rename src/test/java/dev/deepcore/challenge/{ => command}/ChallengeCommandTest.java (94%) rename src/test/java/dev/deepcore/challenge/{ => command}/LobbyCommandTest.java (96%) create mode 100644 src/test/java/dev/deepcore/challenge/events/InventoryMechanicsListenerTest.java create mode 100644 src/test/java/dev/deepcore/challenge/records/RunRecordTest.java rename src/test/java/dev/deepcore/{ => challenge}/records/RunRecordsServiceTest.java (77%) rename src/test/java/dev/deepcore/{ => challenge}/records/package-info.java (66%) create mode 100644 src/test/java/dev/deepcore/challenge/session/RunSaveVoteServiceTest.java create mode 100644 src/test/java/dev/deepcore/challenge/session/SavedRunStateServiceTest.java delete mode 100644 src/test/java/dev/deepcore/records/RunRecordTest.java diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..3885d3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,18 @@ +--- +name: Task +about: Create a new task +labels: task +assignees: "" +--- + +### Summary: + +[Summary of the task] + +### Description: + +[Detailed description of the task] + +--- + +**Estimated Ideal Time:** [Estimated time to complete the task] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ed2a068 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +### Proposed Changes: + +[Brief description of the changes made in this pull request] + +### Details: + +- [Detail 1] + +### Related Issues: + +- Resolves #[Issue nb] + +### Screenshot: + +[Optional section. Add UI/code screenshots if needed.] diff --git a/src/main/java/dev/deepcore/challenge/ChallengeDifficulty.java b/src/main/java/dev/deepcore/challenge/ChallengeDifficulty.java new file mode 100644 index 0000000..953ce46 --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/ChallengeDifficulty.java @@ -0,0 +1,45 @@ +package dev.deepcore.challenge; + +import java.util.Optional; + +/** Represents the selectable difficulty level for a challenge run. */ +public enum ChallengeDifficulty { + EASY("easy", "Easy"), + NORMAL("normal", "Normal"), + HARD("hard", "Hard"); + + private final String key; + private final String displayName; + + ChallengeDifficulty(String key, String displayName) { + this.key = key; + this.displayName = displayName; + } + + public String key() { + return key; + } + + public String displayName() { + return displayName; + } + + /** + * Returns the next difficulty in the cycle, wrapping from HARD back to EASY. + * + * @return next difficulty in the cycle + */ + public ChallengeDifficulty next() { + ChallengeDifficulty[] values = values(); + return values[(ordinal() + 1) % values.length]; + } + + public static Optional fromKey(String key) { + for (ChallengeDifficulty d : values()) { + if (d.key.equalsIgnoreCase(key)) { + return Optional.of(d); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/dev/deepcore/challenge/ChallengeManager.java b/src/main/java/dev/deepcore/challenge/ChallengeManager.java index 8f983dd..699d736 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeManager.java +++ b/src/main/java/dev/deepcore/challenge/ChallengeManager.java @@ -6,17 +6,19 @@ import org.bukkit.plugin.java.JavaPlugin; /** - * Stores and persists challenge enablement, mode, and component toggle state. + * Stores and persists challenge enablement, mode, component toggle state, and difficulty. */ public final class ChallengeManager { private static final String ENABLED_PATH = "challenge.enabled"; private static final String MODE_PATH = "challenge.mode"; + private static final String DIFFICULTY_PATH = "challenge.difficulty"; private static final String COMPONENTS_PATH = "challenge.components"; private static final String LEGACY_UNLIMITED_DEATHS_PATH = "challenge.components.unlimited_deaths"; private final JavaPlugin plugin; private boolean enabled; private ChallengeMode mode; + private ChallengeDifficulty difficulty; private final Map componentToggles; /** @@ -28,6 +30,7 @@ public ChallengeManager(JavaPlugin plugin) { this.plugin = plugin; this.enabled = false; this.mode = ChallengeMode.KEEP_INVENTORY_UNLIMITED_DEATHS; + this.difficulty = ChallengeDifficulty.NORMAL; this.componentToggles = new EnumMap<>(ChallengeComponent.class); applyModeDefaults(); } @@ -42,6 +45,9 @@ public void loadFromConfig() { String configuredMode = config.getString(MODE_PATH, ChallengeMode.KEEP_INVENTORY_UNLIMITED_DEATHS.key()); mode = ChallengeMode.fromKey(configuredMode).orElse(ChallengeMode.KEEP_INVENTORY_UNLIMITED_DEATHS); + String configuredDifficulty = config.getString(DIFFICULTY_PATH, ChallengeDifficulty.NORMAL.key()); + difficulty = ChallengeDifficulty.fromKey(configuredDifficulty).orElse(ChallengeDifficulty.NORMAL); + applyModeDefaults(); for (ChallengeComponent component : ChallengeComponent.values()) { String path = componentPath(component); @@ -65,6 +71,7 @@ public void saveToConfig() { FileConfiguration config = plugin.getConfig(); config.set(ENABLED_PATH, enabled); config.set(MODE_PATH, mode.key()); + config.set(DIFFICULTY_PATH, difficulty.key()); for (ChallengeComponent component : ChallengeComponent.values()) { config.set(componentPath(component), componentToggles.get(component)); } @@ -79,6 +86,10 @@ public ChallengeMode getMode() { return mode; } + public ChallengeDifficulty getDifficulty() { + return difficulty; + } + public boolean isComponentEnabled(ChallengeComponent component) { return componentToggles.getOrDefault(component, false); } @@ -98,6 +109,11 @@ public void setMode(ChallengeMode mode) { saveToConfig(); } + public void setDifficulty(ChallengeDifficulty difficulty) { + this.difficulty = difficulty; + saveToConfig(); + } + /** * Enables or disables a specific challenge component and persists settings. * diff --git a/src/main/java/dev/deepcore/challenge/ChallengeRuntime.java b/src/main/java/dev/deepcore/challenge/ChallengeRuntime.java index 32c3e7a..8d41504 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeRuntime.java +++ b/src/main/java/dev/deepcore/challenge/ChallengeRuntime.java @@ -1,8 +1,8 @@ package dev.deepcore.challenge; +import dev.deepcore.challenge.records.RunRecordsService; import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; -import dev.deepcore.records.RunRecordsService; /** * Holds initialized challenge runtime services for plugin lifecycle access. diff --git a/src/main/java/dev/deepcore/challenge/ChallengeRuntimeInitializer.java b/src/main/java/dev/deepcore/challenge/ChallengeRuntimeInitializer.java index 8cd5670..e9925e3 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeRuntimeInitializer.java +++ b/src/main/java/dev/deepcore/challenge/ChallengeRuntimeInitializer.java @@ -1,10 +1,12 @@ package dev.deepcore.challenge; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.command.ChallengeCommand; +import dev.deepcore.challenge.command.LobbyCommand; +import dev.deepcore.challenge.records.RunRecordsService; import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; -import dev.deepcore.records.RunRecordsService; import org.bukkit.command.PluginCommand; /** @@ -30,7 +32,9 @@ public ChallengeRuntime initialize(DeepCorePlugin plugin, DeepCoreLogger logger) worldResetManager.cleanupNonDefaultWorldsOnStartup(); challengeSessionManager.setWorldResetManager(worldResetManager); - RunRecordsService recordsService = new RunRecordsService(plugin); + String dbFileName = plugin.getConfig().getString("records.db-name", "records.db"); + logger.info("Run records database: " + dbFileName); + RunRecordsService recordsService = new RunRecordsService(plugin, dbFileName); recordsService.initialize(); challengeSessionManager.setRecordsService(recordsService); diff --git a/src/main/java/dev/deepcore/challenge/ChallengeSessionManager.java b/src/main/java/dev/deepcore/challenge/ChallengeSessionManager.java index 8f452ba..9f13509 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeSessionManager.java +++ b/src/main/java/dev/deepcore/challenge/ChallengeSessionManager.java @@ -30,10 +30,12 @@ import dev.deepcore.challenge.session.RunHealthCoordinatorService; import dev.deepcore.challenge.session.RunPauseResumeService; import dev.deepcore.challenge.session.RunProgressService; +import dev.deepcore.challenge.session.RunSaveVoteService; import dev.deepcore.challenge.session.RunStartGuardService; import dev.deepcore.challenge.session.RunStartService; import dev.deepcore.challenge.session.RunStatusService; import dev.deepcore.challenge.session.RunUiFormattingService; +import dev.deepcore.challenge.session.SavedRunStateService; import dev.deepcore.challenge.session.SessionFailureService; import dev.deepcore.challenge.session.SessionOperationService; import dev.deepcore.challenge.session.SessionParticipantContextService; @@ -56,11 +58,15 @@ import dev.deepcore.logging.DeepCoreLogger; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.bukkit.ChatColor; import org.bukkit.Location; import org.bukkit.Material; @@ -133,6 +139,8 @@ public final class ChallengeSessionManager implements ChallengeSessionWorldBridg private final SessionTransitionOrchestratorService sessionTransitionOrchestratorService; private final ChallengeEventRegistrar challengeEventRegistrar; private final RunPauseResumeService runPauseResumeService; + private final SavedRunStateService savedRunStateService; + private final RunSaveVoteService runSaveVoteService; private final DegradingInventoryTickerService degradingInventoryTickerService; private final SessionUiCoordinatorService sessionUiCoordinatorService; private final SidebarModelFactory sidebarModelFactory; @@ -142,7 +150,7 @@ public final class ChallengeSessionManager implements ChallengeSessionWorldBridg private final CompletionReturnService completionReturnService; private final TaskGroup taskGroup; private WorldResetManager worldResetManager; - private dev.deepcore.records.RunRecordsService recordsService; + private dev.deepcore.challenge.records.RunRecordsService recordsService; private final SessionState sessionState; @@ -302,6 +310,9 @@ public ChallengeSessionManager(JavaPlugin plugin, ChallengeManager challengeMana prepCountdownService, worldClassificationService, log); + this.savedRunStateService = new SavedRunStateService(plugin, log); + this.runSaveVoteService = + new RunSaveVoteService(plugin, log, sessionParticipantContextService::getOnlineParticipants); this.prepSettingsService = new PrepSettingsService(challengeManager, this::syncWorldRules, this::applySharedVitalsIfEnabled); this.prepGuiFlowService = @@ -325,6 +336,8 @@ public ChallengeSessionManager(JavaPlugin plugin, ChallengeManager challengeMana runStartGuardService::isDiscoPreviewBlockingChallengeStart, runStartGuardService::announceDiscoPreviewStartBlocked, this::startRun, + savedRunStateService::hasSavedRun, + () -> restoreSavedRun(org.bukkit.Bukkit.getConsoleSender()), PREP_GUI_TITLE, RUN_HISTORY_DATE_FORMATTER); this.lobbySidebarCoordinatorService = new LobbySidebarCoordinatorService( @@ -352,6 +365,11 @@ public ChallengeSessionManager(JavaPlugin plugin, ChallengeManager challengeMana participants, () -> worldResetManager, this::endChallengeAndReturnToPrep, + () -> challengeManager.getComponentToggles().entrySet().stream() + .filter(Map.Entry::getValue) + .map(Map.Entry::getKey) + .collect(Collectors.toList()), + () -> challengeManager.getDifficulty().key(), log); this.respawnRoutingService = new RespawnRoutingService( () -> worldResetManager, @@ -474,7 +492,7 @@ public void setWorldResetManager(WorldResetManager worldResetManager) { this.worldResetManager = worldResetManager; } - public void setRecordsService(dev.deepcore.records.RunRecordsService recordsService) { + public void setRecordsService(dev.deepcore.challenge.records.RunRecordsService recordsService) { this.recordsService = recordsService; } @@ -591,6 +609,156 @@ public boolean resumeChallenge(CommandSender sender) { return runPauseResumeService.resume(sender); } + /** + * Records a vote from the given player to save the current run. + * When all online participants have voted, saves the run to disk and returns to prep. + * + * @param voter player casting the save vote + * @return false when the vote was rejected (not a participant, already voted, etc.) + */ + public boolean castSaveVote(Player voter) { + if (!sessionState.is(SessionState.Phase.RUNNING)) { + log.sendError(voter, "Can only vote to save during an active run."); + return false; + } + return runSaveVoteService.castVote(voter, this::saveRunAndReturnToPrep); + } + + /** + * Restores a previously saved run, applying snapshots to online participants + * and resuming from the saved phase. + * + * @param sender command sender requesting the restore + * @return true when the restore was accepted and applied + */ + public boolean restoreSavedRun(CommandSender sender) { + if (!sessionState.is(SessionState.Phase.PREP)) { + log.sendError(sender, "Can only restore a saved run during prep phase."); + return false; + } + + Optional optSnapshot = savedRunStateService.loadSavedRun(); + if (optSnapshot.isEmpty()) { + log.sendError(sender, "No saved run found."); + return false; + } + + SavedRunStateService.SavedRunSnapshot snapshot = optSnapshot.get(); + + Set savedIds = new HashSet<>(); + for (String uuidStr : snapshot.participantUuids()) { + try { + savedIds.add(UUID.fromString(uuidStr)); + } catch (IllegalArgumentException ignored) { + } + } + + List online = new ArrayList<>(); + for (UUID uuid : savedIds) { + Player p = org.bukkit.Bukkit.getPlayer(uuid); + if (p != null) { + online.add(p); + } + } + + if (online.isEmpty()) { + log.sendError(sender, "No saved participants are currently online."); + return false; + } + + participants.clear(); + for (Player p : online) { + participants.add(p.getUniqueId()); + } + readyPlayers.clear(); + + sessionState.timing().restore(snapshot.runStartMs(), snapshot.accumulatedPausedMs(), snapshot.savedAtMs()); + runProgressService.restore( + snapshot.reachedNether(), snapshot.netherMs(), + snapshot.reachedBlazeObjective(), snapshot.blazeObjectiveMs(), + snapshot.reachedEnd(), snapshot.endMs()); + + if (!challengeManager.isEnabled()) { + challengeManager.setEnabled(true); + } + sessionState.setPhase(SessionState.Phase.RUNNING); + sessionOperationService.clearPausedSnapshots(); + syncWorldRules(); + prepAreaService.clearBorders(); + previewOrchestratorService.clearLobbyPreviewEntities(); + runStatusService.reset(); + + for (Player p : online) { + SavedRunStateService.PlayerSnapshot ps = + snapshot.playerSnapshots().get(p.getUniqueId().toString()); + if (ps != null) { + SavedRunStateService.applySnapshot(p, ps); + } + prepBookService.removeFromInventory(p); + } + for (Player all : org.bukkit.Bukkit.getOnlinePlayers()) { + sessionOperationService.clearLobbySidebar(all); + } + + sessionOperationService.startActionBarTask(); + sessionOperationService.snapshotEquippedWearablesForParticipants(); + + savedRunStateService.clearSavedRun(); + sessionOperationService.refreshOpenPrepGuis(); + + org.bukkit.Bukkit.broadcastMessage(ChatColor.GOLD + "[DeepCore] " + ChatColor.GREEN + "Saved run restored! " + + online.size() + " participant(s) back in action."); + return true; + } + + private void saveRunAndReturnToPrep() { + if (!sessionState.is(SessionState.Phase.RUNNING)) { + return; + } + + long now = System.currentTimeMillis(); + long runStartMs = sessionState.timing().getRunStartMillis(); + long accumulatedPausedMs = sessionState.timing().getAccumulatedPausedMillis(); + + List participantUuids = + participants.stream().map(UUID::toString).collect(Collectors.toList()); + + List enabledComponents = challengeManager.getComponentToggles().entrySet().stream() + .filter(Map.Entry::getValue) + .map(e -> e.getKey().key()) + .collect(Collectors.toList()); + + Map playerSnapshots = new HashMap<>(); + for (UUID uuid : participants) { + Player p = org.bukkit.Bukkit.getPlayer(uuid); + if (p != null) { + playerSnapshots.put(uuid.toString(), SavedRunStateService.capturePlayer(p)); + } + } + + SavedRunStateService.SavedRunSnapshot snapshot = new SavedRunStateService.SavedRunSnapshot( + now, + runStartMs, + accumulatedPausedMs, + runProgressService.hasReachedNether(), + runProgressService.getNetherReachedMillis(), + runProgressService.hasReachedBlazeObjective(), + runProgressService.getBlazeObjectiveReachedMillis(), + runProgressService.hasReachedEnd(), + runProgressService.getEndReachedMillis(), + participantUuids, + enabledComponents, + challengeManager.getDifficulty().key(), + playerSnapshots); + + savedRunStateService.saveRun(snapshot); + runSaveVoteService.clearVotes(); + + org.bukkit.Bukkit.broadcastMessage(ChatColor.GOLD + "[DeepCore] " + ChatColor.GREEN + + "Run saved to disk! Returning to prep. Use /challenge restore to resume."); + endChallengeAndReturnToPrep(); + } + /** * Synchronizes shared health/hunger immediately when shared-health mode is * enabled. diff --git a/src/main/java/dev/deepcore/challenge/ChallengeAdminFacade.java b/src/main/java/dev/deepcore/challenge/command/ChallengeAdminFacade.java similarity index 91% rename from src/main/java/dev/deepcore/challenge/ChallengeAdminFacade.java rename to src/main/java/dev/deepcore/challenge/command/ChallengeAdminFacade.java index 97e8e61..7ce4991 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeAdminFacade.java +++ b/src/main/java/dev/deepcore/challenge/command/ChallengeAdminFacade.java @@ -1,12 +1,17 @@ -package dev.deepcore.challenge; +package dev.deepcore.challenge.command; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.ChallengeComponent; +import dev.deepcore.challenge.ChallengeManager; +import dev.deepcore.challenge.ChallengeMode; +import dev.deepcore.challenge.ChallengeSessionManager; 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; +import org.bukkit.entity.Player; /** * Facade for challenge command orchestration across manager/session/world @@ -168,6 +173,26 @@ public boolean resumeChallenge(CommandSender sender) { return challengeSessionManager.resumeChallenge(sender); } + /** + * Records a save-run vote from the given player. + * + * @param voter player casting the vote + * @return false when the vote was rejected + */ + public boolean castSaveVote(Player voter) { + return challengeSessionManager.castSaveVote(voter); + } + + /** + * Restores a previously saved run. + * + * @param sender command sender requesting the restore + * @return true when restore was accepted and applied + */ + public boolean restoreSavedRun(CommandSender sender) { + return challengeSessionManager.restoreSavedRun(sender); + } + /** * Triggers an immediate world reset. * diff --git a/src/main/java/dev/deepcore/challenge/ChallengeCommand.java b/src/main/java/dev/deepcore/challenge/command/ChallengeCommand.java similarity index 94% rename from src/main/java/dev/deepcore/challenge/ChallengeCommand.java rename to src/main/java/dev/deepcore/challenge/command/ChallengeCommand.java index f9654f2..445e4db 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeCommand.java +++ b/src/main/java/dev/deepcore/challenge/command/ChallengeCommand.java @@ -1,6 +1,8 @@ -package dev.deepcore.challenge; +package dev.deepcore.challenge.command; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.ChallengeManager; +import dev.deepcore.challenge.ChallengeSessionManager; import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import java.util.List; diff --git a/src/main/java/dev/deepcore/challenge/ChallengeCoreCommandHandler.java b/src/main/java/dev/deepcore/challenge/command/ChallengeCoreCommandHandler.java similarity index 91% rename from src/main/java/dev/deepcore/challenge/ChallengeCoreCommandHandler.java rename to src/main/java/dev/deepcore/challenge/command/ChallengeCoreCommandHandler.java index d4c32dc..f81065a 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeCoreCommandHandler.java +++ b/src/main/java/dev/deepcore/challenge/command/ChallengeCoreCommandHandler.java @@ -1,5 +1,7 @@ -package dev.deepcore.challenge; +package dev.deepcore.challenge.command; +import dev.deepcore.challenge.ChallengeComponent; +import dev.deepcore.challenge.ChallengeMode; import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.logging.DeepCoreLogger; import java.util.ArrayList; @@ -10,6 +12,7 @@ import java.util.stream.Collectors; import org.bukkit.ChatColor; import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; /** * Handles non-logs /challenge subcommands and tab completion. @@ -174,6 +177,36 @@ public boolean handle(CommandSender sender, String[] args) { } return true; } + case "saverun" -> { + if (!(sender instanceof Player player)) { + sendInfo(sender, ChatColor.RED + "Only players can vote to save a run."); + return true; + } + + if (!adminFacade.isRunningPhase()) { + sendInfo(sender, ChatColor.YELLOW + "Challenge is not currently running."); + return true; + } + + adminFacade.castSaveVote(player); + return true; + } + case "restore" -> { + if (!sender.hasPermission("deepcore.challenge.admin")) { + sendInfo(sender, ChatColor.RED + "You do not have permission to restore saved runs."); + return true; + } + + if (!adminFacade.isPrepPhase()) { + sendInfo(sender, ChatColor.YELLOW + "Can only restore a saved run during prep phase."); + return true; + } + + if (!adminFacade.restoreSavedRun(sender)) { + sendInfo(sender, ChatColor.RED + "Failed to restore saved run."); + } + return true; + } case "reset", "resetworld" -> { if (!sender.hasPermission("deepcore.challenge.reset")) { sendInfo(sender, ChatColor.RED + "You do not have permission to reset worlds."); @@ -229,7 +262,7 @@ public boolean handle(CommandSender sender, String[] args) { sendInfo( sender, ChatColor.RED - + "Unknown subcommand. Use /challenge ."); + + "Unknown subcommand. Use /challenge ."); return true; } } @@ -257,6 +290,8 @@ public List tabComplete(String[] args) { "stop", "pause", "resume", + "saverun", + "restore", "reset", "resetworld", "lobby", diff --git a/src/main/java/dev/deepcore/challenge/ChallengeLogsCommandHandler.java b/src/main/java/dev/deepcore/challenge/command/ChallengeLogsCommandHandler.java similarity index 99% rename from src/main/java/dev/deepcore/challenge/ChallengeLogsCommandHandler.java rename to src/main/java/dev/deepcore/challenge/command/ChallengeLogsCommandHandler.java index 0020b40..8b5401f 100644 --- a/src/main/java/dev/deepcore/challenge/ChallengeLogsCommandHandler.java +++ b/src/main/java/dev/deepcore/challenge/command/ChallengeLogsCommandHandler.java @@ -1,4 +1,4 @@ -package dev.deepcore.challenge; +package dev.deepcore.challenge.command; import dev.deepcore.logging.DeepCoreLogLevel; import dev.deepcore.logging.DeepCoreLogger; diff --git a/src/main/java/dev/deepcore/challenge/LobbyCommand.java b/src/main/java/dev/deepcore/challenge/command/LobbyCommand.java similarity index 94% rename from src/main/java/dev/deepcore/challenge/LobbyCommand.java rename to src/main/java/dev/deepcore/challenge/command/LobbyCommand.java index 5c69148..b01b11b 100644 --- a/src/main/java/dev/deepcore/challenge/LobbyCommand.java +++ b/src/main/java/dev/deepcore/challenge/command/LobbyCommand.java @@ -1,5 +1,6 @@ -package dev.deepcore.challenge; +package dev.deepcore.challenge.command; +import dev.deepcore.challenge.ChallengeSessionManager; import dev.deepcore.challenge.training.TrainingManager; import org.bukkit.ChatColor; import org.bukkit.command.Command; diff --git a/src/main/java/dev/deepcore/challenge/command/package-info.java b/src/main/java/dev/deepcore/challenge/command/package-info.java new file mode 100644 index 0000000..5650351 --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/command/package-info.java @@ -0,0 +1,2 @@ +/** Command handlers and admin facade for the DeepCore challenge plugin. */ +package dev.deepcore.challenge.command; diff --git a/src/main/java/dev/deepcore/challenge/events/InventoryMechanicsListener.java b/src/main/java/dev/deepcore/challenge/events/InventoryMechanicsListener.java index 254c524..abf84cc 100644 --- a/src/main/java/dev/deepcore/challenge/events/InventoryMechanicsListener.java +++ b/src/main/java/dev/deepcore/challenge/events/InventoryMechanicsListener.java @@ -9,6 +9,7 @@ import org.bukkit.event.block.BlockMultiPlaceEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.EntityPlaceEvent; import org.bukkit.event.entity.EntityResurrectEvent; import org.bukkit.event.entity.ProjectileLaunchEvent; import org.bukkit.event.inventory.CraftItemEvent; @@ -173,4 +174,9 @@ public void onBlockPlace(BlockPlaceEvent event) { public void onBlockMultiPlace(BlockMultiPlaceEvent event) { inventoryMechanicsCoordinatorService.handleBlockMultiPlace(event); } + + @EventHandler(ignoreCancelled = true) + public void onEntityPlace(EntityPlaceEvent event) { + inventoryMechanicsCoordinatorService.handleEntityPlace(event); + } } diff --git a/src/main/java/dev/deepcore/challenge/inventory/InventoryMechanicsCoordinatorService.java b/src/main/java/dev/deepcore/challenge/inventory/InventoryMechanicsCoordinatorService.java index 9cac27d..4769bd3 100644 --- a/src/main/java/dev/deepcore/challenge/inventory/InventoryMechanicsCoordinatorService.java +++ b/src/main/java/dev/deepcore/challenge/inventory/InventoryMechanicsCoordinatorService.java @@ -16,6 +16,8 @@ import org.bukkit.Sound; import org.bukkit.SoundCategory; import org.bukkit.entity.AbstractArrow; +import org.bukkit.entity.Boat; +import org.bukkit.entity.ChestBoat; import org.bukkit.entity.Egg; import org.bukkit.entity.EnderPearl; import org.bukkit.entity.GlowItemFrame; @@ -30,9 +32,11 @@ import org.bukkit.event.block.BlockMultiPlaceEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.EntityPlaceEvent; import org.bukkit.event.entity.EntityResurrectEvent; import org.bukkit.event.entity.ProjectileLaunchEvent; import org.bukkit.event.inventory.CraftItemEvent; +import org.bukkit.event.inventory.InventoryAction; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCreativeEvent; import org.bukkit.event.inventory.InventoryDragEvent; @@ -70,6 +74,7 @@ public final class InventoryMechanicsCoordinatorService { private final Consumer enforceInventorySlotCap; private final DeepCoreLogger log; private final Map pendingWearableHotbarEquips; + private final Map pendingArmorEquipSourceSlots; /** * Creates an inventory mechanics coordinator service. @@ -110,6 +115,7 @@ public InventoryMechanicsCoordinatorService( this.enforceInventorySlotCap = enforceInventorySlotCap; this.log = log; this.pendingWearableHotbarEquips = new HashMap<>(); + this.pendingArmorEquipSourceSlots = new HashMap<>(); } /** @@ -130,8 +136,26 @@ public void handleInventoryClick(InventoryClickEvent event) { } requestInventorySlotCapEnforcement(player); - requestSharedInventorySync(player); + + if (event.getAction() == InventoryAction.MOVE_TO_OTHER_INVENTORY + && event.getClickedInventory() instanceof org.bukkit.inventory.PlayerInventory) { + ItemStack clicked = event.getCurrentItem(); + if (clicked != null && isArmorMaterial(clicked.getType())) { + pendingArmorEquipSourceSlots.put(player.getUniqueId(), event.getSlot()); + } + } + + // Wearable detect must be scheduled before the full inventory sync. + // Both use runTask and execute in FIFO order on the next tick. If the sync + // fires first it clears the equip source slot on other participants before the + // consume runs, causing the fallback search to remove the wrong item (the second + // set's copy instead of the one that was just equipped). requestWearableEquipSync(player); + + // InventoryClickEvent fires before Bukkit applies the slot mutation for both + // player and external inventories, so always defer to the next tick so the + // inventory state reflects the completed action when synced. + Bukkit.getScheduler().runTask(plugin, () -> requestSharedInventorySync(player)); } /** @@ -171,7 +195,9 @@ public void handleInventoryDrag(InventoryDragEvent event) { } requestInventorySlotCapEnforcement(player); - requestSharedInventorySync(player); + // InventoryDragEvent fires before Bukkit distributes items into the dragged slots, + // so defer to the next tick just like handleInventoryClick. + Bukkit.getScheduler().runTask(plugin, () -> requestSharedInventorySync(player)); requestWearableEquipSync(player); } @@ -191,6 +217,7 @@ public void handlePlayerDropItem(PlayerDropItemEvent event) { requestInventorySlotCapEnforcement(event.getPlayer()); requestSharedInventorySync(event.getPlayer()); + sharedInventorySyncService.flushPendingSharedInventorySync(); } /** @@ -210,6 +237,7 @@ public void handlePlayerSwapHandItems(PlayerSwapHandItemsEvent event) { requestInventorySlotCapEnforcement(event.getPlayer()); requestSharedInventorySync(event.getPlayer()); + sharedInventorySyncService.flushPendingSharedInventorySync(); } /** @@ -220,6 +248,7 @@ public void handlePlayerSwapHandItems(PlayerSwapHandItemsEvent event) { public void handlePlayerItemConsume(PlayerItemConsumeEvent event) { requestInventorySlotCapEnforcement(event.getPlayer()); requestSharedInventorySync(event.getPlayer()); + sharedInventorySyncService.flushPendingSharedInventorySync(); } /** @@ -234,6 +263,7 @@ public void handleCraftItem(CraftItemEvent event) { requestInventorySlotCapEnforcement(player); requestSharedInventorySync(player); + sharedInventorySyncService.flushPendingSharedInventorySync(); } /** @@ -245,6 +275,7 @@ public void handlePlayerItemHeld(PlayerItemHeldEvent event) { Player player = event.getPlayer(); requestInventorySlotCapEnforcement(player); requestSharedInventorySync(player); + sharedInventorySyncService.flushPendingSharedInventorySync(); } /** @@ -255,6 +286,7 @@ public void handlePlayerItemHeld(PlayerItemHeldEvent event) { public void handlePlayerItemDamage(PlayerItemDamageEvent event) { requestInventorySlotCapEnforcement(event.getPlayer()); requestSharedInventorySync(event.getPlayer()); + sharedInventorySyncService.flushPendingSharedInventorySync(); } /** @@ -264,7 +296,11 @@ public void handlePlayerItemDamage(PlayerItemDamageEvent event) { */ public void handlePlayerBucketEmpty(PlayerBucketEmptyEvent event) { requestInventorySlotCapEnforcement(event.getPlayer()); - requestSharedInventorySync(event.getPlayer()); + // Bucket item transform (water/lava bucket -> empty bucket) happens after the + // event fires, so syncing immediately broadcasts the pre-transform state. + // Defer by 1 tick so Bukkit completes the transform first, matching how + // boats and block-place events are handled. + scheduleDeterministicSourceSync(event.getPlayer(), 1L); } /** @@ -274,7 +310,9 @@ public void handlePlayerBucketEmpty(PlayerBucketEmptyEvent event) { */ public void handlePlayerBucketFill(PlayerBucketFillEvent event) { requestInventorySlotCapEnforcement(event.getPlayer()); - requestSharedInventorySync(event.getPlayer()); + // Same timing issue as bucket-empty: empty bucket -> filled bucket transform + // occurs after the event. Defer by 1 tick. + scheduleDeterministicSourceSync(event.getPlayer(), 1L); } /** @@ -326,8 +364,10 @@ public void handlePotentialWearableUse(PlayerInteractEvent event) { return; } + int sourceInventorySlot = + event.getHand() == EquipmentSlot.HAND ? player.getInventory().getHeldItemSlot() : 40; UUID playerId = player.getUniqueId(); - PendingWearableEquip pending = new PendingWearableEquip(equipSlot, usedItem.getType()); + PendingWearableEquip pending = new PendingWearableEquip(equipSlot, usedItem.getType(), sourceInventorySlot); pendingWearableHotbarEquips.put(playerId, pending); // The equip decision is finalized by handlePlayerArmorChanged. Here we only @@ -360,7 +400,7 @@ public void handlePotentialWearableUse(PlayerInteractEvent event) { pendingWearableHotbarEquips.remove(playerId); sharedInventorySyncService.consumeWearableFromOtherParticipants( - pending.material(), playerId); + pending.material(), playerId, pending.sourceInventorySlot()); sharedInventorySyncService.capturePlayerWearableSnapshot(player); log.debug("[shared-inv:armor-hotbar] equip fallback confirmed for " + player.getName() @@ -411,7 +451,8 @@ public void handlePlayerArmorChanged(PlayerArmorChangeEvent event) { } pendingWearableHotbarEquips.remove(playerId); - sharedInventorySyncService.consumeWearableFromOtherParticipants(pending.material(), playerId); + sharedInventorySyncService.consumeWearableFromOtherParticipants( + pending.material(), playerId, pending.sourceInventorySlot()); sharedInventorySyncService.capturePlayerWearableSnapshot(player); log.debug("[shared-inv:armor-hotbar] equip confirmed for " + player.getName() @@ -655,7 +696,7 @@ public void handleEntityPickupItem(EntityPickupItemEvent event) { + pickedItem.getType().name()); requestInventorySlotCapEnforcement(player); - requestSharedInventorySync(player); + Bukkit.getScheduler().runTaskLater(plugin, () -> requestSharedInventorySync(player), 1L); if (isChallengeActive.test(player) && isSharedInventoryEnabled.get()) { for (Player participant : onlineParticipantsSupplier.get()) { @@ -687,7 +728,59 @@ public void handlePlayerPickupArrow(PlayerPickupArrowEvent event) { */ public void handleBlockPlace(BlockPlaceEvent event) { requestInventorySlotCapEnforcement(event.getPlayer()); - requestSharedInventorySync(event.getPlayer()); + Bukkit.getScheduler().runTaskLater(plugin, () -> requestSharedInventorySync(event.getPlayer()), 1L); + } + + /** + * Handles entity placements (boats, minecarts) that consume an item from + * inventory. + * + * @param event entity place event to process + */ + public void handleEntityPlace(EntityPlaceEvent event) { + Player player = event.getPlayer(); + if (player == null) { + return; + } + requestInventorySlotCapEnforcement(player); + if (!isChallengeActive.test(player) || !isSharedInventoryEnabled.get()) { + return; + } + Material placedMaterial = resolveEntityPlaceMaterial(event.getEntity()); + if (placedMaterial != null) { + sharedInventorySyncService.consumeItemFromOtherParticipants(placedMaterial, player.getUniqueId()); + } else { + Bukkit.getScheduler().runTaskLater(plugin, () -> requestSharedInventorySync(player), 1L); + } + } + + private static Material resolveEntityPlaceMaterial(org.bukkit.entity.Entity entity) { + if (entity instanceof ChestBoat boat) { + Boat.Type type = boat.getBoatType(); + if (type == Boat.Type.BAMBOO) { + return Material.BAMBOO_CHEST_RAFT; + } + return Material.getMaterial(type.name() + "_CHEST_BOAT"); + } + if (entity instanceof Boat boat) { + Boat.Type type = boat.getBoatType(); + if (type == Boat.Type.BAMBOO) { + return Material.BAMBOO_RAFT; + } + return Material.getMaterial(type.name() + "_BOAT"); + } + org.bukkit.entity.EntityType type = entity.getType(); + if (type == null) { + return null; + } + return switch (type.name()) { + case "MINECART" -> Material.MINECART; + case "CHEST_MINECART" -> Material.CHEST_MINECART; + case "FURNACE_MINECART" -> Material.FURNACE_MINECART; + case "TNT_MINECART" -> Material.TNT_MINECART; + case "HOPPER_MINECART" -> Material.HOPPER_MINECART; + default -> null; + }; } /** @@ -697,7 +790,7 @@ public void handleBlockPlace(BlockPlaceEvent event) { */ public void handleBlockMultiPlace(BlockMultiPlaceEvent event) { requestInventorySlotCapEnforcement(event.getPlayer()); - requestSharedInventorySync(event.getPlayer()); + Bukkit.getScheduler().runTaskLater(plugin, () -> requestSharedInventorySync(event.getPlayer()), 1L); } private void requestSharedInventorySync(Player source) { @@ -705,7 +798,9 @@ private void requestSharedInventorySync(Player source) { } private void requestWearableEquipSync(Player source) { - sharedInventorySyncService.requestWearableEquipSync(source); + int sourceSlot = pendingArmorEquipSourceSlots.getOrDefault(source.getUniqueId(), -1); + pendingArmorEquipSourceSlots.remove(source.getUniqueId()); + sharedInventorySyncService.requestWearableEquipSync(source, sourceSlot); } private void requestInventorySlotCapEnforcement(Player player) { @@ -771,5 +866,13 @@ private EquipmentSlot toEquipmentSlot(PlayerArmorChangeEvent.SlotType slotType) }; } - private record PendingWearableEquip(EquipmentSlot slot, Material material) {} + private static boolean isArmorMaterial(Material material) { + EquipmentSlot slot = material.getEquipmentSlot(); + return slot == EquipmentSlot.HEAD + || slot == EquipmentSlot.CHEST + || slot == EquipmentSlot.LEGS + || slot == EquipmentSlot.FEET; + } + + private record PendingWearableEquip(EquipmentSlot slot, Material material, int sourceInventorySlot) {} } diff --git a/src/main/java/dev/deepcore/challenge/inventory/SharedInventorySyncService.java b/src/main/java/dev/deepcore/challenge/inventory/SharedInventorySyncService.java index 5a7dfb6..f507089 100644 --- a/src/main/java/dev/deepcore/challenge/inventory/SharedInventorySyncService.java +++ b/src/main/java/dev/deepcore/challenge/inventory/SharedInventorySyncService.java @@ -91,8 +91,41 @@ public void requestSharedInventorySync(Player source) { return; } - pendingSharedInventorySourceId = source.getUniqueId(); - scheduleSharedInventoryDrain(); + if (!isRunningPhase.get()) { + return; + } + + syncSharedInventoryFromSourceNow(source); + } + + /** + * Flushes any queued shared-inventory sync immediately without deferring to + * next + * tick. This ensures inventory changes are synced instantly to other players. + */ + public void flushPendingSharedInventorySync() { + if (!isSharedInventoryEnabled.getAsBoolean() || !isRunningPhase.get()) { + sharedInventorySyncQueued = false; + pendingSharedInventorySourceId = null; + return; + } + + if (syncingInventory) { + return; + } + + if (!sharedInventorySyncQueued) { + return; + } + + sharedInventorySyncQueued = false; + Player syncSource = resolveSharedInventorySyncSource(); + pendingSharedInventorySourceId = null; + if (syncSource == null) { + return; + } + + syncInventoryFrom(syncSource); } /** @@ -142,6 +175,15 @@ private void scheduleSharedInventoryDrain() { Bukkit.getScheduler().runTask(plugin, this::drainSharedInventoryQueue); } + private void scheduleSharedInventoryDrainImmediate() { + if (sharedInventorySyncQueued) { + return; + } + + sharedInventorySyncQueued = true; + Bukkit.getScheduler().runTask(plugin, this::drainSharedInventoryQueueImmediate); + } + private void drainSharedInventoryQueue() { if (!sharedInventorySyncQueued) { return; @@ -168,6 +210,38 @@ private void drainSharedInventoryQueue() { syncInventoryFrom(syncSource); } + /** + * Internal drain that retries immediately instead of deferring to next tick if + * a + * sync is in progress. Used for rapid inventory mutations within the same + * player + * inventory to ensure instant sync feedback. + */ + private void drainSharedInventoryQueueImmediate() { + if (!sharedInventorySyncQueued) { + return; + } + + if (!isSharedInventoryEnabled.getAsBoolean() || !isRunningPhase.get()) { + sharedInventorySyncQueued = false; + pendingSharedInventorySourceId = null; + return; + } + + if (syncingInventory) { + return; + } + + sharedInventorySyncQueued = false; + Player syncSource = resolveSharedInventorySyncSource(); + pendingSharedInventorySourceId = null; + if (syncSource == null) { + return; + } + + syncInventoryFrom(syncSource); + } + /** * Removes one matching item from all participants except the source player. * @@ -192,7 +266,22 @@ public void consumeItemFromOtherParticipants(Material material, UUID sourcePlaye * @param sourcePlayerId player UUID that equipped the wearable */ public void consumeWearableFromOtherParticipants(Material material, UUID sourcePlayerId) { - consumeWearableFromParticipants(material, sourcePlayerId, getOnlineActiveSharedInventoryParticipants()); + consumeWearableFromOtherParticipants(material, sourcePlayerId, -1); + } + + /** + * Removes one wearable item from others after a participant equips it, + * preferring the exact source slot so other participants' equipped armor is + * never touched. + * + * @param material wearable material that was equipped + * @param sourcePlayerId player UUID that equipped the wearable + * @param sourceInventorySlot slot index in the source player's inventory the + * wearable was taken from, or -1 if unknown + */ + public void consumeWearableFromOtherParticipants(Material material, UUID sourcePlayerId, int sourceInventorySlot) { + consumeWearableFromParticipants( + material, sourcePlayerId, sourceInventorySlot, getOnlineActiveSharedInventoryParticipants()); } /** @@ -204,6 +293,11 @@ public void consumeWearableFromOtherParticipants(Material material, UUID sourceP * @param participants participant snapshot to apply removal against */ public void consumeWearableFromParticipants(Material material, UUID sourcePlayerId, List participants) { + consumeWearableFromParticipants(material, sourcePlayerId, -1, participants); + } + + private void consumeWearableFromParticipants( + Material material, UUID sourcePlayerId, int sourceInventorySlot, List participants) { if (participants == null || participants.isEmpty()) { return; } @@ -214,16 +308,40 @@ public void consumeWearableFromParticipants(Material material, UUID sourcePlayer } PlayerInventory inventory = participant.getInventory(); - if (removeOneFromMainInventory(inventory, material) - || removeOneFromOffhand(inventory, material) - || removeOneFromArmorSlots(inventory, material) - || removeOneFromFallbackNonArmorSlots(inventory, material) - || removeOneFromCursor(participant, material)) { + boolean removed = false; + + if (sourceInventorySlot >= 0 && !isArmorOrOffhandSlot(sourceInventorySlot)) { + removed = removeFromSlot(inventory, material, sourceInventorySlot); + } + + if (!removed) { + removed = removeOneFromMainInventory(inventory, material) + || removeOneFromOffhand(inventory, material) + || removeOneFromFallbackNonArmorSlots(inventory, material) + || removeOneFromCursor(participant, material); + } + + if (removed) { participant.updateInventory(); } } } + private boolean removeFromSlot(PlayerInventory inventory, Material material, int slot) { + ItemStack item = inventory.getItem(slot); + if (item == null || item.getType() != material) { + return false; + } + int amount = item.getAmount(); + if (amount <= 1) { + inventory.setItem(slot, null); + } else { + item.setAmount(amount - 1); + inventory.setItem(slot, item); + } + return true; + } + private boolean removeOneFromArmorSlots(PlayerInventory inventory, Material material) { ItemStack helmet = inventory.getHelmet(); if (helmet != null && helmet.getType() == material) { @@ -279,22 +397,28 @@ private enum EquipmentSlotRef { /** * Schedules a wearable-equip synchronization pass for the source player. * - * @param source player whose newly equipped wearables should be detected + * @param source player whose newly equipped wearables should be + * detected + * @param sourceInventorySlot inventory slot the wearable was taken from, or -1 + * if unknown */ - public void requestWearableEquipSync(Player source) { + public void requestWearableEquipSync(Player source, int sourceInventorySlot) { if (!isChallengeActive.test(source) || !isSharedInventoryEnabled.getAsBoolean()) { return; } - Bukkit.getScheduler().runTask(plugin, () -> detectNewlyEquippedWearables(source)); + Bukkit.getScheduler().runTask(plugin, () -> detectNewlyEquippedWearables(source, sourceInventorySlot)); } /** * Detects newly equipped wearables and mirrors consumption across participants. * - * @param player player whose equipped wearable set should be diffed + * @param player player whose equipped wearable set should be + * diffed + * @param sourceInventorySlot inventory slot the wearable was taken from, or -1 + * if unknown */ - public void detectNewlyEquippedWearables(Player player) { + public void detectNewlyEquippedWearables(Player player, int sourceInventorySlot) { if (!isChallengeActive.test(player) || !isSharedInventoryEnabled.getAsBoolean()) { return; } @@ -307,7 +431,7 @@ public void detectNewlyEquippedWearables(Player player) { Material material = entry.getKey(); int delta = entry.getValue() - previous.getOrDefault(material, 0); for (int i = 0; i < delta; i++) { - consumeWearableFromOtherParticipants(material, playerId); + consumeWearableFromOtherParticipants(material, playerId, sourceInventorySlot); } } @@ -393,11 +517,34 @@ private void syncInventoryFrom(Player source) { ItemStack[] storage = cloneContents(sourceInventory.getStorageContents()); ItemStack[] extra = cloneContents(sourceInventory.getExtraContents()); + UUID finalSourceId = source.getUniqueId(); for (Player target : syncParticipants) { + if (target.getUniqueId().equals(finalSourceId)) { + continue; + } + + // If the target has an item on their cursor, snapshot it before syncing. + // updateInventory() sends a full resync packet that includes the cursor slot; + // without restoring it first, the packet clears the cursor and the held item + // vanishes. Restoring into the InventoryView before updateInventory() ensures + // Bukkit reads the correct cursor when building the outgoing packet. + InventoryView openView = target.getOpenInventory(); + ItemStack cursorToRestore = null; + if (openView != null) { + ItemStack cursor = openView.getCursor(); + if (cursor != null && !cursor.getType().isAir()) { + cursorToRestore = cursor.clone(); + } + } + PlayerInventory targetInventory = target.getInventory(); targetInventory.setStorageContents(cloneContents(storage)); targetInventory.setExtraContents(cloneContents(extra)); + if (cursorToRestore != null) { + openView.setCursor(cursorToRestore); + } + if (isDegradingInventoryEnabled.getAsBoolean()) { enforceInventorySlotCap.accept(target); } diff --git a/src/main/java/dev/deepcore/challenge/preview/PreviewOrchestratorService.java b/src/main/java/dev/deepcore/challenge/preview/PreviewOrchestratorService.java index 056d1fe..4f80d4e 100644 --- a/src/main/java/dev/deepcore/challenge/preview/PreviewOrchestratorService.java +++ b/src/main/java/dev/deepcore/challenge/preview/PreviewOrchestratorService.java @@ -85,6 +85,9 @@ public void refreshLobbyPreview() { if (previewState.previewDestroying) { return; } + if (!plugin.isEnabled()) { + return; + } Bukkit.getScheduler().runTask(plugin, this::rebuildLobbyPreview); } diff --git a/src/main/java/dev/deepcore/records/RunRecord.java b/src/main/java/dev/deepcore/challenge/records/RunRecord.java similarity index 70% rename from src/main/java/dev/deepcore/records/RunRecord.java rename to src/main/java/dev/deepcore/challenge/records/RunRecord.java index 6551c0e..1afba4b 100644 --- a/src/main/java/dev/deepcore/records/RunRecord.java +++ b/src/main/java/dev/deepcore/challenge/records/RunRecord.java @@ -1,4 +1,4 @@ -package dev.deepcore.records; +package dev.deepcore.challenge.records; import java.util.Arrays; import java.util.List; @@ -21,9 +21,12 @@ public class RunRecord { private final long netherToEndMs; private final long endToDragonMs; private final String participantsCsv; + private final String componentsCsv; + private final String difficulty; /** - * Creates a run record snapshot with split timings and participant names. + * Creates a run record snapshot with split timings, participant names, and + * enabled mechanics. * * @param timestamp record timestamp in epoch milliseconds * @param overallTimeMs total run completion time in milliseconds @@ -33,6 +36,8 @@ public class RunRecord { * @param netherToEndMs elapsed time from first nether entry to end entry * @param endToDragonMs elapsed time from end entry to dragon defeat * @param participantsCsv comma-separated participant names + * @param componentsCsv comma-separated enabled component keys + * @param difficulty difficulty key for the run (easy/normal/hard) */ public RunRecord( long timestamp, @@ -42,7 +47,9 @@ public RunRecord( long blazeRodsToEndMs, long netherToEndMs, long endToDragonMs, - String participantsCsv) { + String participantsCsv, + String componentsCsv, + String difficulty) { this.timestamp = timestamp; this.overallTimeMs = overallTimeMs; this.overworldToNetherMs = overworldToNetherMs; @@ -51,6 +58,8 @@ public RunRecord( this.netherToEndMs = netherToEndMs; this.endToDragonMs = endToDragonMs; this.participantsCsv = participantsCsv == null ? "" : participantsCsv; + this.componentsCsv = componentsCsv == null ? "" : componentsCsv; + this.difficulty = difficulty == null ? "" : difficulty; } public long getTimestamp() { @@ -96,6 +105,30 @@ public List getParticipants() { .collect(Collectors.toList()); } + public String getComponentsCsv() { + return componentsCsv; + } + + public String getDifficulty() { + return difficulty; + } + + /** + * Returns the enabled component keys stored in this record. + * + * @return list of enabled challenge component keys, empty for standard runs + */ + public List getComponentKeys() { + if (componentsCsv.isBlank()) { + return List.of(); + } + + return Arrays.stream(componentsCsv.split(",")) + .map(String::trim) + .filter(key -> !key.isEmpty()) + .collect(Collectors.toList()); + } + @Override public String toString() { return "RunRecord{" + "timestamp=" @@ -106,6 +139,8 @@ public String toString() { + blazeRodsToEndMs + ", netherToEndMs=" + netherToEndMs + ", endToDragonMs=" + endToDragonMs + ", participantsCsv='" - + participantsCsv + '\'' + "}"; + + participantsCsv + "', componentsCsv='" + + componentsCsv + "', difficulty='" + + difficulty + '\'' + "}"; } } diff --git a/src/main/java/dev/deepcore/records/RunRecordsService.java b/src/main/java/dev/deepcore/challenge/records/RunRecordsService.java similarity index 78% rename from src/main/java/dev/deepcore/records/RunRecordsService.java rename to src/main/java/dev/deepcore/challenge/records/RunRecordsService.java index ca6f86b..6e59118 100644 --- a/src/main/java/dev/deepcore/records/RunRecordsService.java +++ b/src/main/java/dev/deepcore/challenge/records/RunRecordsService.java @@ -1,4 +1,4 @@ -package dev.deepcore.records; +package dev.deepcore.challenge.records; import dev.deepcore.DeepCorePlugin; import dev.deepcore.logging.DeepCoreLogger; @@ -26,12 +26,13 @@ public class RunRecordsService { /** * Creates a records service bound to this plugin's data folder and logger. * - * @param plugin plugin providing data folder and logger context + * @param plugin plugin providing data folder and logger context + * @param dbFileName filename of the SQLite database within the plugin data folder */ - public RunRecordsService(JavaPlugin plugin) { + public RunRecordsService(JavaPlugin plugin, String dbFileName) { this.plugin = plugin; this.log = ((DeepCorePlugin) plugin).getDeepCoreLogger(); - this.dbPath = "jdbc:sqlite:" + plugin.getDataFolder().getAbsolutePath() + "/records.db"; + this.dbPath = "jdbc:sqlite:" + plugin.getDataFolder().getAbsolutePath() + "/" + dbFileName; } /** @@ -68,6 +69,16 @@ private void createTablesIfNotExist() throws SQLException { log.warn("Legacy run_records schema detected (player_uuid). Wiping run_records to apply current schema."); recreateRunRecordsTable(); } + + // Non-destructive migration: add components column to pre-existing databases. + if (!hasColumn("components")) { + addComponentsColumn(); + } + + // Non-destructive migration: add difficulty column to pre-existing databases. + if (!hasColumn("difficulty")) { + addDifficultyColumn(); + } } private boolean hasColumn(String columnName) throws SQLException { @@ -94,7 +105,9 @@ private void createRunRecordsTableIfMissing() throws SQLException { + "blaze_rods_to_end_ms LONG NOT NULL," + "nether_to_end_ms LONG NOT NULL," + "end_to_dragon_ms LONG NOT NULL," - + "participants TEXT NOT NULL DEFAULT ''" + + "participants TEXT NOT NULL DEFAULT ''," + + "components TEXT NOT NULL DEFAULT ''," + + "difficulty TEXT NOT NULL DEFAULT ''" + ")"); } } @@ -107,8 +120,22 @@ private void recreateRunRecordsTable() throws SQLException { log.debug("run_records table recreated with current schema."); } + private void addComponentsColumn() throws SQLException { + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate("ALTER TABLE run_records ADD COLUMN components TEXT NOT NULL DEFAULT ''"); + } + log.debug("Added 'components' column to run_records table."); + } + + private void addDifficultyColumn() throws SQLException { + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate("ALTER TABLE run_records ADD COLUMN difficulty TEXT NOT NULL DEFAULT ''"); + } + log.debug("Added 'difficulty' column to run_records table."); + } + /** - * Records a completed team speedrun with section timings. + * Records a completed team speedrun with section timings and enabled mechanics. * * @param overallTimeMs total elapsed time in milliseconds * @param overworldToNetherMs time from start to reaching Nether @@ -118,6 +145,8 @@ private void recreateRunRecordsTable() throws SQLException { * @param netherToEndMs time from Nether to End * @param endToDragonMs time from End to dragon defeat * @param participants participant names included in the run + * @param enabledComponents component keys for mechanics enabled during the run + * @param difficulty difficulty key for the run (easy/normal/hard) * @return the created RunRecord */ public RunRecord recordRun( @@ -127,13 +156,17 @@ public RunRecord recordRun( long blazeRodsToEndMs, long netherToEndMs, long endToDragonMs, - List participants) { + List participants, + List enabledComponents, + String difficulty) { long timestamp = System.currentTimeMillis(); - String participantsCsv = encodeParticipants(participants); + String participantsCsv = encodeCsv(participants); + String componentsCsv = encodeCsv(enabledComponents); + String difficultyValue = difficulty == null ? "" : difficulty; try (PreparedStatement pstmt = connection.prepareStatement("INSERT INTO run_records " - + "(timestamp, overall_time_ms, overworld_to_nether_ms, nether_to_blaze_rods_ms, blaze_rods_to_end_ms, nether_to_end_ms, end_to_dragon_ms, participants) " - + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) { + + "(timestamp, overall_time_ms, overworld_to_nether_ms, nether_to_blaze_rods_ms, blaze_rods_to_end_ms, nether_to_end_ms, end_to_dragon_ms, participants, components, difficulty) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")) { pstmt.setLong(1, timestamp); pstmt.setLong(2, overallTimeMs); pstmt.setLong(3, overworldToNetherMs); @@ -142,6 +175,8 @@ public RunRecord recordRun( pstmt.setLong(6, netherToEndMs); pstmt.setLong(7, endToDragonMs); pstmt.setString(8, participantsCsv); + pstmt.setString(9, componentsCsv); + pstmt.setString(10, difficultyValue); pstmt.executeUpdate(); log.debug(String.format( @@ -164,7 +199,9 @@ public RunRecord recordRun( blazeRodsToEndMs, netherToEndMs, endToDragonMs, - participantsCsv); + participantsCsv, + componentsCsv, + difficultyValue); } /** @@ -239,7 +276,7 @@ public List getAllRecords() { try (Statement stmt = connection.createStatement()) { try (ResultSet rs = stmt.executeQuery( - "SELECT timestamp, overall_time_ms, overworld_to_nether_ms, nether_to_blaze_rods_ms, blaze_rods_to_end_ms, nether_to_end_ms, end_to_dragon_ms, participants " + "SELECT timestamp, overall_time_ms, overworld_to_nether_ms, nether_to_blaze_rods_ms, blaze_rods_to_end_ms, nether_to_end_ms, end_to_dragon_ms, participants, components, difficulty " + "FROM run_records ORDER BY timestamp DESC")) { while (rs.next()) { records.add(new RunRecord( @@ -250,7 +287,9 @@ public List getAllRecords() { rs.getLong("blaze_rods_to_end_ms"), rs.getLong("nether_to_end_ms"), rs.getLong("end_to_dragon_ms"), - rs.getString("participants"))); + rs.getString("participants"), + rs.getString("components"), + rs.getString("difficulty"))); } } } catch (SQLException e) { @@ -274,14 +313,14 @@ public void shutdown() { } } - private String encodeParticipants(List participants) { - if (participants == null || participants.isEmpty()) { + private String encodeCsv(List values) { + if (values == null || values.isEmpty()) { return ""; } - return participants.stream() - .map(name -> name == null ? "" : name.trim()) - .filter(name -> !name.isEmpty()) + return values.stream() + .map(v -> v == null ? "" : v.trim()) + .filter(v -> !v.isEmpty()) .collect(Collectors.joining(", ")); } } diff --git a/src/main/java/dev/deepcore/records/package-info.java b/src/main/java/dev/deepcore/challenge/records/package-info.java similarity index 68% rename from src/main/java/dev/deepcore/records/package-info.java rename to src/main/java/dev/deepcore/challenge/records/package-info.java index 9038b3c..a6b6de9 100644 --- a/src/main/java/dev/deepcore/records/package-info.java +++ b/src/main/java/dev/deepcore/challenge/records/package-info.java @@ -1,4 +1,4 @@ /** * Persistence models and services for speedrun record storage and retrieval. */ -package dev.deepcore.records; +package dev.deepcore.challenge.records; diff --git a/src/main/java/dev/deepcore/challenge/session/PlayerLobbyStateService.java b/src/main/java/dev/deepcore/challenge/session/PlayerLobbyStateService.java index 5d27474..fc8aa21 100644 --- a/src/main/java/dev/deepcore/challenge/session/PlayerLobbyStateService.java +++ b/src/main/java/dev/deepcore/challenge/session/PlayerLobbyStateService.java @@ -42,8 +42,12 @@ public void enforceSurvivalOnWorldEntry( return; } - if (player.getGameMode() != GameMode.SURVIVAL) { - player.setGameMode(GameMode.SURVIVAL); + GameMode target = (worldClassificationService.isLobbyOrLimboWorld(player.getWorld()) + || worldClassificationService.isTrainingWorld(player.getWorld())) + ? GameMode.ADVENTURE + : GameMode.SURVIVAL; + if (player.getGameMode() != target) { + player.setGameMode(target); } } diff --git a/src/main/java/dev/deepcore/challenge/session/PrepGuiCoordinatorService.java b/src/main/java/dev/deepcore/challenge/session/PrepGuiCoordinatorService.java index 751aa0f..606f59d 100644 --- a/src/main/java/dev/deepcore/challenge/session/PrepGuiCoordinatorService.java +++ b/src/main/java/dev/deepcore/challenge/session/PrepGuiCoordinatorService.java @@ -1,14 +1,14 @@ package dev.deepcore.challenge.session; import dev.deepcore.challenge.ChallengeManager; -import dev.deepcore.challenge.PrepGuiPage; import dev.deepcore.challenge.preview.PreviewOrchestratorService; +import dev.deepcore.challenge.records.RunRecord; +import dev.deepcore.challenge.records.RunRecordsService; import dev.deepcore.challenge.ui.PrepBookService; +import dev.deepcore.challenge.ui.PrepGuiPage; import dev.deepcore.challenge.ui.PrepGuiRenderer; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; -import dev.deepcore.records.RunRecord; -import dev.deepcore.records.RunRecordsService; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.List; @@ -51,6 +51,8 @@ public final class PrepGuiCoordinatorService { private final BooleanSupplier isDiscoPreviewBlockingChallengeStart; private final Runnable announceDiscoPreviewStartBlocked; private final Runnable startRun; + private final BooleanSupplier savedRunExistsSupplier; + private final Runnable restoreSavedRunFlow; private final String prepGuiTitle; private final DateTimeFormatter runHistoryDateFormatter; @@ -90,6 +92,10 @@ public final class PrepGuiCoordinatorService { * block * @param startRun runnable that starts a challenge * run + * @param savedRunExistsSupplier supplier returning true when a + * persistent saved run is available + * @param restoreSavedRunFlow runnable that triggers restore of + * the saved run * @param prepGuiTitle prep GUI inventory title text * @param runHistoryDateFormatter formatter for run history date * labels @@ -113,6 +119,8 @@ public PrepGuiCoordinatorService( BooleanSupplier isDiscoPreviewBlockingChallengeStart, Runnable announceDiscoPreviewStartBlocked, Runnable startRun, + BooleanSupplier savedRunExistsSupplier, + Runnable restoreSavedRunFlow, String prepGuiTitle, DateTimeFormatter runHistoryDateFormatter) { this.plugin = plugin; @@ -133,6 +141,8 @@ public PrepGuiCoordinatorService( this.isDiscoPreviewBlockingChallengeStart = isDiscoPreviewBlockingChallengeStart; this.announceDiscoPreviewStartBlocked = announceDiscoPreviewStartBlocked; this.startRun = startRun; + this.savedRunExistsSupplier = savedRunExistsSupplier; + this.restoreSavedRunFlow = restoreSavedRunFlow; this.prepGuiTitle = prepGuiTitle; this.runHistoryDateFormatter = runHistoryDateFormatter; } @@ -278,6 +288,11 @@ public void handlePrepGuiClick(InventoryClickEvent event) { targetPage -> openPrepGui(player, targetPage), player::closeInventory, () -> { + if (savedRunExistsSupplier.getAsBoolean()) { + log.sendError(player, "Clear the saved run before regenerating worlds."); + return; + } + if (worldResetManagerSupplier.get() == null) { log.sendError(player, "World reset manager is not available."); return; @@ -294,6 +309,10 @@ public void handlePrepGuiClick(InventoryClickEvent event) { () -> { player.closeInventory(); player.performCommand("challenge train"); + }, + () -> { + player.closeInventory(); + restoreSavedRunFlow.run(); }); } @@ -371,7 +390,9 @@ private void openPrepGui(Player player, PrepGuiPage page) { readyPlayers.contains(player.getUniqueId()), readyPlayers.size(), participantsView.onlineCount(), - previewOrchestratorService.isPreviewEnabled()); + previewOrchestratorService.isPreviewEnabled(), + challengeManager.getDifficulty(), + savedRunExistsSupplier.getAsBoolean()); case INVENTORY -> prepGuiRenderer.populateInventoryPage( inventory, challengeManager, diff --git a/src/main/java/dev/deepcore/challenge/session/PrepGuiFlowService.java b/src/main/java/dev/deepcore/challenge/session/PrepGuiFlowService.java index 58a25ae..f35c384 100644 --- a/src/main/java/dev/deepcore/challenge/session/PrepGuiFlowService.java +++ b/src/main/java/dev/deepcore/challenge/session/PrepGuiFlowService.java @@ -2,9 +2,9 @@ import dev.deepcore.challenge.ChallengeComponent; import dev.deepcore.challenge.ChallengeManager; -import dev.deepcore.challenge.PrepGuiPage; +import dev.deepcore.challenge.records.RunRecordsService; +import dev.deepcore.challenge.ui.PrepGuiPage; import dev.deepcore.challenge.ui.PrepGuiRenderer; -import dev.deepcore.records.RunRecordsService; import java.util.Map; import java.util.UUID; import java.util.function.Consumer; @@ -54,6 +54,7 @@ public PrepGuiFlowService( * @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 + * @param restoreSavedRunFlow action to restore a persistent saved run * @return true when the click was handled by prep GUI flow logic */ public boolean handleClick( @@ -66,18 +67,24 @@ public boolean handleClick( Consumer openPrepGui, Runnable closeInventory, Runnable resetWorldFlow, - Runnable trainingTeleportFlow) { - if (slot == 47 && page != PrepGuiPage.RUN_HISTORY) { + Runnable trainingTeleportFlow, + Runnable restoreSavedRunFlow) { + if (slot == 47 && page != PrepGuiPage.CATEGORIES && page != PrepGuiPage.RUN_HISTORY) { readyToggleFlow.run(); return true; } - if (page == PrepGuiPage.CATEGORIES && slot == 51) { + if (slot == 48 && page == PrepGuiPage.CATEGORIES) { + readyToggleFlow.run(); + return true; + } + + if (page == PrepGuiPage.CATEGORIES && slot == 30) { resetWorldFlow.run(); return true; } - if (page == PrepGuiPage.CATEGORIES && slot == 53) { + if (page == PrepGuiPage.CATEGORIES && slot == 32) { trainingTeleportFlow.run(); return true; } @@ -108,6 +115,17 @@ public boolean handleClick( return true; } + if (slot == 31 && page == PrepGuiPage.CATEGORIES) { + prepSettingsService.cycleDifficulty(); + refreshOpenPrepGuis.run(); + return true; + } + + if (slot == 33 && page == PrepGuiPage.CATEGORIES) { + restoreSavedRunFlow.run(); + return true; + } + if (page == PrepGuiPage.INVENTORY) { if (slot == 20) { prepSettingsService.toggleComponent(ChallengeComponent.KEEP_INVENTORY); diff --git a/src/main/java/dev/deepcore/challenge/session/PrepSettingsService.java b/src/main/java/dev/deepcore/challenge/session/PrepSettingsService.java index b014f2f..ac54370 100644 --- a/src/main/java/dev/deepcore/challenge/session/PrepSettingsService.java +++ b/src/main/java/dev/deepcore/challenge/session/PrepSettingsService.java @@ -52,6 +52,11 @@ public void setHealthRefill(boolean enabled) { applySharedVitalsIfEnabled.run(); } + /** Advances difficulty to the next level in the cycle and persists the change. */ + public void cycleDifficulty() { + challengeManager.setDifficulty(challengeManager.getDifficulty().next()); + } + /** * Sets initial-half-heart state and enforces mutual exclusion with health * refill. diff --git a/src/main/java/dev/deepcore/challenge/session/RunCompletionService.java b/src/main/java/dev/deepcore/challenge/session/RunCompletionService.java index 573128e..6842e85 100644 --- a/src/main/java/dev/deepcore/challenge/session/RunCompletionService.java +++ b/src/main/java/dev/deepcore/challenge/session/RunCompletionService.java @@ -1,5 +1,6 @@ package dev.deepcore.challenge.session; +import dev.deepcore.challenge.ChallengeComponent; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; import java.util.ArrayList; @@ -8,6 +9,7 @@ import java.util.Set; import java.util.UUID; import java.util.function.Supplier; +import java.util.stream.Collectors; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.entity.EnderDragon; @@ -22,10 +24,12 @@ public final class RunCompletionService { private final SessionState sessionState; private final RunProgressService runProgressService; private final CompletionReturnService completionReturnService; - private final Supplier recordsServiceSupplier; + private final Supplier recordsServiceSupplier; private final Set participants; private final Supplier worldResetManagerSupplier; private final Runnable endChallengeAndReturnToPrep; + private final Supplier> enabledComponentsSupplier; + private final Supplier difficultyKeySupplier; private final DeepCoreLogger log; /** @@ -38,16 +42,20 @@ public final class RunCompletionService { * @param participants participant UUID set for current run * @param worldResetManagerSupplier supplier for world reset manager * @param endChallengeAndReturnToPrep fallback action to return session to prep + * @param enabledComponentsSupplier supplier returning currently enabled mechanics + * @param difficultyKeySupplier supplier returning the current difficulty key * @param log logger for completion lifecycle messages */ public RunCompletionService( SessionState sessionState, RunProgressService runProgressService, CompletionReturnService completionReturnService, - Supplier recordsServiceSupplier, + Supplier recordsServiceSupplier, Set participants, Supplier worldResetManagerSupplier, Runnable endChallengeAndReturnToPrep, + Supplier> enabledComponentsSupplier, + Supplier difficultyKeySupplier, DeepCoreLogger log) { this.sessionState = sessionState; this.runProgressService = runProgressService; @@ -56,6 +64,8 @@ public RunCompletionService( this.participants = participants; this.worldResetManagerSupplier = worldResetManagerSupplier; this.endChallengeAndReturnToPrep = endChallengeAndReturnToPrep; + this.enabledComponentsSupplier = enabledComponentsSupplier; + this.difficultyKeySupplier = difficultyKeySupplier; this.log = log; } @@ -82,7 +92,7 @@ public void handleEntityDeath(EntityDeathEvent event) { } private void recordRunIfAvailable(long dragonDeathTime) { - dev.deepcore.records.RunRecordsService recordsService = recordsServiceSupplier.get(); + dev.deepcore.challenge.records.RunRecordsService recordsService = recordsServiceSupplier.get(); if (recordsService == null || sessionState.timing().getRunStartMillis() <= 0L) { return; } @@ -91,6 +101,10 @@ private void recordRunIfAvailable(long dragonDeathTime) { RunProgressService.SectionDurations sectionDurations = runProgressService.calculateSectionDurations( sessionState.timing().getRunStartMillis(), dragonDeathTime, overallTimeMs); + List componentKeys = enabledComponentsSupplier.get().stream() + .map(ChallengeComponent::key) + .collect(Collectors.toList()); + recordsService.recordRun( overallTimeMs, sectionDurations.overworldToNetherMs(), @@ -98,7 +112,9 @@ private void recordRunIfAvailable(long dragonDeathTime) { sectionDurations.blazeRodsToEndMs(), sectionDurations.netherToEndMs(), sectionDurations.endToDragonMs(), - getParticipantNamesForRecord()); + getParticipantNamesForRecord(), + componentKeys, + difficultyKeySupplier.get()); } private void startCompletionReturnCountdown() { diff --git a/src/main/java/dev/deepcore/challenge/session/RunHealthCoordinatorService.java b/src/main/java/dev/deepcore/challenge/session/RunHealthCoordinatorService.java index 33144c7..d12b5ea 100644 --- a/src/main/java/dev/deepcore/challenge/session/RunHealthCoordinatorService.java +++ b/src/main/java/dev/deepcore/challenge/session/RunHealthCoordinatorService.java @@ -88,7 +88,8 @@ public void handleEntityRegainHealth(EntityRegainHealthEvent event) { return; } - if (!challengeManager.isComponentEnabled(ChallengeComponent.HEALTH_REFILL)) { + if (!challengeManager.isComponentEnabled(ChallengeComponent.HEALTH_REFILL) + && event.getRegainReason() == EntityRegainHealthEvent.RegainReason.SATIATED) { event.setCancelled(true); return; } diff --git a/src/main/java/dev/deepcore/challenge/session/RunProgressService.java b/src/main/java/dev/deepcore/challenge/session/RunProgressService.java index fffaa33..0499978 100644 --- a/src/main/java/dev/deepcore/challenge/session/RunProgressService.java +++ b/src/main/java/dev/deepcore/challenge/session/RunProgressService.java @@ -21,6 +21,33 @@ public final class RunProgressService { private boolean dragonKilled; private long dragonKilledMillis; + /** + * Restores run milestone state from a persistent snapshot. + * + * @param reachedNether whether the Nether was reached before save + * @param netherMs timestamp when Nether was reached, or 0 + * @param reachedBlazeObjective whether the blaze objective was met before save + * @param blazeObjectiveMs timestamp when blaze objective was met, or 0 + * @param reachedEnd whether the End was reached before save + * @param endMs timestamp when End was reached, or 0 + */ + public void restore( + boolean reachedNether, + long netherMs, + boolean reachedBlazeObjective, + long blazeObjectiveMs, + boolean reachedEnd, + long endMs) { + this.reachedNether = reachedNether; + this.netherReachedMillis = netherMs; + this.reachedBlazeObjective = reachedBlazeObjective; + this.blazeObjectiveReachedMillis = blazeObjectiveMs; + this.reachedEnd = reachedEnd; + this.endReachedMillis = endMs; + this.dragonKilled = false; + this.dragonKilledMillis = 0L; + } + /** Resets all tracked run milestones. */ public void reset() { reachedNether = false; @@ -51,6 +78,15 @@ public boolean hasReachedEnd() { return reachedEnd; } + /** + * Returns whether the blaze objective milestone has been reached. + * + * @return true when the blaze objective milestone has been recorded + */ + public boolean hasReachedBlazeObjective() { + return reachedBlazeObjective; + } + /** * Returns whether the dragon kill milestone has been reached. * @@ -60,6 +96,33 @@ public boolean isDragonKilled() { return dragonKilled; } + /** + * Returns the timestamp when the Nether milestone was first reached. + * + * @return epoch milliseconds when Nether was reached, or 0 if not reached + */ + public long getNetherReachedMillis() { + return netherReachedMillis; + } + + /** + * Returns the timestamp when the blaze objective milestone was first reached. + * + * @return epoch milliseconds when blaze objective was reached, or 0 if not reached + */ + public long getBlazeObjectiveReachedMillis() { + return blazeObjectiveReachedMillis; + } + + /** + * Returns the timestamp when the End milestone was first reached. + * + * @return epoch milliseconds when End was reached, or 0 if not reached + */ + public long getEndReachedMillis() { + return endReachedMillis; + } + /** * Resolves the timestamp to use for elapsed-time rendering. * diff --git a/src/main/java/dev/deepcore/challenge/session/RunSaveVoteService.java b/src/main/java/dev/deepcore/challenge/session/RunSaveVoteService.java new file mode 100644 index 0000000..d0a4d8d --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/session/RunSaveVoteService.java @@ -0,0 +1,124 @@ +package dev.deepcore.challenge.session; + +import dev.deepcore.logging.DeepCoreLogger; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +/** Collects unanimous player votes to save the current speedrun to disk. */ +public final class RunSaveVoteService { + private static final int VOTE_TIMEOUT_SECONDS = 60; + + private final JavaPlugin plugin; + private final DeepCoreLogger log; + private final Supplier> onlineParticipantsSupplier; + + private final Set votes = new HashSet<>(); + private BukkitTask timeoutTask; + + /** + * Creates a run-save vote service. + * + * @param plugin plugin instance for scheduling tasks + * @param log logger for vote progress and timeout messages + * @param onlineParticipantsSupplier supplier for currently online participants + */ + public RunSaveVoteService( + JavaPlugin plugin, DeepCoreLogger log, Supplier> onlineParticipantsSupplier) { + this.plugin = plugin; + this.log = log; + this.onlineParticipantsSupplier = onlineParticipantsSupplier; + } + + /** + * Records a vote from the given player and triggers the save callback when + * all online participants have voted. + * + * @param voter player casting the vote + * @param onAllVoted runnable executed when unanimous vote is reached + * @return false if the player already voted or is not a participant + */ + public boolean castVote(Player voter, Runnable onAllVoted) { + List participants = onlineParticipantsSupplier.get(); + if (participants.isEmpty()) { + log.sendError(voter, "No active run participants to vote with."); + return false; + } + + boolean isParticipant = + participants.stream().anyMatch(p -> p.getUniqueId().equals(voter.getUniqueId())); + if (!isParticipant) { + log.sendError(voter, "You are not a participant in the current run."); + return false; + } + + if (votes.contains(voter.getUniqueId())) { + log.sendWarn(voter, "You have already voted to save this run."); + return false; + } + + boolean firstVote = votes.isEmpty(); + votes.add(voter.getUniqueId()); + + Set participantIds = new HashSet<>(); + for (Player p : participants) { + participantIds.add(p.getUniqueId()); + } + + broadcastVoteProgress(voter.getName(), votes.size(), participantIds.size()); + + if (votes.containsAll(participantIds)) { + clearVotes(); + onAllVoted.run(); + return true; + } + + if (firstVote) { + startTimeout(); + } + + return true; + } + + /** Cancels any pending vote and clears all recorded votes. */ + public void clearVotes() { + votes.clear(); + if (timeoutTask != null && !timeoutTask.isCancelled()) { + timeoutTask.cancel(); + timeoutTask = null; + } + } + + private void broadcastVoteProgress(String voterName, int voteCount, int totalCount) { + String msg = ChatColor.GOLD + "[DeepCore] " + ChatColor.YELLOW + voterName + + ChatColor.WHITE + " voted to save the run (" + + ChatColor.AQUA + voteCount + "/" + totalCount + + ChatColor.WHITE + "). Use " + + ChatColor.YELLOW + "/challenge saverun" + + ChatColor.WHITE + " to vote."; + Bukkit.broadcastMessage(msg); + } + + private void startTimeout() { + long ticks = VOTE_TIMEOUT_SECONDS * 20L; + timeoutTask = Bukkit.getScheduler() + .runTaskLater( + plugin, + () -> { + if (!votes.isEmpty()) { + votes.clear(); + timeoutTask = null; + Bukkit.broadcastMessage(ChatColor.GOLD + "[DeepCore] " + ChatColor.RED + + "Save vote expired — not all participants voted in time."); + } + }, + ticks); + } +} diff --git a/src/main/java/dev/deepcore/challenge/session/SavedRunStateService.java b/src/main/java/dev/deepcore/challenge/session/SavedRunStateService.java new file mode 100644 index 0000000..200ac15 --- /dev/null +++ b/src/main/java/dev/deepcore/challenge/session/SavedRunStateService.java @@ -0,0 +1,413 @@ +package dev.deepcore.challenge.session; + +import dev.deepcore.logging.DeepCoreLogger; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.potion.PotionEffect; + +/** Persists and restores a single saved speedrun state across server restarts. */ +public final class SavedRunStateService { + + /** Snapshot of the full run state serialized to disk on a save vote. */ + public record SavedRunSnapshot( + long savedAtMs, + long runStartMs, + long accumulatedPausedMs, + boolean reachedNether, + long netherMs, + boolean reachedBlazeObjective, + long blazeObjectiveMs, + boolean reachedEnd, + long endMs, + List participantUuids, + List enabledComponents, + String difficulty, + Map playerSnapshots) {} + + /** Snapshot of a single player's position, inventory, health, and status state. */ + public record PlayerSnapshot( + String worldName, + double x, + double y, + double z, + float yaw, + float pitch, + double health, + double maxHealth, + int foodLevel, + float saturation, + float exhaustion, + int fireTicks, + int remainingAir, + int level, + float exp, + int totalExperience, + String gameMode, + boolean allowFlight, + boolean flying, + ItemStack[] storageContents, + ItemStack[] armorContents, + ItemStack[] extraContents, + List potionEffects) {} + + private static final double DEFAULT_MAX_HEALTH = 20.0; + + private final DeepCoreLogger log; + private final File saveFile; + + /** + * Creates a saved-run state service backed by a YAML file in the plugin data folder. + * + * @param plugin plugin instance used to resolve the data folder path + * @param log logger for save and load diagnostics + */ + public SavedRunStateService(JavaPlugin plugin, DeepCoreLogger log) { + this.log = log; + this.saveFile = new File(plugin.getDataFolder(), "saved_run.yml"); + } + + /** + * Returns whether a saved run file currently exists on disk. + * + * @return true when a saved run is available for restore + */ + public boolean hasSavedRun() { + return saveFile.exists(); + } + + /** + * Serializes the given snapshot to the saved-run YAML file, overwriting any previous save. + * + * @param snapshot run snapshot to persist + */ + public void saveRun(SavedRunSnapshot snapshot) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set("version", 1); + yaml.set("savedAtMs", snapshot.savedAtMs()); + yaml.set("runStartMs", snapshot.runStartMs()); + yaml.set("accumulatedPausedMs", snapshot.accumulatedPausedMs()); + yaml.set("reachedNether", snapshot.reachedNether()); + yaml.set("netherMs", snapshot.netherMs()); + yaml.set("reachedBlazeObjective", snapshot.reachedBlazeObjective()); + yaml.set("blazeObjectiveMs", snapshot.blazeObjectiveMs()); + yaml.set("reachedEnd", snapshot.reachedEnd()); + yaml.set("endMs", snapshot.endMs()); + yaml.set("participantUuids", snapshot.participantUuids()); + yaml.set("enabledComponents", snapshot.enabledComponents()); + yaml.set("difficulty", snapshot.difficulty()); + + for (Map.Entry entry : + snapshot.playerSnapshots().entrySet()) { + String prefix = "playerSnapshots." + entry.getKey(); + PlayerSnapshot ps = entry.getValue(); + yaml.set(prefix + ".worldName", ps.worldName()); + yaml.set(prefix + ".x", ps.x()); + yaml.set(prefix + ".y", ps.y()); + yaml.set(prefix + ".z", ps.z()); + yaml.set(prefix + ".yaw", (double) ps.yaw()); + yaml.set(prefix + ".pitch", (double) ps.pitch()); + yaml.set(prefix + ".health", ps.health()); + yaml.set(prefix + ".maxHealth", ps.maxHealth()); + yaml.set(prefix + ".foodLevel", ps.foodLevel()); + yaml.set(prefix + ".saturation", (double) ps.saturation()); + yaml.set(prefix + ".exhaustion", (double) ps.exhaustion()); + yaml.set(prefix + ".fireTicks", ps.fireTicks()); + yaml.set(prefix + ".remainingAir", ps.remainingAir()); + yaml.set(prefix + ".level", ps.level()); + yaml.set(prefix + ".exp", (double) ps.exp()); + yaml.set(prefix + ".totalExperience", ps.totalExperience()); + yaml.set(prefix + ".gameMode", ps.gameMode()); + yaml.set(prefix + ".allowFlight", ps.allowFlight()); + yaml.set(prefix + ".flying", ps.flying()); + saveItemArray(yaml, prefix + ".storage", ps.storageContents()); + saveItemArray(yaml, prefix + ".armor", ps.armorContents()); + saveItemArray(yaml, prefix + ".extra", ps.extraContents()); + yaml.set(prefix + ".potionEffects", new ArrayList<>(ps.potionEffects())); + } + + try { + yaml.save(saveFile); + log.debug("Saved run state to " + saveFile.getPath()); + } catch (IOException e) { + log.error("Failed to save run state: " + e.getMessage(), e); + } + } + + /** + * Loads and deserializes the saved run from disk. + * + * @return the saved snapshot, or empty if no save file exists + */ + public Optional loadSavedRun() { + if (!saveFile.exists()) { + return Optional.empty(); + } + + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(saveFile); + long savedAtMs = yaml.getLong("savedAtMs"); + long runStartMs = yaml.getLong("runStartMs"); + long accumulatedPausedMs = yaml.getLong("accumulatedPausedMs"); + boolean reachedNether = yaml.getBoolean("reachedNether"); + long netherMs = yaml.getLong("netherMs"); + boolean reachedBlazeObjective = yaml.getBoolean("reachedBlazeObjective"); + long blazeObjectiveMs = yaml.getLong("blazeObjectiveMs"); + boolean reachedEnd = yaml.getBoolean("reachedEnd"); + long endMs = yaml.getLong("endMs"); + List participantUuids = yaml.getStringList("participantUuids"); + List enabledComponents = yaml.getStringList("enabledComponents"); + String difficulty = yaml.getString("difficulty", ""); + + Map playerSnapshots = new HashMap<>(); + if (yaml.isConfigurationSection("playerSnapshots")) { + for (String uuidStr : + yaml.getConfigurationSection("playerSnapshots").getKeys(false)) { + String prefix = "playerSnapshots." + uuidStr; + PlayerSnapshot ps = loadPlayerSnapshot(yaml, prefix); + playerSnapshots.put(uuidStr, ps); + } + } + + return Optional.of(new SavedRunSnapshot( + savedAtMs, + runStartMs, + accumulatedPausedMs, + reachedNether, + netherMs, + reachedBlazeObjective, + blazeObjectiveMs, + reachedEnd, + endMs, + participantUuids, + enabledComponents, + difficulty, + playerSnapshots)); + } + + /** + * Deletes the saved run file from disk, clearing the restore slot. + */ + public void clearSavedRun() { + if (saveFile.exists() && !saveFile.delete()) { + log.warn("Failed to delete saved run file: " + saveFile.getPath()); + } + } + + /** + * Captures the current in-game state of a player as a persistent snapshot. + * + * @param player player whose state should be captured + * @return snapshot of the player's current position, inventory, health, and status + */ + public static PlayerSnapshot capturePlayer(Player player) { + PlayerInventory inventory = player.getInventory(); + double maxHealth = DEFAULT_MAX_HEALTH; + try { + Attribute attr = resolveMaxHealthAttribute(); + AttributeInstance instance = player.getAttribute(attr); + if (instance != null) { + maxHealth = instance.getBaseValue(); + } + } catch (ReflectiveOperationException ignored) { + } + + Location loc = player.getLocation(); + return new PlayerSnapshot( + loc.getWorld() != null ? loc.getWorld().getName() : "", + loc.getX(), + loc.getY(), + loc.getZ(), + loc.getYaw(), + loc.getPitch(), + player.getHealth(), + maxHealth, + player.getFoodLevel(), + player.getSaturation(), + player.getExhaustion(), + player.getFireTicks(), + player.getRemainingAir(), + player.getLevel(), + player.getExp(), + player.getTotalExperience(), + player.getGameMode().name(), + player.getAllowFlight(), + player.isFlying(), + cloneContents(inventory.getStorageContents()), + cloneContents(inventory.getArmorContents()), + cloneContents(inventory.getExtraContents()), + new ArrayList<>(player.getActivePotionEffects())); + } + + /** + * Applies a saved snapshot to a player, teleporting them to their saved location and + * restoring their inventory, health, hunger, XP, game mode, and potion effects. + * + * @param player player to restore + * @param snapshot snapshot to apply + */ + public static void applySnapshot(Player player, PlayerSnapshot snapshot) { + World world = Bukkit.getWorld(snapshot.worldName()); + if (world != null) { + Location loc = + new Location(world, snapshot.x(), snapshot.y(), snapshot.z(), snapshot.yaw(), snapshot.pitch()); + player.teleport(loc, PlayerTeleportEvent.TeleportCause.PLUGIN); + } + + PlayerInventory inventory = player.getInventory(); + inventory.setStorageContents(cloneContents(snapshot.storageContents())); + inventory.setArmorContents(cloneContents(snapshot.armorContents())); + inventory.setExtraContents(cloneContents(snapshot.extraContents())); + + try { + Attribute attr = resolveMaxHealthAttribute(); + AttributeInstance instance = player.getAttribute(attr); + if (instance != null && instance.getBaseValue() != snapshot.maxHealth()) { + instance.setBaseValue(snapshot.maxHealth()); + } + } catch (ReflectiveOperationException ignored) { + } + + double clampedHealth = Math.max(0.0D, Math.min(snapshot.maxHealth(), snapshot.health())); + player.setHealth(clampedHealth <= 0.0D ? 0.5D : clampedHealth); + player.setFoodLevel(snapshot.foodLevel()); + player.setSaturation(snapshot.saturation()); + player.setExhaustion(snapshot.exhaustion()); + player.setFireTicks(snapshot.fireTicks()); + player.setRemainingAir(snapshot.remainingAir()); + player.setLevel(snapshot.level()); + player.setExp(snapshot.exp()); + player.setTotalExperience(snapshot.totalExperience()); + + try { + player.setGameMode(GameMode.valueOf(snapshot.gameMode())); + } catch (IllegalArgumentException ignored) { + } + + player.setAllowFlight(snapshot.allowFlight()); + player.setFlying(snapshot.flying() && snapshot.allowFlight()); + + for (PotionEffect active : player.getActivePotionEffects()) { + player.removePotionEffect(active.getType()); + } + for (PotionEffect effect : snapshot.potionEffects()) { + player.addPotionEffect(effect); + } + player.updateInventory(); + } + + private void saveItemArray(YamlConfiguration yaml, String prefix, ItemStack[] contents) { + yaml.set(prefix + ".size", contents.length); + for (int i = 0; i < contents.length; i++) { + if (contents[i] != null && !contents[i].getType().isAir()) { + yaml.set(prefix + "." + i, contents[i]); + } + } + } + + private static ItemStack[] loadItemArray(YamlConfiguration yaml, String prefix, int defaultSize) { + int size = yaml.getInt(prefix + ".size", defaultSize); + ItemStack[] result = new ItemStack[size]; + for (int i = 0; i < size; i++) { + result[i] = yaml.getItemStack(prefix + "." + i); + } + return result; + } + + @SuppressWarnings("unchecked") + private static List loadPotionEffects(YamlConfiguration yaml, String key) { + List raw = yaml.getList(key); + if (raw == null) { + return List.of(); + } + List effects = new ArrayList<>(); + for (Object obj : raw) { + if (obj instanceof PotionEffect effect) { + effects.add(effect); + } + } + return effects; + } + + private static PlayerSnapshot loadPlayerSnapshot(YamlConfiguration yaml, String prefix) { + String worldName = yaml.getString(prefix + ".worldName", ""); + double x = yaml.getDouble(prefix + ".x"); + double y = yaml.getDouble(prefix + ".y"); + double z = yaml.getDouble(prefix + ".z"); + float yaw = (float) yaml.getDouble(prefix + ".yaw"); + float pitch = (float) yaml.getDouble(prefix + ".pitch"); + double health = yaml.getDouble(prefix + ".health"); + double maxHealth = yaml.getDouble(prefix + ".maxHealth", DEFAULT_MAX_HEALTH); + int foodLevel = yaml.getInt(prefix + ".foodLevel"); + float saturation = (float) yaml.getDouble(prefix + ".saturation"); + float exhaustion = (float) yaml.getDouble(prefix + ".exhaustion"); + int fireTicks = yaml.getInt(prefix + ".fireTicks"); + int remainingAir = yaml.getInt(prefix + ".remainingAir"); + int level = yaml.getInt(prefix + ".level"); + float exp = (float) yaml.getDouble(prefix + ".exp"); + int totalExperience = yaml.getInt(prefix + ".totalExperience"); + String gameMode = yaml.getString(prefix + ".gameMode", "SURVIVAL"); + boolean allowFlight = yaml.getBoolean(prefix + ".allowFlight"); + boolean flying = yaml.getBoolean(prefix + ".flying"); + ItemStack[] storage = loadItemArray(yaml, prefix + ".storage", 36); + ItemStack[] armor = loadItemArray(yaml, prefix + ".armor", 4); + ItemStack[] extra = loadItemArray(yaml, prefix + ".extra", 1); + List effects = loadPotionEffects(yaml, prefix + ".potionEffects"); + + return new PlayerSnapshot( + worldName, + x, + y, + z, + yaw, + pitch, + health, + maxHealth, + foodLevel, + saturation, + exhaustion, + fireTicks, + remainingAir, + level, + exp, + totalExperience, + gameMode, + allowFlight, + flying, + storage, + armor, + extra, + effects); + } + + private static ItemStack[] cloneContents(ItemStack[] contents) { + ItemStack[] cloned = new ItemStack[contents.length]; + for (int i = 0; i < contents.length; i++) { + cloned[i] = contents[i] == null ? null : contents[i].clone(); + } + return cloned; + } + + private static Attribute resolveMaxHealthAttribute() throws ReflectiveOperationException { + try { + return (Attribute) Attribute.class.getField("MAX_HEALTH").get(null); + } catch (ReflectiveOperationException ignored) { + return (Attribute) Attribute.class.getField("GENERIC_MAX_HEALTH").get(null); + } + } +} diff --git a/src/main/java/dev/deepcore/challenge/session/SessionFailureService.java b/src/main/java/dev/deepcore/challenge/session/SessionFailureService.java index 9dbd9e4..4dbb733 100644 --- a/src/main/java/dev/deepcore/challenge/session/SessionFailureService.java +++ b/src/main/java/dev/deepcore/challenge/session/SessionFailureService.java @@ -90,6 +90,10 @@ public void handleAllPlayersDeadFailureIfNeeded() { return; } + if (challengeManager.isComponentEnabled(ChallengeComponent.SHARED_HEALTH)) { + return; + } + if (participants.isEmpty() || recentlyDeadPlayers.isEmpty()) { return; } diff --git a/src/main/java/dev/deepcore/challenge/session/SessionPlayerLifecycleService.java b/src/main/java/dev/deepcore/challenge/session/SessionPlayerLifecycleService.java index 581c360..6dc3554 100644 --- a/src/main/java/dev/deepcore/challenge/session/SessionPlayerLifecycleService.java +++ b/src/main/java/dev/deepcore/challenge/session/SessionPlayerLifecycleService.java @@ -381,6 +381,19 @@ public void handlePlayerDeath(PlayerDeathEvent event) { UUID playerId = player.getUniqueId(); respawnRoutingService.recordDeathWorld(playerId, player.getWorld()); + + if (isSharedInventoryEnabled.getAsBoolean() + && !challengeManager.isComponentEnabled(ChallengeComponent.KEEP_INVENTORY)) { + event.setKeepInventory(false); + for (Player participant : onlineParticipantsSupplier.get()) { + if (participant.getUniqueId().equals(playerId)) { + continue; + } + participant.getInventory().clear(); + participant.updateInventory(); + } + } + boolean hardcore = challengeManager.isComponentEnabled(ChallengeComponent.HARDCORE); if (hardcore) { eliminatedPlayers.add(playerId); @@ -402,7 +415,16 @@ public void handlePlayerRespawn(PlayerRespawnEvent event) { if (sessionState.is(SessionState.Phase.RUNNING) && participants.contains(playerId)) { Location runRespawn = respawnRoutingService.resolveRunRespawnLocation(playerId); - if (runRespawn != null) { + Location currentRespawn = event.getRespawnLocation(); + boolean preserveRespawn = currentRespawn != null + && currentRespawn.getWorld() != null + && runRespawn != null + && runRespawn.getWorld() != null + && currentRespawn + .getWorld() + .getUID() + .equals(runRespawn.getWorld().getUID()); + if (runRespawn != null && !preserveRespawn) { event.setRespawnLocation(runRespawn); } } @@ -431,13 +453,17 @@ public void handlePlayerRespawn(PlayerRespawnEvent event) { if (!sessionState.is(SessionState.Phase.RUNNING) || !eliminatedPlayers.contains(playerId)) { return; } - player.setGameMode(GameMode.SPECTATOR); - log.sendWarn(player, "You were eliminated by hardcore mode."); + if (eliminatedPlayers.containsAll(participants)) { + endChallengeAndReturnToPrep.run(); + } else { + player.setGameMode(GameMode.SPECTATOR); + log.sendWarn(player, "You were eliminated by hardcore mode."); + } }); } if (isSharedInventoryEnabled.getAsBoolean()) { - Bukkit.getScheduler().runTask(plugin, syncSharedInventoryFromFirstParticipant); + Bukkit.getScheduler().runTask(plugin, () -> syncSharedInventoryFromOtherParticipant(playerId)); } if (challengeManager.isComponentEnabled(ChallengeComponent.SHARED_HEALTH)) { @@ -467,6 +493,10 @@ public void handlePlayerChangedWorld(PlayerChangedWorldEvent event) { eliminatedPlayers); playerLobbyStateService.applyLobbyInventoryLoadoutIfInLobbyWorld(event.getPlayer()); + if (worldClassificationService.isLobbyOrLimboWorld(event.getPlayer().getWorld())) { + restoreDefaultMaxHealth.accept(event.getPlayer()); + } + if (worldClassificationService.isTrainingWorld(event.getPlayer().getWorld())) { prepBookService.removeFromInventory(event.getPlayer()); } @@ -490,4 +520,23 @@ public void handlePlayerChangedWorld(PlayerChangedWorldEvent event) { now, sessionState.is(SessionState.Phase.RUNNING)); } + + private void syncSharedInventoryFromOtherParticipant(UUID excludedPlayerId) { + if (!sessionState.is(SessionState.Phase.RUNNING) || !isSharedInventoryEnabled.getAsBoolean()) { + return; + } + + for (Player participant : onlineParticipantsSupplier.get()) { + if (participant.getUniqueId().equals(excludedPlayerId)) { + continue; + } + + if (participant.getGameMode() == GameMode.SPECTATOR) { + continue; + } + + sharedInventorySyncService.syncSharedInventoryFromSourceNow(participant); + return; + } + } } diff --git a/src/main/java/dev/deepcore/challenge/session/SessionRulesCoordinatorService.java b/src/main/java/dev/deepcore/challenge/session/SessionRulesCoordinatorService.java index f3066be..5f19e33 100644 --- a/src/main/java/dev/deepcore/challenge/session/SessionRulesCoordinatorService.java +++ b/src/main/java/dev/deepcore/challenge/session/SessionRulesCoordinatorService.java @@ -5,6 +5,7 @@ import dev.deepcore.challenge.world.WorldResetManager; import java.util.function.Supplier; import org.bukkit.Bukkit; +import org.bukkit.Difficulty; import org.bukkit.GameRule; import org.bukkit.World; @@ -39,8 +40,12 @@ public SessionRulesCoordinatorService( public void syncWorldRules() { boolean keepInventory = challengeManager.isEnabled() && challengeManager.isComponentEnabled(ChallengeComponent.KEEP_INVENTORY); + Difficulty worldDifficulty = challengeManager.isEnabled() + ? Difficulty.valueOf(challengeManager.getDifficulty().name()) + : Difficulty.NORMAL; for (World world : Bukkit.getWorlds()) { world.setGameRule(GameRule.KEEP_INVENTORY, keepInventory); + world.setDifficulty(worldDifficulty); } WorldResetManager worldResetManager = worldResetManagerSupplier.get(); diff --git a/src/main/java/dev/deepcore/challenge/session/SessionTimingState.java b/src/main/java/dev/deepcore/challenge/session/SessionTimingState.java index c850c68..79e2a09 100644 --- a/src/main/java/dev/deepcore/challenge/session/SessionTimingState.java +++ b/src/main/java/dev/deepcore/challenge/session/SessionTimingState.java @@ -6,6 +6,20 @@ public final class SessionTimingState { private long pausedStartedMillis; private long accumulatedPausedMillis; + /** + * Restores timing state from a persistent snapshot, advancing pause + * accumulation to account for lobby time since the run was saved. + * + * @param runStartMs original run start timestamp in milliseconds + * @param accumulatedPausedMs total paused duration at the time the run was saved + * @param savedAtMs wall-clock timestamp when the run was saved + */ + public void restore(long runStartMs, long accumulatedPausedMs, long savedAtMs) { + this.runStartMillis = runStartMs; + this.accumulatedPausedMillis = accumulatedPausedMs + Math.max(0L, System.currentTimeMillis() - savedAtMs); + this.pausedStartedMillis = 0L; + } + /** Resets all tracked timing values to zero. */ public void reset() { runStartMillis = 0L; diff --git a/src/main/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorService.java b/src/main/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorService.java index b596fa4..116ccb8 100644 --- a/src/main/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorService.java +++ b/src/main/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorService.java @@ -281,7 +281,9 @@ public void resetForNewRun() { prepAreaService.applyBordersToOnlinePlayers( sessionState.is(SessionState.Phase.RUNNING), worldClassificationService::isPrepBorderExemptWorld); refreshLobbyPreview.run(); - Bukkit.getScheduler().runTask(plugin, refreshOpenPrepGuis); + if (plugin.isEnabled()) { + Bukkit.getScheduler().runTask(plugin, refreshOpenPrepGuis); + } } /** Ends an active challenge and transitions all players back to prep mode. */ diff --git a/src/main/java/dev/deepcore/challenge/session/SessionUiCoordinatorService.java b/src/main/java/dev/deepcore/challenge/session/SessionUiCoordinatorService.java index 4ce07bd..9b22391 100644 --- a/src/main/java/dev/deepcore/challenge/session/SessionUiCoordinatorService.java +++ b/src/main/java/dev/deepcore/challenge/session/SessionUiCoordinatorService.java @@ -23,7 +23,7 @@ public final class SessionUiCoordinatorService { private final Supplier> onlineParticipantsSupplier; private final ParticipantsView participantsView; private final SidebarModelFactory sidebarModelFactory; - private final Supplier recordsServiceSupplier; + private final Supplier recordsServiceSupplier; private final IntSupplier readyCountSupplier; private final LobbySidebarCoordinatorService lobbySidebarCoordinatorService; private final LobbySidebarService lobbySidebarService; @@ -56,7 +56,7 @@ public SessionUiCoordinatorService( Supplier> onlineParticipantsSupplier, ParticipantsView participantsView, SidebarModelFactory sidebarModelFactory, - Supplier recordsServiceSupplier, + Supplier recordsServiceSupplier, IntSupplier readyCountSupplier, LobbySidebarCoordinatorService lobbySidebarCoordinatorService, LobbySidebarService lobbySidebarService) { diff --git a/src/main/java/dev/deepcore/challenge/session/SidebarModelFactory.java b/src/main/java/dev/deepcore/challenge/session/SidebarModelFactory.java index 28b4072..2cbf27a 100644 --- a/src/main/java/dev/deepcore/challenge/session/SidebarModelFactory.java +++ b/src/main/java/dev/deepcore/challenge/session/SidebarModelFactory.java @@ -16,7 +16,7 @@ public final class SidebarModelFactory { * @return sidebar model snapshot for lobby rendering */ public SidebarModel create( - dev.deepcore.records.RunRecordsService recordsService, + dev.deepcore.challenge.records.RunRecordsService recordsService, SessionState.Phase phase, int readyCount, int onlineCount) { diff --git a/src/main/java/dev/deepcore/challenge/training/TrainingManager.java b/src/main/java/dev/deepcore/challenge/training/TrainingManager.java index 2240d4d..e9841e4 100644 --- a/src/main/java/dev/deepcore/challenge/training/TrainingManager.java +++ b/src/main/java/dev/deepcore/challenge/training/TrainingManager.java @@ -86,7 +86,6 @@ public final class TrainingManager implements Listener { private final Map arenaSnapshots; private final Map hiddenStartButtonSnapshots; private final Map> trackedPortalLavaByChallenge; - private final Map lastDeathWorldByPlayer; private final Map pendingAttemptRespawnByPlayer; private final Map bridgeParticleTaskByPlayer; @@ -113,7 +112,6 @@ public TrainingManager(JavaPlugin plugin) { 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<>(); } @@ -816,10 +814,9 @@ && sameBlock(event.getTo(), definition.startLocation())) { 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); + clearAttemptInventoryAndRestoreAdventure(player); } cancelAttempt(player, null); } @@ -828,10 +825,9 @@ public void onAttemptQuit(PlayerQuitEvent event) { 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); + clearAttemptInventoryAndRestoreAdventure(player); } cancelAttempt(player, null); } @@ -856,14 +852,9 @@ public void onPlayerDeath(PlayerDeathEvent event) { event.getDrops().clear(); event.setDroppedExp(0); - clearAttemptInventoryAndForceSurvival(player); + clearAttemptInventoryAndRestoreAdventure(player); cancelAttempt(player, "Attempt cancelled: you died."); } - - if (world == null) { - return; - } - lastDeathWorldByPlayer.put(player.getUniqueId(), world.getName()); } @EventHandler(priority = EventPriority.HIGH) @@ -872,19 +863,9 @@ public void onPlayerRespawn(PlayerRespawnEvent event) { Location pendingAttemptRespawn = pendingAttemptRespawnByPlayer.remove(playerId); if (pendingAttemptRespawn != null) { event.setRespawnLocation(pendingAttemptRespawn); - Bukkit.getScheduler().runTask(plugin, () -> clearAttemptInventoryAndForceSurvival(event.getPlayer())); + Bukkit.getScheduler().runTask(plugin, () -> clearAttemptInventoryAndRestoreAdventure(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() { @@ -1025,6 +1006,10 @@ private boolean startAttempt(Player player, TrainingChallengeType type, boolean startBridgeVisuals(player, bridgeDestinationPlate); } + if (type == TrainingChallengeType.BRIDGE) { + player.setGameMode(org.bukkit.GameMode.SURVIVAL); + } + if (teleportToStartLocation) { Location startLoc = bridgeStartLocation != null ? bridgeStartLocation : definition.startLocation(); player.teleport(startLoc); @@ -1223,7 +1208,7 @@ private void completeAttempt(Player player, ActiveAttempt attempt) { player.closeInventory(); ItemStack slot8 = player.getInventory().getItem(TrainingReturnItemService.RETURN_ITEM_SLOT); boolean hasReturnItem = returnItemService != null && returnItemService.isReturnItem(slot8); - clearAttemptInventoryAndForceSurvival(player); + clearAttemptInventoryAndRestoreAdventure(player); if (hasReturnItem) { player.getInventory().setItem(TrainingReturnItemService.RETURN_ITEM_SLOT, slot8); } @@ -1256,9 +1241,14 @@ private void cancelAttempt(Player player, String message) { player.stopSound(TRAINING_MUSIC_SOUND, SoundCategory.MUSIC); stopBridgeVisuals(player); restoreAttemptArenaState(attempt.type()); - clearAttempt(player, attempt.type()); + TrainingChallengeType cancelledType = attempt.type(); + clearAttempt(player, cancelledType); player.sendActionBar(Component.empty()); + if (cancelledType == TrainingChallengeType.BRIDGE) { + player.setGameMode(org.bukkit.GameMode.ADVENTURE); + } + if (returnItemService != null) { returnItemService.onPlayerLeaveTraining(player); } @@ -1419,7 +1409,7 @@ private ItemStack[] cloneContents(ItemStack[] original) { return copy; } - private void clearAttemptInventoryAndForceSurvival(Player player) { + private void clearAttemptInventoryAndRestoreAdventure(Player player) { if (player == null) { return; } @@ -1429,7 +1419,7 @@ private void clearAttemptInventoryAndForceSurvival(Player player) { inventory.setArmorContents(new ItemStack[4]); inventory.setExtraContents(new ItemStack[inventory.getExtraContents().length]); player.setItemOnCursor(null); - player.setGameMode(org.bukkit.GameMode.SURVIVAL); + player.setGameMode(org.bukkit.GameMode.ADVENTURE); player.updateInventory(); } diff --git a/src/main/java/dev/deepcore/challenge/PrepGuiPage.java b/src/main/java/dev/deepcore/challenge/ui/PrepGuiPage.java similarity index 89% rename from src/main/java/dev/deepcore/challenge/PrepGuiPage.java rename to src/main/java/dev/deepcore/challenge/ui/PrepGuiPage.java index 50e76e4..8095512 100644 --- a/src/main/java/dev/deepcore/challenge/PrepGuiPage.java +++ b/src/main/java/dev/deepcore/challenge/ui/PrepGuiPage.java @@ -1,4 +1,4 @@ -package dev.deepcore.challenge; +package dev.deepcore.challenge.ui; /** * Prep GUI page identifiers. diff --git a/src/main/java/dev/deepcore/challenge/ui/PrepGuiRenderer.java b/src/main/java/dev/deepcore/challenge/ui/PrepGuiRenderer.java index 0660287..6122e4d 100644 --- a/src/main/java/dev/deepcore/challenge/ui/PrepGuiRenderer.java +++ b/src/main/java/dev/deepcore/challenge/ui/PrepGuiRenderer.java @@ -1,8 +1,9 @@ package dev.deepcore.challenge.ui; import dev.deepcore.challenge.ChallengeComponent; +import dev.deepcore.challenge.ChallengeDifficulty; import dev.deepcore.challenge.ChallengeManager; -import dev.deepcore.records.RunRecord; +import dev.deepcore.challenge.records.RunRecord; import java.time.Instant; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -47,9 +48,17 @@ public void applyPrepGuiDecorations(Inventory inventory) { * @param readyCount number of ready participants * @param onlineCount number of online participants * @param previewEnabled whether world preview mechanics are enabled + * @param difficulty currently selected challenge difficulty + * @param savedRunExists whether a persistent saved run is available to restore */ public void populateCategoriesPage( - Inventory inventory, boolean ready, int readyCount, int onlineCount, boolean previewEnabled) { + Inventory inventory, + boolean ready, + int readyCount, + int onlineCount, + boolean previewEnabled, + ChallengeDifficulty difficulty, + boolean savedRunExists) { inventory.setItem(4, createInfoItem(Material.NETHER_STAR, "Mechanic Categories", "Select a category")); inventory.setItem( 20, createInfoItem(Material.CHEST, "Inventory Mechanics", "Open inventory-related mechanics")); @@ -60,23 +69,32 @@ public void populateCategoriesPage( "Completed Runs", "View successful runs with date, participants, and split times")); inventory.setItem(24, createInfoItem(Material.GOLDEN_APPLE, "Health Mechanics", "Open health mechanics")); - inventory.setItem(45, createInfoItem(Material.BARRIER, "Close", "Close this menu")); - inventory.setItem(47, createToggleItem("Ready", ready, "Each online player must ready up")); + if (savedRunExists) { + inventory.setItem(30, createLockedItem("Regenerate World", "Clear the saved run before regenerating")); + } else { + inventory.setItem( + 30, + createInfoItem( + Material.RECOVERY_COMPASS, + "Regenerate World", + previewEnabled + ? "Create a new run world and refresh pedestal preview" + : "Create a new run world")); + } + inventory.setItem(31, createDifficultyItem(difficulty)); + inventory.setItem(32, createInfoItem(Material.ENDER_PEARL, "Training World", "Teleport to the training gym")); + if (savedRunExists) { + inventory.setItem( + 33, + createInfoItem(Material.RESPAWN_ANCHOR, "Restore Saved Run", "Resume a previously saved speedrun")); + } + inventory.setItem(48, createToggleItem("Ready", ready, "Each online player must ready up")); inventory.setItem( - 49, + 50, createInfoItem( 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")); - inventory.setItem(53, createInfoItem(Material.ENDER_PEARL, "Training World", "Teleport to the training gym")); } /** @@ -291,11 +309,33 @@ private ItemStack createRunRecordItem( + " - Nether to End"); lore.add(ChatColor.AQUA + durationFormatter.apply(record.getEndToDragonMs()) + ChatColor.GRAY + " - End to Dragon"); + lore.add(ChatColor.DARK_GRAY + " "); + lore.add(ChatColor.GOLD + "Difficulty: " + ChatColor.WHITE + formatDifficulty(record.getDifficulty())); + lore.add(ChatColor.GOLD + "Mechanics"); + List componentKeys = record.getComponentKeys(); + if (componentKeys.isEmpty()) { + lore.add(ChatColor.GRAY + "Standard (no modifiers)"); + } else { + for (String key : componentKeys) { + ChallengeComponent component = ChallengeComponent.fromKey(key).orElse(null); + String label = component != null ? component.displayName() : key; + lore.add(ChatColor.GREEN + "• " + ChatColor.WHITE + label); + } + } meta.setLore(lore); item.setItemMeta(meta); return item; } + private String formatDifficulty(String difficultyKey) { + if (difficultyKey == null || difficultyKey.isBlank()) { + return ChallengeDifficulty.NORMAL.displayName(); + } + return ChallengeDifficulty.fromKey(difficultyKey) + .map(ChallengeDifficulty::displayName) + .orElse(difficultyKey); + } + private String formatParticipantNames(List names) { if (names == null || names.isEmpty()) { return "Unknown"; @@ -351,6 +391,36 @@ private ItemStack createMechanicItem( return item; } + private ItemStack createDifficultyItem(ChallengeDifficulty difficulty) { + Material material = + switch (difficulty) { + case EASY -> Material.LIME_DYE; + case NORMAL -> Material.YELLOW_DYE; + case HARD -> Material.RED_DYE; + }; + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.YELLOW + "Difficulty: " + ChatColor.WHITE + difficulty.displayName()); + meta.setLore(List.of(ChatColor.GRAY + "Click to cycle difficulty")); + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createLockedItem(String name, String reason) { + ItemStack item = new ItemStack(Material.BARRIER); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.RED + name); + meta.setLore(List.of(ChatColor.DARK_RED + reason)); + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES); + item.setItemMeta(meta); + } + return item; + } + private ItemStack createEdgePaneItem() { ItemStack item = new ItemStack(Material.BLACK_STAINED_GLASS_PANE); ItemMeta meta = item.getItemMeta(); diff --git a/src/main/java/dev/deepcore/challenge/vitals/SharedVitalsService.java b/src/main/java/dev/deepcore/challenge/vitals/SharedVitalsService.java index b87c2e2..0402ba5 100644 --- a/src/main/java/dev/deepcore/challenge/vitals/SharedVitalsService.java +++ b/src/main/java/dev/deepcore/challenge/vitals/SharedVitalsService.java @@ -86,6 +86,10 @@ public void syncHealthAcrossParticipants(double targetHealth, UUID damageSourceI continue; } + if (participant.getHealth() <= 0.0D) { + continue; + } + double previousHealth = participant.getHealth(); double maxHealth = participant .getAttribute(resolveMaxHealthAttribute()) @@ -112,7 +116,7 @@ public void syncHealthAcrossParticipants(double targetHealth, UUID damageSourceI }); } - /** Copies health from the first active participant to the entire team. */ + /** Copies health from the first active participant to the entire team, including dead players. */ public void syncSharedHealthFromFirstParticipant() { List onlineParticipants = playersForSharedVitals.get(); for (Player participant : onlineParticipants) { @@ -120,7 +124,27 @@ public void syncSharedHealthFromFirstParticipant() { continue; } - syncHealthAcrossParticipants(participant.getHealth()); + if (participant.getHealth() <= 0.0D) { + continue; + } + + double sourceHealth = participant.getHealth(); + Bukkit.getScheduler().runTask(plugin, () -> { + syncingHealth = true; + try { + for (Player p : playersForSharedVitals.get()) { + if (p.getGameMode() == GameMode.SPECTATOR) { + continue; + } + double maxHealth = + p.getAttribute(resolveMaxHealthAttribute()).getValue(); + double clamped = Math.max(0.0D, Math.min(maxHealth, sourceHealth)); + p.setHealth(clamped <= 0.0D ? 0.5D : clamped); + } + } finally { + syncingHealth = false; + } + }); return; } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 867b57e..59bece3 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -49,6 +49,9 @@ challenge: initial_half_heart: false degrading_inventory: false +records: + db-name: records.db + prep: countdown-seconds: 10 diff --git a/src/test/java/dev/deepcore/DeepCorePluginTest.java b/src/test/java/dev/deepcore/DeepCorePluginTest.java index f6a6c30..584510d 100644 --- a/src/test/java/dev/deepcore/DeepCorePluginTest.java +++ b/src/test/java/dev/deepcore/DeepCorePluginTest.java @@ -82,7 +82,8 @@ void onDisable_persistsAndShutsDownRuntimeServicesWhenPresent() { 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); + dev.deepcore.challenge.records.RunRecordsService recordsService = + mock(dev.deepcore.challenge.records.RunRecordsService.class); TrainingManager trainingManager = mock(TrainingManager.class); when(runtime.getChallengeManager()).thenReturn(challengeManager); @@ -111,7 +112,8 @@ void onDisable_doesNotForceEndWhenSessionNotRunningOrPaused() { 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); + dev.deepcore.challenge.records.RunRecordsService recordsService = + mock(dev.deepcore.challenge.records.RunRecordsService.class); TrainingManager trainingManager = mock(TrainingManager.class); when(runtime.getChallengeManager()).thenReturn(challengeManager); diff --git a/src/test/java/dev/deepcore/challenge/ChallengeCoreCommandHandlerTest.java b/src/test/java/dev/deepcore/challenge/ChallengeCoreCommandHandlerTest.java index 5244f32..9938c10 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeCoreCommandHandlerTest.java +++ b/src/test/java/dev/deepcore/challenge/ChallengeCoreCommandHandlerTest.java @@ -9,6 +9,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import dev.deepcore.challenge.command.ChallengeAdminFacade; +import dev.deepcore.challenge.command.ChallengeCoreCommandHandler; import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.logging.DeepCoreLogger; import java.util.EnumMap; @@ -248,6 +250,41 @@ void handle_list_and_modeWithoutArgument_showExpectedMessages() { verify(logger).sendInfo(any(CommandSender.class), contains("Usage: /challenge mode")); } + @Test + void handle_saverun_restore_successAndFailurePaths() { + // Player saverun checks + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(adminFacade.isRunningPhase()).thenReturn(true); + assertTrue(handler.handle(player, new String[] {"saverun"})); + verify(adminFacade).castSaveVote(player); + + when(adminFacade.isRunningPhase()).thenReturn(false); + assertTrue(handler.handle(player, new String[] {"saverun"})); + verify(logger).sendInfo(any(CommandSender.class), contains("not currently running")); + + assertTrue(handler.handle(sender, new String[] {"saverun"})); + verify(logger).sendInfo(any(CommandSender.class), contains("Only players can vote")); + + // Restore checks + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); + when(adminFacade.isPrepPhase()).thenReturn(true); + when(adminFacade.restoreSavedRun(sender)).thenReturn(true); + assertTrue(handler.handle(sender, new String[] {"restore"})); + verify(adminFacade).restoreSavedRun(sender); + + when(adminFacade.restoreSavedRun(sender)).thenReturn(false); + assertTrue(handler.handle(sender, new String[] {"restore"})); + verify(logger).sendInfo(any(CommandSender.class), contains("Failed to restore saved run")); + + when(adminFacade.isPrepPhase()).thenReturn(false); + assertTrue(handler.handle(sender, new String[] {"restore"})); + verify(logger).sendInfo(any(CommandSender.class), contains("Can only restore")); + + when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(false); + assertTrue(handler.handle(sender, new String[] {"restore"})); + verify(logger, atLeastOnce()).sendInfo(any(CommandSender.class), contains("do not have permission")); + } + @Test void handle_componentStatusUsageAndInvalidOperation_paths() { when(sender.hasPermission("deepcore.challenge.admin")).thenReturn(true); diff --git a/src/test/java/dev/deepcore/challenge/ChallengeLogsCommandHandlerTest.java b/src/test/java/dev/deepcore/challenge/ChallengeLogsCommandHandlerTest.java index 364c25f..679eff5 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeLogsCommandHandlerTest.java +++ b/src/test/java/dev/deepcore/challenge/ChallengeLogsCommandHandlerTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import dev.deepcore.challenge.command.ChallengeLogsCommandHandler; import dev.deepcore.logging.DeepCoreLogLevel; import dev.deepcore.logging.DeepCoreLogger; import java.util.List; diff --git a/src/test/java/dev/deepcore/challenge/ChallengeRuntimeInitializerTest.java b/src/test/java/dev/deepcore/challenge/ChallengeRuntimeInitializerTest.java index 2560066..9ba4251 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeRuntimeInitializerTest.java +++ b/src/test/java/dev/deepcore/challenge/ChallengeRuntimeInitializerTest.java @@ -8,10 +8,11 @@ import static org.mockito.Mockito.when; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.command.ChallengeCommand; +import dev.deepcore.challenge.records.RunRecordsService; import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; -import dev.deepcore.records.RunRecordsService; import org.bukkit.command.PluginCommand; import org.junit.jupiter.api.Test; import org.mockito.MockedConstruction; @@ -28,6 +29,7 @@ void initialize_wiresServicesAndRegistersChallengeCommand() { when(plugin.getCommand("challenge")).thenReturn(challengeCommandBinding); when(plugin.getCommand("lobby")).thenReturn(lobbyCommandBinding); when(plugin.getDeepCoreLogger()).thenReturn(logger); + when(plugin.getConfig()).thenReturn(mock(org.bukkit.configuration.file.FileConfiguration.class)); try (MockedConstruction managerConstruction = org.mockito.Mockito.mockConstruction(ChallengeManager.class); @@ -84,6 +86,7 @@ void initialize_throwsWhenChallengeCommandMissing() { when(plugin.getCommand("challenge")).thenReturn(null); when(plugin.getCommand("lobby")).thenReturn(mock(PluginCommand.class)); + when(plugin.getConfig()).thenReturn(mock(org.bukkit.configuration.file.FileConfiguration.class)); try (MockedConstruction managerConstruction = org.mockito.Mockito.mockConstruction(ChallengeManager.class); @@ -108,6 +111,7 @@ void initialize_throwsWhenLobbyCommandMissing() { when(plugin.getCommand("challenge")).thenReturn(mock(PluginCommand.class)); when(plugin.getCommand("lobby")).thenReturn(null); + when(plugin.getConfig()).thenReturn(mock(org.bukkit.configuration.file.FileConfiguration.class)); try (MockedConstruction managerConstruction = org.mockito.Mockito.mockConstruction(ChallengeManager.class); diff --git a/src/test/java/dev/deepcore/challenge/ChallengeSessionManagerSmokeTest.java b/src/test/java/dev/deepcore/challenge/ChallengeSessionManagerSmokeTest.java index d42792a..0323901 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeSessionManagerSmokeTest.java +++ b/src/test/java/dev/deepcore/challenge/ChallengeSessionManagerSmokeTest.java @@ -79,7 +79,7 @@ void delegatingMethods_invokeBackedServices() throws Exception { WorldResetManager worldResetManager = mock(WorldResetManager.class); manager.setWorldResetManager(worldResetManager); - manager.setRecordsService(mock(dev.deepcore.records.RunRecordsService.class)); + manager.setRecordsService(mock(dev.deepcore.challenge.records.RunRecordsService.class)); manager.initialize(); manager.registerEventListeners(); @@ -177,7 +177,8 @@ void setters_storeInjectedServices() throws Exception { try (MockedConstruction ignored = org.mockito.Mockito.mockConstruction(NamespacedKey.class)) { ChallengeSessionManager manager = new ChallengeSessionManager(plugin, challengeManager); WorldResetManager worldResetManager = mock(WorldResetManager.class); - dev.deepcore.records.RunRecordsService recordsService = mock(dev.deepcore.records.RunRecordsService.class); + dev.deepcore.challenge.records.RunRecordsService recordsService = + mock(dev.deepcore.challenge.records.RunRecordsService.class); manager.setWorldResetManager(worldResetManager); manager.setRecordsService(recordsService); diff --git a/src/test/java/dev/deepcore/challenge/ChallengeAdminFacadeTest.java b/src/test/java/dev/deepcore/challenge/command/ChallengeAdminFacadeTest.java similarity index 95% rename from src/test/java/dev/deepcore/challenge/ChallengeAdminFacadeTest.java rename to src/test/java/dev/deepcore/challenge/command/ChallengeAdminFacadeTest.java index 6f56958..aa1b85b 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeAdminFacadeTest.java +++ b/src/test/java/dev/deepcore/challenge/command/ChallengeAdminFacadeTest.java @@ -1,4 +1,4 @@ -package dev.deepcore.challenge; +package dev.deepcore.challenge.command; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -11,6 +11,10 @@ import static org.mockito.Mockito.when; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.ChallengeComponent; +import dev.deepcore.challenge.ChallengeManager; +import dev.deepcore.challenge.ChallengeMode; +import dev.deepcore.challenge.ChallengeSessionManager; import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; diff --git a/src/test/java/dev/deepcore/challenge/ChallengeCommandTest.java b/src/test/java/dev/deepcore/challenge/command/ChallengeCommandTest.java similarity index 94% rename from src/test/java/dev/deepcore/challenge/ChallengeCommandTest.java rename to src/test/java/dev/deepcore/challenge/command/ChallengeCommandTest.java index b7b807b..eb2290c 100644 --- a/src/test/java/dev/deepcore/challenge/ChallengeCommandTest.java +++ b/src/test/java/dev/deepcore/challenge/command/ChallengeCommandTest.java @@ -1,4 +1,4 @@ -package dev.deepcore.challenge; +package dev.deepcore.challenge.command; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -8,6 +8,10 @@ import static org.mockito.Mockito.when; import dev.deepcore.DeepCorePlugin; +import dev.deepcore.challenge.ChallengeComponent; +import dev.deepcore.challenge.ChallengeManager; +import dev.deepcore.challenge.ChallengeMode; +import dev.deepcore.challenge.ChallengeSessionManager; import dev.deepcore.challenge.training.TrainingManager; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; diff --git a/src/test/java/dev/deepcore/challenge/LobbyCommandTest.java b/src/test/java/dev/deepcore/challenge/command/LobbyCommandTest.java similarity index 96% rename from src/test/java/dev/deepcore/challenge/LobbyCommandTest.java rename to src/test/java/dev/deepcore/challenge/command/LobbyCommandTest.java index 8f8191e..4f73716 100644 --- a/src/test/java/dev/deepcore/challenge/LobbyCommandTest.java +++ b/src/test/java/dev/deepcore/challenge/command/LobbyCommandTest.java @@ -1,4 +1,4 @@ -package dev.deepcore.challenge; +package dev.deepcore.challenge.command; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -6,6 +6,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import dev.deepcore.challenge.ChallengeSessionManager; import dev.deepcore.challenge.training.TrainingManager; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; diff --git a/src/test/java/dev/deepcore/challenge/events/InventoryMechanicsListenerTest.java b/src/test/java/dev/deepcore/challenge/events/InventoryMechanicsListenerTest.java new file mode 100644 index 0000000..c4dbe75 --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/events/InventoryMechanicsListenerTest.java @@ -0,0 +1,42 @@ +package dev.deepcore.challenge.events; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import dev.deepcore.challenge.inventory.InventoryMechanicsCoordinatorService; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCreativeEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class InventoryMechanicsListenerTest { + private InventoryMechanicsCoordinatorService coordinator; + private InventoryMechanicsListener listener; + + @BeforeEach + void setup() { + coordinator = mock(InventoryMechanicsCoordinatorService.class); + listener = new InventoryMechanicsListener(coordinator); + } + + @Test + void delegatesInventoryEvents() { + InventoryClickEvent click = mock(InventoryClickEvent.class); + listener.onInventoryClick(click); + verify(coordinator).handleInventoryClick(click); + + InventoryCreativeEvent creative = mock(InventoryCreativeEvent.class); + listener.onInventoryCreative(creative); + verify(coordinator).handleInventoryCreative(creative); + + InventoryDragEvent drag = mock(InventoryDragEvent.class); + listener.onInventoryDrag(drag); + verify(coordinator).handleInventoryDrag(drag); + + PlayerDropItemEvent drop = mock(PlayerDropItemEvent.class); + listener.onDrop(drop); + verify(coordinator).handlePlayerDropItem(drop); + } +} diff --git a/src/test/java/dev/deepcore/challenge/inventory/InventoryMechanicsCoordinatorServiceTest.java b/src/test/java/dev/deepcore/challenge/inventory/InventoryMechanicsCoordinatorServiceTest.java index 3d8c0fa..aa42413 100644 --- a/src/test/java/dev/deepcore/challenge/inventory/InventoryMechanicsCoordinatorServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/inventory/InventoryMechanicsCoordinatorServiceTest.java @@ -1,8 +1,10 @@ package dev.deepcore.challenge.inventory; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -24,6 +26,7 @@ import org.bukkit.Sound; import org.bukkit.SoundCategory; import org.bukkit.entity.AbstractArrow; +import org.bukkit.entity.Boat; import org.bukkit.entity.Egg; import org.bukkit.entity.EnderPearl; import org.bukkit.entity.ItemFrame; @@ -37,6 +40,7 @@ import org.bukkit.event.block.BlockMultiPlaceEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.EntityPlaceEvent; import org.bukkit.event.entity.EntityResurrectEvent; import org.bukkit.event.entity.ProjectileLaunchEvent; import org.bukkit.event.inventory.CraftItemEvent; @@ -125,6 +129,56 @@ void handleInventoryClick_cancelsLockedSlot_andSkipsSync() { verify(sharedService, never()).requestSharedInventorySync(any()); } + @Test + void handleInventoryClick_schedulesWearableDetectBeforeInventorySync() { + // When a player shift-clicks armor into an equipment slot, both wearable detect + // and the full inventory sync are deferred to the next tick via runTask. Bukkit + // executes runTask callbacks in FIFO order, so the wearable detect must be + // scheduled first. If the sync fires first it clears the equip source slot on + // other participants before the consume runs; the fallback search then removes + // the wrong item (e.g. the second set's piece instead of the equipped one). + JavaPlugin plugin = mock(JavaPlugin.class); + ChallengeManager challengeManager = mock(ChallengeManager.class); + DegradingInventoryService degradingService = mock(DegradingInventoryService.class); + SharedInventorySyncService sharedService = mock(SharedInventorySyncService.class); + + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getInventory()).thenReturn(inventory); + + ItemStack chestplate = new ItemStack(Material.IRON_CHESTPLATE); + InventoryClickEvent event = mock(InventoryClickEvent.class); + when(event.getWhoClicked()).thenReturn(player); + when(event.getAction()).thenReturn(org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY); + when(event.getClickedInventory()).thenReturn(inventory); + when(event.getCurrentItem()).thenReturn(chestplate); + when(event.getSlot()).thenReturn(1); + + InventoryMechanicsCoordinatorService service = new InventoryMechanicsCoordinatorService( + plugin, + challengeManager, + degradingService, + sharedService, + p -> true, + () -> true, + List::of, + p -> {}, + mock(DeepCoreLogger.class)); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + service.handleInventoryClick(event); + } + + // requestWearableEquipSync must be called before scheduler.runTask so that + // detectNewlyEquippedWearables is queued ahead of requestSharedInventorySync + inOrder(sharedService, scheduler).verify(sharedService).requestWearableEquipSync(eq(player), eq(1)); + inOrder(sharedService, scheduler).verify(scheduler).runTask(eq(plugin), any(Runnable.class)); + } + @Test void handleInventoryEvents_scheduleCapAndRequestSyncs_whenEligible() { JavaPlugin plugin = mock(JavaPlugin.class); @@ -134,13 +188,19 @@ void handleInventoryEvents_scheduleCapAndRequestSyncs_whenEligible() { @SuppressWarnings("unchecked") Consumer enforceCap = mock(Consumer.class); + UUID playerId = UUID.randomUUID(); Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + InventoryClickEvent clickEvent = mock(InventoryClickEvent.class); InventoryCreativeEvent creativeEvent = mock(InventoryCreativeEvent.class); CraftItemEvent craftEvent = mock(CraftItemEvent.class); PlayerItemConsumeEvent consumeEvent = mock(PlayerItemConsumeEvent.class); BlockPlaceEvent placeEvent = mock(BlockPlaceEvent.class); BlockMultiPlaceEvent multiPlaceEvent = mock(BlockMultiPlaceEvent.class); + EntityPlaceEvent entityPlaceEvent = mock(EntityPlaceEvent.class); + Boat boatEntity = mock(Boat.class); + when(boatEntity.getBoatType()).thenReturn(Boat.Type.OAK); when(clickEvent.getWhoClicked()).thenReturn(player); when(creativeEvent.getWhoClicked()).thenReturn(player); @@ -148,6 +208,8 @@ void handleInventoryEvents_scheduleCapAndRequestSyncs_whenEligible() { when(consumeEvent.getPlayer()).thenReturn(player); when(placeEvent.getPlayer()).thenReturn(player); when(multiPlaceEvent.getPlayer()).thenReturn(player); + when(entityPlaceEvent.getPlayer()).thenReturn(player); + when(entityPlaceEvent.getEntity()).thenReturn(boatEntity); when(challengeManager.isComponentEnabled(ChallengeComponent.DEGRADING_INVENTORY)) .thenReturn(true); @@ -173,6 +235,13 @@ void handleInventoryEvents_scheduleCapAndRequestSyncs_whenEligible() { }) .when(scheduler) .runTask(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }) + .when(scheduler) + .runTaskLater(eq(plugin), any(Runnable.class), eq(1L)); service.handleInventoryClick(clickEvent); service.handleInventoryCreative(creativeEvent); @@ -180,11 +249,32 @@ void handleInventoryEvents_scheduleCapAndRequestSyncs_whenEligible() { service.handlePlayerItemConsume(consumeEvent); service.handleBlockPlace(placeEvent); service.handleBlockMultiPlace(multiPlaceEvent); + service.handleEntityPlace(entityPlaceEvent); } - verify(enforceCap, org.mockito.Mockito.times(6)).accept(player); + verify(enforceCap, org.mockito.Mockito.times(7)).accept(player); verify(sharedService, org.mockito.Mockito.times(6)).requestSharedInventorySync(player); - verify(sharedService, org.mockito.Mockito.times(2)).requestWearableEquipSync(player); + verify(sharedService).consumeItemFromOtherParticipants(Material.OAK_BOAT, playerId); + verify(sharedService, org.mockito.Mockito.times(2)).requestWearableEquipSync(eq(player), anyInt()); + } + + @Test + void handleEntityPlace_ignoresNullPlayer() { + InventoryMechanicsCoordinatorService service = new InventoryMechanicsCoordinatorService( + mock(JavaPlugin.class), + mock(ChallengeManager.class), + mock(DegradingInventoryService.class), + mock(SharedInventorySyncService.class), + p -> true, + () -> true, + List::of, + p -> {}, + mock(DeepCoreLogger.class)); + + EntityPlaceEvent event = mock(EntityPlaceEvent.class); + when(event.getPlayer()).thenReturn(null); + + service.handleEntityPlace(event); } @Test @@ -307,6 +397,51 @@ void handleInventoryDrag_cancelsWhenBarrierProtectionTouchesLockedOrBarrierSlots verify(sharedService, never()).requestSharedInventorySync(any()); } + @Test + void handleInventoryDrag_defersSyncToNextTick_likeInventoryClick() { + // InventoryDragEvent fires before Bukkit distributes items into dragged slots, + // so the sync must be deferred to the next tick (runTask). Calling + // requestSharedInventorySync immediately would broadcast pre-drag state. + JavaPlugin plugin = mock(JavaPlugin.class); + ChallengeManager challengeManager = mock(ChallengeManager.class); + DegradingInventoryService degradingService = mock(DegradingInventoryService.class); + SharedInventorySyncService sharedService = mock(SharedInventorySyncService.class); + + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + + InventoryDragEvent event = mock(InventoryDragEvent.class); + when(event.getWhoClicked()).thenReturn(player); + when(event.getOldCursor()).thenReturn(new ItemStack(Material.STONE)); + when(event.getNewItems()).thenReturn(java.util.Map.of()); + when(event.getRawSlots()).thenReturn(java.util.Set.of(1, 2)); + when(degradingService.isBarrierProtectionActive(player)).thenReturn(false); + + InventoryMechanicsCoordinatorService service = new InventoryMechanicsCoordinatorService( + plugin, + challengeManager, + degradingService, + sharedService, + p -> true, + () -> true, + List::of, + p -> {}, + mock(DeepCoreLogger.class)); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + service.handleInventoryDrag(event); + } + + // Sync must not be called synchronously — Bukkit hasn't applied the drag yet + verify(sharedService, never()).requestSharedInventorySync(any()); + // A runTask (next-tick deferred) call must have been scheduled instead + verify(scheduler).runTask(eq(plugin), any(Runnable.class)); + } + @Test void handlePotentialWearableUse_supportsOffHandEquip_afterEquipmentChangeConfirmation() { JavaPlugin plugin = mock(JavaPlugin.class); @@ -351,7 +486,8 @@ void handlePotentialWearableUse_supportsOffHandEquip_afterEquipmentChangeConfirm service.handlePlayerArmorChanged(equipmentEvent); } - verify(sharedService).consumeWearableFromOtherParticipants(Material.DIAMOND_HELMET, playerId); + // Off-hand equip: sourceInventorySlot is hardcoded to 40 for the off-hand slot + verify(sharedService).consumeWearableFromOtherParticipants(Material.DIAMOND_HELMET, playerId, 40); verify(sharedService).capturePlayerWearableSnapshot(player); } @@ -639,6 +775,13 @@ void handleEntityPickupItem_logsAndSyncs_whenChallengeInactive() { }) .when(scheduler) .runTask(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }) + .when(scheduler) + .runTaskLater(eq(plugin), any(Runnable.class), eq(1L)); service.handleEntityPickupItem(event); } @@ -684,6 +827,13 @@ void handlePlayerPickupArrow_defersSharedSync() { }) .when(scheduler) .runTask(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }) + .when(scheduler) + .runTaskLater(eq(plugin), any(Runnable.class), eq(1L)); service.handlePlayerPickupArrow(event); } @@ -749,6 +899,13 @@ void passThroughHandlers_requestCapAndSharedSync_whenNotCancelled() { }) .when(scheduler) .runTask(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }) + .when(scheduler) + .runTaskLater(eq(plugin), any(Runnable.class), eq(1L)); service.handlePlayerDropItem(dropEvent); service.handlePlayerSwapHandItems(swapEvent); @@ -759,7 +916,60 @@ void passThroughHandlers_requestCapAndSharedSync_whenNotCancelled() { } verify(enforceCap, org.mockito.Mockito.times(6)).accept(player); - verify(sharedService, org.mockito.Mockito.times(6)).requestSharedInventorySync(player); + // drop, swap, held, damage call requestSharedInventorySync directly (4 calls). + // bucket empty and fill now use scheduleDeterministicSourceSync (1-tick defer), + // which routes to syncSharedInventoryFromSourceNow — not requestSharedInventorySync. + verify(sharedService, org.mockito.Mockito.times(4)).requestSharedInventorySync(player); + verify(sharedService, org.mockito.Mockito.times(2)).syncSharedInventoryFromSourceNow(player); + } + + @Test + void handlePlayerBucketEmptyAndFill_useDeferredDeterministicSync_notImmediateSync() { + // Bucket item transforms (empty→filled, filled→empty) happen after the event + // fires. Both handlers must defer by 1 tick via scheduleDeterministicSourceSync + // so Bukkit completes the transform before the inventory state is read. + JavaPlugin plugin = mock(JavaPlugin.class); + ChallengeManager challengeManager = mock(ChallengeManager.class); + SharedInventorySyncService sharedService = mock(SharedInventorySyncService.class); + + Player player = mock(Player.class); + PlayerBucketEmptyEvent emptyEvent = mock(PlayerBucketEmptyEvent.class); + PlayerBucketFillEvent fillEvent = mock(PlayerBucketFillEvent.class); + when(emptyEvent.getPlayer()).thenReturn(player); + when(fillEvent.getPlayer()).thenReturn(player); + when(challengeManager.isComponentEnabled(ChallengeComponent.DEGRADING_INVENTORY)) + .thenReturn(false); + + InventoryMechanicsCoordinatorService service = new InventoryMechanicsCoordinatorService( + plugin, + challengeManager, + mock(DegradingInventoryService.class), + sharedService, + p -> true, + () -> true, + List::of, + p -> {}, + mock(DeepCoreLogger.class)); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }) + .when(scheduler) + .runTaskLater(eq(plugin), any(Runnable.class), eq(1L)); + + service.handlePlayerBucketEmpty(emptyEvent); + service.handlePlayerBucketFill(fillEvent); + } + + // Must never call the immediate sync — transform hasn't happened yet at event time + verify(sharedService, never()).requestSharedInventorySync(any()); + // Must call the 1-tick deferred deterministic sync for each bucket event + verify(sharedService, org.mockito.Mockito.times(2)).syncSharedInventoryFromSourceNow(player); } @Test @@ -930,7 +1140,8 @@ void handlePlayerArmorChanged_consumesWhenMatchingPendingHotbarEquip() { service.handlePlayerArmorChanged(equipmentEvent); } - verify(sharedService).consumeWearableFromOtherParticipants(Material.DIAMOND_HELMET, playerId); + // Main-hand equip: sourceInventorySlot = getHeldItemSlot(), unstubbed mock returns 0 + verify(sharedService).consumeWearableFromOtherParticipants(Material.DIAMOND_HELMET, playerId, 0); verify(sharedService).capturePlayerWearableSnapshot(player); } @@ -1011,7 +1222,8 @@ void handlePotentialWearableUse_repeatedRightClick_dispatchesSingleConsumeOnConf service.handlePlayerArmorChanged(equipmentEvent); } - verify(sharedService).consumeWearableFromOtherParticipants(Material.IRON_CHESTPLATE, playerId); + // Main-hand equip: sourceInventorySlot = getHeldItemSlot(), unstubbed mock returns 0 + verify(sharedService).consumeWearableFromOtherParticipants(Material.IRON_CHESTPLATE, playerId, 0); verify(sharedService).capturePlayerWearableSnapshot(player); } diff --git a/src/test/java/dev/deepcore/challenge/inventory/SharedInventorySyncServiceTest.java b/src/test/java/dev/deepcore/challenge/inventory/SharedInventorySyncServiceTest.java index 41cf5ae..c769e81 100644 --- a/src/test/java/dev/deepcore/challenge/inventory/SharedInventorySyncServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/inventory/SharedInventorySyncServiceTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; 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.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; @@ -13,6 +14,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.HashMap; import java.util.List; @@ -80,7 +82,7 @@ void requestSharedInventorySync_ignoresWhenInactiveOrSharedInventoryDisabled() { } @Test - void requestSharedInventorySync_runsDeferredSync_andEnforcesSlotCap() { + void requestSharedInventorySync_syncsImmediately_andEnforcesSlotCap() { JavaPlugin plugin = mock(JavaPlugin.class); UUID sourceId = UUID.randomUUID(); UUID targetId = UUID.randomUUID(); @@ -103,7 +105,6 @@ void requestSharedInventorySync_runsDeferredSync_andEnforcesSlotCap() { when(sourceInventory.getStorageContents()).thenReturn(new ItemStack[] {sourceStack}); when(sourceInventory.getExtraContents()).thenReturn(new ItemStack[0]); when(sourceInventory.getArmorContents()).thenReturn(new ItemStack[4]); - when(targetInventory.getArmorContents()).thenReturn(new ItemStack[4]); @SuppressWarnings("unchecked") java.util.function.Consumer enforceCap = mock(java.util.function.Consumer.class); @@ -118,24 +119,15 @@ void requestSharedInventorySync_runsDeferredSync_andEnforcesSlotCap() { enforceCap, new HashMap<>()); - BukkitScheduler scheduler = mock(BukkitScheduler.class); - try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { - bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); - doAnswer(invocation -> { - Runnable task = invocation.getArgument(1); - task.run(); - return null; - }) - .when(scheduler) - .runTask(eq(plugin), any(Runnable.class)); - - service.requestSharedInventorySync(source); - } + service.requestSharedInventorySync(source); verify(targetInventory).setStorageContents(any(ItemStack[].class)); verify(targetInventory).setExtraContents(any(ItemStack[].class)); + verify(sourceInventory, never()).setStorageContents(any(ItemStack[].class)); + verify(sourceInventory, never()).setExtraContents(any(ItemStack[].class)); verify(target).updateInventory(); - verify(enforceCap).accept(source); + verify(source, never()).updateInventory(); + verify(enforceCap, never()).accept(source); verify(enforceCap).accept(target); } @@ -188,7 +180,7 @@ void snapshotAndDetectWearables_updatesSnapshotAndConsumesNewWearables() { assertEquals(0, snapshots.get(sourceId).size()); when(sourceInventory.getArmorContents()).thenReturn(new ItemStack[] {helmet, boots, null, null}); - service.detectNewlyEquippedWearables(source); + service.detectNewlyEquippedWearables(source, -1); verify(otherInventory).setItem(0, null); verify(otherInventory).setItemInOffHand(null); @@ -245,30 +237,53 @@ void consumeItemAndWearable_applyToOtherParticipantsOnly() { } @Test - void requestSharedInventorySync_onlyQueuesOnceUntilDeferredTaskRuns() { + void requestSharedInventorySync_defersOnlyWhileAlreadySyncing_thenDrains() throws Exception { JavaPlugin plugin = mock(JavaPlugin.class); Player source = mock(Player.class); + Player target = mock(Player.class); when(source.getUniqueId()).thenReturn(UUID.randomUUID()); + when(target.getUniqueId()).thenReturn(UUID.randomUUID()); + when(source.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(target.getGameMode()).thenReturn(GameMode.SURVIVAL); + + PlayerInventory sourceInventory = mock(PlayerInventory.class); + PlayerInventory targetInventory = mock(PlayerInventory.class); + when(source.getInventory()).thenReturn(sourceInventory); + when(target.getInventory()).thenReturn(targetInventory); + when(sourceInventory.getStorageContents()).thenReturn(new ItemStack[0]); + when(sourceInventory.getExtraContents()).thenReturn(new ItemStack[0]); + when(targetInventory.getStorageContents()).thenReturn(new ItemStack[0]); + when(targetInventory.getExtraContents()).thenReturn(new ItemStack[0]); + when(sourceInventory.getArmorContents()).thenReturn(new ItemStack[4]); + when(targetInventory.getArmorContents()).thenReturn(new ItemStack[4]); SharedInventorySyncService service = new SharedInventorySyncService( plugin, player -> true, () -> true, () -> true, - () -> List.of(source), - () -> false, + () -> List.of(source, target), + () -> true, player -> {}, new HashMap<>()); BukkitScheduler scheduler = mock(BukkitScheduler.class); try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); - - service.requestSharedInventorySync(source); + setBooleanField(service, "syncingInventory", true); service.requestSharedInventorySync(source); + + verify(targetInventory, never()).setStorageContents(any(ItemStack[].class)); + verify(targetInventory, never()).setExtraContents(any(ItemStack[].class)); + + setBooleanField(service, "syncingInventory", false); + ArgumentCaptor drainCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), drainCaptor.capture()); + drainCaptor.getValue().run(); } - verify(scheduler, org.mockito.Mockito.times(1)).runTask(eq(plugin), any(Runnable.class)); + verify(sourceInventory, never()).setStorageContents(any(ItemStack[].class)); + verify(targetInventory).setStorageContents(any(ItemStack[].class)); } @Test @@ -323,8 +338,8 @@ void requestWearableEquipSync_respectsEligibilityGuards() { try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); - inactive.requestWearableEquipSync(source); - active.requestWearableEquipSync(source); + inactive.requestWearableEquipSync(source, -1); + active.requestWearableEquipSync(source, -1); } verify(scheduler, org.mockito.Mockito.times(1)).runTask(eq(plugin), any(Runnable.class)); @@ -373,7 +388,6 @@ void requestSharedInventorySync_keepsSpareWearableWhenSourceHasOneEquipped() { when(sourceInventory.getStorageContents()).thenReturn(new ItemStack[] {spareChestplate}); when(sourceInventory.getExtraContents()).thenReturn(new ItemStack[0]); when(sourceInventory.getArmorContents()).thenReturn(new ItemStack[] {null, equippedChestplate, null, null}); - when(targetInventory.getArmorContents()).thenReturn(new ItemStack[4]); SharedInventorySyncService service = new SharedInventorySyncService( plugin, @@ -408,6 +422,104 @@ void requestSharedInventorySync_keepsSpareWearableWhenSourceHasOneEquipped() { assertEquals(1, syncedStorage[0].getAmount()); } + @Test + void requestSharedInventorySync_propagatesUnequippedArmorToOthersEvenWhenTheyHaveSamePieceWorn() { + // When player A un-equips armor it genuinely returns to the shared pool. + // Player B seeing that piece in their shared storage is correct — A gave it up + // and it is now available again — even if B still has their own copy equipped. + // The sync must distribute the source storage as-is without subtracting what + // targets currently have in their armor slots. + JavaPlugin plugin = mock(JavaPlugin.class); + Player source = mock(Player.class); + Player target = mock(Player.class); + + when(source.getUniqueId()).thenReturn(UUID.randomUUID()); + when(target.getUniqueId()).thenReturn(UUID.randomUUID()); + when(source.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(target.getGameMode()).thenReturn(GameMode.SURVIVAL); + + PlayerInventory sourceInventory = mock(PlayerInventory.class); + PlayerInventory targetInventory = mock(PlayerInventory.class); + when(source.getInventory()).thenReturn(sourceInventory); + when(target.getInventory()).thenReturn(targetInventory); + + // Source un-equipped their iron chestplate; it is now in storage + ItemStack chestplate = new ItemStack(Material.IRON_CHESTPLATE, 1); + when(sourceInventory.getStorageContents()).thenReturn(new ItemStack[] {chestplate}); + when(sourceInventory.getExtraContents()).thenReturn(new ItemStack[0]); + when(sourceInventory.getArmorContents()).thenReturn(new ItemStack[4]); + + SharedInventorySyncService service = new SharedInventorySyncService( + plugin, + player -> true, + () -> true, + () -> true, + () -> List.of(source, target), + () -> false, + player -> {}, + new HashMap<>()); + + service.requestSharedInventorySync(source); + + ArgumentCaptor storageCaptor = ArgumentCaptor.forClass(ItemStack[].class); + verify(targetInventory).setStorageContents(storageCaptor.capture()); + ItemStack[] synced = storageCaptor.getValue(); + // Un-equipped item must reach the target's storage unconditionally + assertNotNull(synced[0]); + assertEquals(Material.IRON_CHESTPLATE, synced[0].getType()); + } + + @Test + void requestSharedInventorySync_preservesCursorItemWhileSyncingStorage() { + // updateInventory() sends a full resync packet that includes the cursor slot. + // Without restoring the cursor into the InventoryView first, the packet clears + // it and the player loses their held item. The sync must snapshot the cursor, + // apply storage, restore the cursor, then call updateInventory() — so the player + // stays in sync with other participants AND keeps their held item. + JavaPlugin plugin = mock(JavaPlugin.class); + Player source = mock(Player.class); + Player target = mock(Player.class); + + when(source.getUniqueId()).thenReturn(UUID.randomUUID()); + when(target.getUniqueId()).thenReturn(UUID.randomUUID()); + when(source.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(target.getGameMode()).thenReturn(GameMode.SURVIVAL); + + PlayerInventory sourceInventory = mock(PlayerInventory.class); + PlayerInventory targetInventory = mock(PlayerInventory.class); + when(source.getInventory()).thenReturn(sourceInventory); + when(target.getInventory()).thenReturn(targetInventory); + + when(sourceInventory.getStorageContents()).thenReturn(new ItemStack[] {new ItemStack(Material.STONE)}); + when(sourceInventory.getExtraContents()).thenReturn(new ItemStack[0]); + when(sourceInventory.getArmorContents()).thenReturn(new ItemStack[4]); + when(targetInventory.getArmorContents()).thenReturn(new ItemStack[4]); + + // Use a real ItemStack so clone() works correctly + ItemStack cursorItem = new ItemStack(Material.DIAMOND, 1); + InventoryView openView = mock(InventoryView.class); + when(openView.getCursor()).thenReturn(cursorItem); + when(target.getOpenInventory()).thenReturn(openView); + + SharedInventorySyncService service = new SharedInventorySyncService( + plugin, + player -> true, + () -> true, + () -> true, + () -> List.of(source, target), + () -> false, + player -> {}, + new HashMap<>()); + + service.requestSharedInventorySync(source); + + // Storage must still be synced — the player must not fall behind + verify(targetInventory).setStorageContents(any()); + // Cursor must be restored before updateInventory() so the outgoing packet keeps it + verify(openView).setCursor(argThat(item -> item != null && item.getType() == Material.DIAMOND)); + verify(target).updateInventory(); + } + @Test void wearableSnapshotRemovalAndClear_mutateBackingMap() { JavaPlugin plugin = mock(JavaPlugin.class); @@ -453,8 +565,8 @@ void detectNewlyEquippedWearables_noopsWhenChallengeInactiveOrSharedDisabled() { p -> {}, new HashMap<>()); - inactive.detectNewlyEquippedWearables(player); - disabled.detectNewlyEquippedWearables(player); + inactive.detectNewlyEquippedWearables(player, -1); + disabled.detectNewlyEquippedWearables(player, -1); } @Test @@ -659,6 +771,84 @@ void consumeWearableFromOtherParticipants_removesFromCursorWhenHeld() { verify(other).updateInventory(); } + @Test + void consumeWearableFromOtherParticipants_neverStripsEquippedArmorFromOtherPlayers() { + JavaPlugin plugin = mock(JavaPlugin.class); + UUID sourceId = UUID.randomUUID(); + + Player source = mock(Player.class); + Player other = mock(Player.class); + when(source.getUniqueId()).thenReturn(sourceId); + when(other.getUniqueId()).thenReturn(UUID.randomUUID()); + when(source.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(other.getGameMode()).thenReturn(GameMode.SURVIVAL); + + PlayerInventory otherInventory = mock(PlayerInventory.class); + when(other.getInventory()).thenReturn(otherInventory); + when(source.getInventory()).thenReturn(mock(PlayerInventory.class)); + when(otherInventory.getSize()).thenReturn(41); + when(otherInventory.getItemInOffHand()).thenReturn(null); + // No item in any storage slot + when(otherInventory.getItem(any(Integer.class))).thenReturn(null); + // But other player has diamond helmet equipped + ItemStack equippedHelmet = mock(ItemStack.class); + when(equippedHelmet.getType()).thenReturn(Material.DIAMOND_HELMET); + when(equippedHelmet.getAmount()).thenReturn(1); + when(otherInventory.getHelmet()).thenReturn(equippedHelmet); + + SharedInventorySyncService service = new SharedInventorySyncService( + plugin, + player -> true, + () -> true, + () -> true, + () -> List.of(source, other), + () -> false, + p -> {}, + new HashMap<>()); + + service.consumeWearableFromOtherParticipants(Material.DIAMOND_HELMET, sourceId); + + verify(otherInventory, never()).setHelmet(null); + verify(other, never()).updateInventory(); + } + + @Test + void consumeWearableFromOtherParticipants_removesFromExactSourceSlotWhenKnown() { + JavaPlugin plugin = mock(JavaPlugin.class); + UUID sourceId = UUID.randomUUID(); + + Player source = mock(Player.class); + Player other = mock(Player.class); + when(source.getUniqueId()).thenReturn(sourceId); + when(other.getUniqueId()).thenReturn(UUID.randomUUID()); + when(source.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(other.getGameMode()).thenReturn(GameMode.SURVIVAL); + + PlayerInventory otherInventory = mock(PlayerInventory.class); + when(other.getInventory()).thenReturn(otherInventory); + when(source.getInventory()).thenReturn(mock(PlayerInventory.class)); + + ItemStack boots = mock(ItemStack.class); + when(boots.getType()).thenReturn(Material.IRON_BOOTS); + when(boots.getAmount()).thenReturn(1); + when(otherInventory.getItem(5)).thenReturn(boots); + + SharedInventorySyncService service = new SharedInventorySyncService( + plugin, + player -> true, + () -> true, + () -> true, + () -> List.of(source, other), + () -> false, + p -> {}, + new HashMap<>()); + + service.consumeWearableFromOtherParticipants(Material.IRON_BOOTS, sourceId, 5); + + verify(otherInventory).setItem(5, null); + verify(other).updateInventory(); + } + private static int invokeRemoveMaterialFromItemArray( SharedInventorySyncService service, ItemStack[] contents, Material material, int amount) throws Exception { Method method = SharedInventorySyncService.class.getDeclaredMethod( @@ -666,4 +856,10 @@ private static int invokeRemoveMaterialFromItemArray( method.setAccessible(true); return (int) method.invoke(service, contents, material, amount); } + + private static void setBooleanField(Object target, String fieldName, boolean value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.setBoolean(target, value); + } } diff --git a/src/test/java/dev/deepcore/challenge/preview/PreviewOrchestratorServiceBehaviorTest.java b/src/test/java/dev/deepcore/challenge/preview/PreviewOrchestratorServiceBehaviorTest.java index e445676..3a810a4 100644 --- a/src/test/java/dev/deepcore/challenge/preview/PreviewOrchestratorServiceBehaviorTest.java +++ b/src/test/java/dev/deepcore/challenge/preview/PreviewOrchestratorServiceBehaviorTest.java @@ -297,6 +297,7 @@ void playPreviewDestroyAnimationThenReset_returnsWhenManagerMissingOrAlreadyDest @Test void refreshLobbyPreview_schedulesWorkWhenNotDestroying() { JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.isEnabled()).thenReturn(true); PreviewOrchestratorService service = newService( plugin, mock(ChallengeConfigView.class), @@ -500,6 +501,7 @@ void playPreviewDestroyAnimationThenReset_withActiveEntries_startsDestroyTask() @Test void refreshLobbyPreview_runningPhase_clearsTrackedPreviewEntities() throws Exception { JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.isEnabled()).thenReturn(true); PreviewEntityService entityService = mock(PreviewEntityService.class); PreviewOrchestratorService service = newService( plugin, @@ -540,6 +542,7 @@ void refreshLobbyPreview_runningPhase_clearsTrackedPreviewEntities() throws Exce @Test void refreshLobbyPreview_disabledPreview_clearsTrackedPreviewEntities() throws Exception { JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.isEnabled()).thenReturn(true); ChallengeConfigView config = mock(ChallengeConfigView.class); when(config.previewEnabled()).thenReturn(false); @@ -582,6 +585,7 @@ void refreshLobbyPreview_disabledPreview_clearsTrackedPreviewEntities() throws E @Test void refreshLobbyPreview_missingRunWorldOrAnchor_clearsEntities() throws Exception { JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.isEnabled()).thenReturn(true); ChallengeConfigView config = mock(ChallengeConfigView.class); when(config.previewEnabled()).thenReturn(true); diff --git a/src/test/java/dev/deepcore/challenge/records/RunRecordTest.java b/src/test/java/dev/deepcore/challenge/records/RunRecordTest.java new file mode 100644 index 0000000..cddf075 --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/records/RunRecordTest.java @@ -0,0 +1,75 @@ +package dev.deepcore.challenge.records; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class RunRecordTest { + + @Test + void gettersAndParticipants_areExposed() { + RunRecord record = new RunRecord(1000L, 2000L, 300L, 400L, 500L, 600L, 700L, "Alice, Bob , Charlie", "", ""); + + assertEquals(1000L, record.getTimestamp()); + assertEquals(2000L, record.getOverallTimeMs()); + assertEquals(300L, record.getOverworldToNetherMs()); + assertEquals(400L, record.getNetherToBlazeRodsMs()); + assertEquals(500L, record.getBlazeRodsToEndMs()); + assertEquals(600L, record.getNetherToEndMs()); + assertEquals(700L, record.getEndToDragonMs()); + assertEquals("Alice, Bob , Charlie", record.getParticipantsCsv()); + assertEquals(List.of("Alice", "Bob", "Charlie"), record.getParticipants()); + } + + @Test + void getParticipants_handlesBlankAndEmptyEntries() { + RunRecord blank = new RunRecord(0L, 0L, 0L, 0L, 0L, 0L, 0L, " ", "", ""); + RunRecord sparse = new RunRecord(0L, 0L, 0L, 0L, 0L, 0L, 0L, "A,, ,B", "", ""); + + assertTrue(blank.getParticipants().isEmpty()); + assertEquals(List.of("A", "B"), sparse.getParticipants()); + } + + @Test + void toString_containsCoreFields() { + RunRecord record = new RunRecord(1L, 2L, 3L, 4L, 5L, 6L, 7L, "P1,P2", "", ""); + String text = record.toString(); + + assertTrue(text.contains("overallTimeMs=2")); + assertTrue(text.contains("participantsCsv='P1,P2'")); + } + + @Test + void getComponentKeys_parsesStoredCsv() { + RunRecord record = new RunRecord(0L, 0L, 0L, 0L, 0L, 0L, 0L, "", "shared_inventory, hardcore", ""); + + assertEquals(List.of("shared_inventory", "hardcore"), record.getComponentKeys()); + assertEquals("shared_inventory, hardcore", record.getComponentsCsv()); + } + + @Test + void getComponentKeys_returnsEmptyListWhenBlank() { + RunRecord blank = new RunRecord(0L, 0L, 0L, 0L, 0L, 0L, 0L, "", "", ""); + RunRecord nullComponents = new RunRecord(0L, 0L, 0L, 0L, 0L, 0L, 0L, "", null, ""); + + assertTrue(blank.getComponentKeys().isEmpty()); + assertTrue(nullComponents.getComponentKeys().isEmpty()); + } + + @Test + void toString_containsComponentsField() { + RunRecord record = new RunRecord(1L, 2L, 3L, 4L, 5L, 6L, 7L, "P1", "hardcore", "hard"); + assertTrue(record.toString().contains("componentsCsv='hardcore'")); + } + + @Test + void getDifficulty_returnsStoredValue_andNullBecomesEmpty() { + RunRecord withDifficulty = new RunRecord(0L, 0L, 0L, 0L, 0L, 0L, 0L, "", "", "normal"); + RunRecord nullDifficulty = new RunRecord(0L, 0L, 0L, 0L, 0L, 0L, 0L, "", "", null); + + assertEquals("normal", withDifficulty.getDifficulty()); + assertEquals("", nullDifficulty.getDifficulty()); + } +} diff --git a/src/test/java/dev/deepcore/records/RunRecordsServiceTest.java b/src/test/java/dev/deepcore/challenge/records/RunRecordsServiceTest.java similarity index 77% rename from src/test/java/dev/deepcore/records/RunRecordsServiceTest.java rename to src/test/java/dev/deepcore/challenge/records/RunRecordsServiceTest.java index 976bd6b..a4eeba7 100644 --- a/src/test/java/dev/deepcore/records/RunRecordsServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/records/RunRecordsServiceTest.java @@ -1,4 +1,4 @@ -package dev.deepcore.records; +package dev.deepcore.challenge.records; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -13,6 +13,7 @@ import java.io.File; import java.lang.reflect.Field; import java.sql.SQLException; +import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; @@ -27,12 +28,18 @@ void initialize_successfullyOpensConnectionAndCreatesSchema() throws Exception { File dataFolder = new File("."); when(plugin.getDataFolder()).thenReturn(dataFolder); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); java.sql.Connection connection = mock(java.sql.Connection.class); java.sql.Statement createStmt = mock(java.sql.Statement.class); java.sql.Statement pragmaStmt = mock(java.sql.Statement.class); + java.sql.Statement pragma2Stmt = mock(java.sql.Statement.class); + java.sql.Statement pragma3Stmt = mock(java.sql.Statement.class); + java.sql.Statement alterStmt = mock(java.sql.Statement.class); + java.sql.Statement alter2Stmt = mock(java.sql.Statement.class); java.sql.ResultSet pragmaRs = mock(java.sql.ResultSet.class); + java.sql.ResultSet pragma2Rs = mock(java.sql.ResultSet.class); + java.sql.ResultSet pragma3Rs = mock(java.sql.ResultSet.class); try (MockedStatic driverManager = org.mockito.Mockito.mockStatic(java.sql.DriverManager.class)) { @@ -40,9 +47,22 @@ void initialize_successfullyOpensConnectionAndCreatesSchema() throws Exception { driverManager .when(() -> java.sql.DriverManager.getConnection(expectedDbPath)) .thenReturn(connection); - when(connection.createStatement()).thenReturn(createStmt).thenReturn(pragmaStmt); + when(connection.createStatement()) + .thenReturn(createStmt) + .thenReturn(pragmaStmt) + .thenReturn(pragma2Stmt) + .thenReturn(alterStmt) + .thenReturn(pragma3Stmt) + .thenReturn(alter2Stmt); when(pragmaStmt.executeQuery(anyString())).thenReturn(pragmaRs); + when(pragma2Stmt.executeQuery(anyString())).thenReturn(pragma2Rs); + when(pragma3Stmt.executeQuery(anyString())).thenReturn(pragma3Rs); + // No player_uuid column. when(pragmaRs.next()).thenReturn(false); + // No components column — triggers ALTER TABLE add. + when(pragma2Rs.next()).thenReturn(false); + // No difficulty column — triggers ALTER TABLE add. + when(pragma3Rs.next()).thenReturn(false); service.initialize(); } @@ -57,7 +77,7 @@ void recordRun_getBestTimes_getAllRecords_andShutdown_useConnectionSuccessfully( when(plugin.getDeepCoreLogger()).thenReturn(log); when(plugin.getDataFolder()).thenReturn(new File(".")); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); java.sql.Connection connection = mock(java.sql.Connection.class); java.sql.PreparedStatement insertStatement = mock(java.sql.PreparedStatement.class); @@ -93,12 +113,15 @@ void recordRun_getBestTimes_getAllRecords_andShutdown_useConnectionSuccessfully( when(allRecordsRs.getLong("nether_to_end_ms")).thenReturn(11000L, 12000L); when(allRecordsRs.getLong("end_to_dragon_ms")).thenReturn(13000L, 14000L); when(allRecordsRs.getString("participants")).thenReturn("A, B", "C"); + when(allRecordsRs.getString("components")).thenReturn("shared_inventory", ""); + when(allRecordsRs.getString("difficulty")).thenReturn("normal", ""); setConnection(service, connection); RunRecord created = service.recordRun( - 40000L, 5000L, 7000L, 9000L, 11000L, 13000L, java.util.Arrays.asList("A", " ", null, "B")); + 40000L, 5000L, 7000L, 9000L, 11000L, 13000L, Arrays.asList("A", " ", null, "B"), List.of(), ""); assertEquals("A, B", created.getParticipantsCsv()); + assertEquals("", created.getComponentsCsv()); assertEquals(1500L, service.getBestOverallTime()); assertEquals(600L, service.getBestSectionTime("nether_to_end")); @@ -107,12 +130,42 @@ void recordRun_getBestTimes_getAllRecords_andShutdown_useConnectionSuccessfully( List all = service.getAllRecords(); assertEquals(2, all.size()); assertEquals("A, B", all.get(0).getParticipantsCsv()); + assertEquals("shared_inventory", all.get(0).getComponentsCsv()); + assertEquals("", all.get(1).getComponentsCsv()); when(connection.isClosed()).thenReturn(false); service.shutdown(); verify(connection).close(); } + @Test + void recordRun_storesEnabledComponentsCsv() throws Exception { + DeepCorePlugin plugin = mock(DeepCorePlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + when(plugin.getDeepCoreLogger()).thenReturn(log); + when(plugin.getDataFolder()).thenReturn(new File(".")); + + RunRecordsService service = new RunRecordsService(plugin, "records.db"); + java.sql.Connection connection = mock(java.sql.Connection.class); + java.sql.PreparedStatement insertStatement = mock(java.sql.PreparedStatement.class); + when(connection.prepareStatement(anyString())).thenReturn(insertStatement); + setConnection(service, connection); + + RunRecord record = service.recordRun( + 1000L, + 100L, + 200L, + 300L, + 400L, + 500L, + List.of("Alice"), + List.of("shared_inventory", "hardcore"), + "normal"); + + assertEquals("shared_inventory, hardcore", record.getComponentsCsv()); + assertEquals(List.of("shared_inventory", "hardcore"), record.getComponentKeys()); + } + @Test void getBestTimes_andRecords_returnFallbackOnSqlExceptions() throws Exception { DeepCorePlugin plugin = mock(DeepCorePlugin.class); @@ -120,7 +173,7 @@ void getBestTimes_andRecords_returnFallbackOnSqlExceptions() throws Exception { when(plugin.getDeepCoreLogger()).thenReturn(log); when(plugin.getDataFolder()).thenReturn(new File(".")); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); java.sql.Connection connection = mock(java.sql.Connection.class); setConnection(service, connection); @@ -142,7 +195,7 @@ void getBestOverallTime_returnsMinusOneWhenQueryReturnsZero() throws Exception { when(plugin.getDeepCoreLogger()).thenReturn(log); when(plugin.getDataFolder()).thenReturn(new File(".")); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); java.sql.Connection connection = mock(java.sql.Connection.class); java.sql.Statement statement = mock(java.sql.Statement.class); java.sql.ResultSet rs = mock(java.sql.ResultSet.class); @@ -163,7 +216,7 @@ void getBestSectionTime_returnsMinusOneWhenQueryReturnsZero() throws Exception { when(plugin.getDeepCoreLogger()).thenReturn(log); when(plugin.getDataFolder()).thenReturn(new File(".")); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); java.sql.Connection connection = mock(java.sql.Connection.class); java.sql.Statement statement = mock(java.sql.Statement.class); java.sql.ResultSet rs = mock(java.sql.ResultSet.class); @@ -184,7 +237,7 @@ void getBestTimes_returnMinusOneWhenResultSetHasNoRows() throws Exception { when(plugin.getDeepCoreLogger()).thenReturn(log); when(plugin.getDataFolder()).thenReturn(new File(".")); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); java.sql.Connection connection = mock(java.sql.Connection.class); java.sql.Statement stmt1 = mock(java.sql.Statement.class); java.sql.Statement stmt2 = mock(java.sql.Statement.class); @@ -210,14 +263,14 @@ void recordRun_logsFailureWhenInsertThrows_andEncodesParticipants() throws Excep when(plugin.getDeepCoreLogger()).thenReturn(log); when(plugin.getDataFolder()).thenReturn(new File(".")); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); java.sql.Connection connection = mock(java.sql.Connection.class); when(connection.prepareStatement(anyString())).thenThrow(new SQLException("insert failed")); setConnection(service, connection); RunRecord record = - service.recordRun(100L, 10L, 20L, 30L, 40L, 50L, java.util.Arrays.asList("A", " ", null, "B")); + service.recordRun(100L, 10L, 20L, 30L, 40L, 50L, Arrays.asList("A", " ", null, "B"), List.of(), ""); assertEquals("A, B", record.getParticipantsCsv()); verify(log) @@ -233,7 +286,7 @@ void shutdown_isNoopWhenConnectionNullOrAlreadyClosed() throws Exception { when(plugin.getDeepCoreLogger()).thenReturn(log); when(plugin.getDataFolder()).thenReturn(new File(".")); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); service.shutdown(); @@ -253,7 +306,7 @@ void shutdown_logsWhenCloseFails() throws Exception { when(plugin.getDeepCoreLogger()).thenReturn(log); when(plugin.getDataFolder()).thenReturn(new File(".")); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); java.sql.Connection connection = mock(java.sql.Connection.class); when(connection.isClosed()).thenReturn(false); org.mockito.Mockito.doThrow(new java.sql.SQLException("close boom")) @@ -276,22 +329,36 @@ void initialize_recreatesLegacySchemaWhenPlayerUuidColumnExists() throws Excepti when(plugin.getDeepCoreLogger()).thenReturn(log); when(plugin.getDataFolder()).thenReturn(new File(".")); - RunRecordsService service = new RunRecordsService(plugin); + RunRecordsService service = new RunRecordsService(plugin, "records.db"); java.sql.Connection connection = mock(java.sql.Connection.class); java.sql.Statement createStmt = mock(java.sql.Statement.class); java.sql.Statement pragmaStmt = mock(java.sql.Statement.class); java.sql.Statement dropStmt = mock(java.sql.Statement.class); java.sql.Statement recreateStmt = mock(java.sql.Statement.class); + java.sql.Statement pragma2Stmt = mock(java.sql.Statement.class); + java.sql.Statement pragma3Stmt = mock(java.sql.Statement.class); java.sql.ResultSet pragmaRs = mock(java.sql.ResultSet.class); + java.sql.ResultSet pragma2Rs = mock(java.sql.ResultSet.class); + java.sql.ResultSet pragma3Rs = mock(java.sql.ResultSet.class); when(connection.createStatement()) .thenReturn(createStmt) .thenReturn(pragmaStmt) .thenReturn(dropStmt) - .thenReturn(recreateStmt); + .thenReturn(recreateStmt) + .thenReturn(pragma2Stmt) + .thenReturn(pragma3Stmt); when(pragmaStmt.executeQuery(anyString())).thenReturn(pragmaRs); when(pragmaRs.next()).thenReturn(true, false); when(pragmaRs.getString("name")).thenReturn("player_uuid"); + // After recreate, components column is already present in fresh schema. + when(pragma2Stmt.executeQuery(anyString())).thenReturn(pragma2Rs); + when(pragma2Rs.next()).thenReturn(true, false); + when(pragma2Rs.getString("name")).thenReturn("components"); + // After recreate, difficulty column is also already present in fresh schema. + when(pragma3Stmt.executeQuery(anyString())).thenReturn(pragma3Rs); + when(pragma3Rs.next()).thenReturn(true, false); + when(pragma3Rs.getString("name")).thenReturn("difficulty"); setConnection(service, connection); diff --git a/src/test/java/dev/deepcore/records/package-info.java b/src/test/java/dev/deepcore/challenge/records/package-info.java similarity index 66% rename from src/test/java/dev/deepcore/records/package-info.java rename to src/test/java/dev/deepcore/challenge/records/package-info.java index 0dbaa54..7dd188f 100644 --- a/src/test/java/dev/deepcore/records/package-info.java +++ b/src/test/java/dev/deepcore/challenge/records/package-info.java @@ -1,4 +1,4 @@ /** * Unit tests for run record parsing and persistence model semantics. */ -package dev.deepcore.records; +package dev.deepcore.challenge.records; diff --git a/src/test/java/dev/deepcore/challenge/session/PlayerLobbyStateServiceTest.java b/src/test/java/dev/deepcore/challenge/session/PlayerLobbyStateServiceTest.java index 62fbdf4..b3ce93e 100644 --- a/src/test/java/dev/deepcore/challenge/session/PlayerLobbyStateServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/PlayerLobbyStateServiceTest.java @@ -33,20 +33,41 @@ void enforceSurvivalOnWorldEntry_keepsEliminatedHardcorePlayerUntouchedDuringRun } @Test - void enforceSurvivalOnWorldEntry_setsSurvivalForNonEliminatedOrNonHardcore() { + void enforceSurvivalOnWorldEntry_setsSurvivalInRunWorldForNonEliminatedPlayer() { WorldClassificationService worldClassificationService = mock(WorldClassificationService.class); PrepBookService prepBookService = mock(PrepBookService.class); PlayerLobbyStateService service = new PlayerLobbyStateService(worldClassificationService, prepBookService); Player player = mock(Player.class); + World world = mock(World.class); when(player.getUniqueId()).thenReturn(UUID.randomUUID()); when(player.getGameMode()).thenReturn(GameMode.ADVENTURE); + when(player.getWorld()).thenReturn(world); + when(worldClassificationService.isLobbyOrLimboWorld(world)).thenReturn(false); service.enforceSurvivalOnWorldEntry(player, true, true, Set.of(UUID.randomUUID())); verify(player).setGameMode(GameMode.SURVIVAL); } + @Test + void enforceSurvivalOnWorldEntry_setsAdventureInLobbyOrLimboWorld() { + WorldClassificationService worldClassificationService = mock(WorldClassificationService.class); + PrepBookService prepBookService = mock(PrepBookService.class); + PlayerLobbyStateService service = new PlayerLobbyStateService(worldClassificationService, prepBookService); + + Player player = mock(Player.class); + World world = mock(World.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(player.getWorld()).thenReturn(world); + when(worldClassificationService.isLobbyOrLimboWorld(world)).thenReturn(true); + + service.enforceSurvivalOnWorldEntry(player, false, false, Set.of()); + + verify(player).setGameMode(GameMode.ADVENTURE); + } + @Test void applyLobbyInventoryLoadoutIfInLobbyWorld_resetsInventoryAndGivesPrepBook() { WorldClassificationService worldClassificationService = mock(WorldClassificationService.class); diff --git a/src/test/java/dev/deepcore/challenge/session/PrepGuiCoordinatorServiceTest.java b/src/test/java/dev/deepcore/challenge/session/PrepGuiCoordinatorServiceTest.java index ac64a6d..0245d28 100644 --- a/src/test/java/dev/deepcore/challenge/session/PrepGuiCoordinatorServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/PrepGuiCoordinatorServiceTest.java @@ -9,15 +9,16 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import dev.deepcore.challenge.ChallengeDifficulty; import dev.deepcore.challenge.ChallengeManager; -import dev.deepcore.challenge.PrepGuiPage; import dev.deepcore.challenge.preview.PreviewOrchestratorService; +import dev.deepcore.challenge.records.RunRecord; +import dev.deepcore.challenge.records.RunRecordsService; import dev.deepcore.challenge.ui.PrepBookService; +import dev.deepcore.challenge.ui.PrepGuiPage; import dev.deepcore.challenge.ui.PrepGuiRenderer; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; -import dev.deepcore.records.RunRecord; -import dev.deepcore.records.RunRecordsService; import java.lang.reflect.Field; import java.time.format.DateTimeFormatter; import java.util.List; @@ -69,7 +70,8 @@ void handlePrepBookUse_opensGuiInPrep_andWarnsOutsidePrep() { verify(event).setCancelled(true); verify(f.prepGuiRenderer).applyPrepGuiDecorations(inventory); - verify(f.prepGuiRenderer).populateCategoriesPage(inventory, false, 0, 1, false); + verify(f.prepGuiRenderer) + .populateCategoriesPage(inventory, false, 0, 1, false, ChallengeDifficulty.NORMAL, false); verify(player).openInventory(inventory); f.sessionState.setPhase(SessionState.Phase.COUNTDOWN); @@ -127,6 +129,7 @@ void handlePrepGuiClick_executesReadyAndResetFlows() { any(), any(), any(), + any(), any()); Inventory categoryInventory = mock(Inventory.class); @@ -225,7 +228,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(), any()); + .handleClick(eq(player), eq(99), any(), any(), any(), any(), any(), any(), any(), any(), any()); InventoryClickEvent resetClick = mock(InventoryClickEvent.class); InventoryView resetView = mock(InventoryView.class); @@ -255,6 +258,7 @@ void prepHandlers_coverGuardBranches_andPreviewDestroyingResetFlow() { any(), any(), any(), + any(), any()); f.service.handlePrepGuiClick(resetClick); @@ -416,10 +420,13 @@ private static final class Fixture { isDiscoPreviewBlockingChallengeStart, announceDiscoPreviewStartBlocked, startRun, + () -> false, + mock(Runnable.class), prepGuiTitle, DateTimeFormatter.ISO_LOCAL_DATE); Fixture() { + when(challengeManager.getDifficulty()).thenReturn(ChallengeDifficulty.NORMAL); when(recordsService.getAllRecords()).thenReturn(List.of(mock(RunRecord.class))); when(runUiFormattingService.formatSplitDuration(anyLong())).thenReturn("00:10"); when(prepGuiRenderer.populateRunHistoryPage(any(), any(Integer.class), any(), any(), any())) diff --git a/src/test/java/dev/deepcore/challenge/session/PrepGuiFlowServiceTest.java b/src/test/java/dev/deepcore/challenge/session/PrepGuiFlowServiceTest.java index eeae599..8c84e26 100644 --- a/src/test/java/dev/deepcore/challenge/session/PrepGuiFlowServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/PrepGuiFlowServiceTest.java @@ -9,10 +9,10 @@ import dev.deepcore.challenge.ChallengeComponent; import dev.deepcore.challenge.ChallengeManager; -import dev.deepcore.challenge.PrepGuiPage; +import dev.deepcore.challenge.records.RunRecord; +import dev.deepcore.challenge.records.RunRecordsService; +import dev.deepcore.challenge.ui.PrepGuiPage; import dev.deepcore.challenge.ui.PrepGuiRenderer; -import dev.deepcore.records.RunRecord; -import dev.deepcore.records.RunRecordsService; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -47,7 +47,7 @@ void categoriesNavigationAndReadyResetActions_areHandled() { assertTrue(service.handleClick( player, - 47, + 48, PrepGuiPage.CATEGORIES, history, readyToggle, @@ -55,12 +55,13 @@ void categoriesNavigationAndReadyResetActions_areHandled() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); verify(readyToggle).run(); assertTrue(service.handleClick( player, - 51, + 30, PrepGuiPage.CATEGORIES, history, readyToggle, @@ -68,12 +69,13 @@ void categoriesNavigationAndReadyResetActions_areHandled() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); verify(reset).run(); assertTrue(service.handleClick( player, - 53, + 32, PrepGuiPage.CATEGORIES, history, readyToggle, @@ -81,7 +83,8 @@ void categoriesNavigationAndReadyResetActions_areHandled() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); verify(trainingTeleport).run(); assertTrue(service.handleClick( @@ -94,7 +97,8 @@ void categoriesNavigationAndReadyResetActions_areHandled() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); verify(open).accept(PrepGuiPage.HEALTH); assertTrue(service.handleClick( @@ -107,7 +111,8 @@ void categoriesNavigationAndReadyResetActions_areHandled() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); assertEquals(0, history.get(id)); verify(open).accept(PrepGuiPage.RUN_HISTORY); @@ -121,7 +126,8 @@ void categoriesNavigationAndReadyResetActions_areHandled() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); verify(open).accept(PrepGuiPage.INVENTORY); assertTrue(service.handleClick( @@ -134,7 +140,8 @@ void categoriesNavigationAndReadyResetActions_areHandled() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); verify(close).run(); } @@ -166,7 +173,8 @@ void inventoryAndHealthToggleSlots_invokeSettingsAndRefresh() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); verify(settings).toggleComponent(ChallengeComponent.KEEP_INVENTORY); assertTrue(service.handleClick( @@ -179,7 +187,8 @@ void inventoryAndHealthToggleSlots_invokeSettingsAndRefresh() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); verify(settings).toggleComponent(ChallengeComponent.SHARED_INVENTORY); assertTrue(service.handleClick( @@ -192,26 +201,67 @@ void inventoryAndHealthToggleSlots_invokeSettingsAndRefresh() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); 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, trainingTeleport)); + player, + 20, + PrepGuiPage.HEALTH, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport, + mock(Runnable.class))); verify(settings).setHealthRefill(true); assertTrue(service.handleClick( - player, 22, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset, trainingTeleport)); + player, + 22, + PrepGuiPage.HEALTH, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport, + mock(Runnable.class))); verify(settings).toggleComponent(ChallengeComponent.SHARED_HEALTH); assertTrue(service.handleClick( - player, 24, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset, trainingTeleport)); + player, + 24, + PrepGuiPage.HEALTH, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport, + mock(Runnable.class))); verify(settings).setInitialHalfHeart(false); assertTrue(service.handleClick( - player, 31, PrepGuiPage.HEALTH, history, readyToggle, refresh, open, close, reset, trainingTeleport)); + player, + 31, + PrepGuiPage.HEALTH, + history, + readyToggle, + refresh, + open, + close, + reset, + trainingTeleport, + mock(Runnable.class))); verify(settings).toggleComponent(ChallengeComponent.HARDCORE); verify(refresh, org.mockito.Mockito.atLeastOnce()).run(); @@ -252,7 +302,8 @@ void runHistoryPaging_usesPrevAndNextGuards() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); assertEquals(0, history.get(id)); when(renderer.hasRunHistoryNextPage(0, 2)).thenReturn(true); @@ -266,7 +317,8 @@ void runHistoryPaging_usesPrevAndNextGuards() { open, close, reset, - trainingTeleport)); + trainingTeleport, + mock(Runnable.class))); assertEquals(1, history.get(id)); verify(open, org.mockito.Mockito.atLeastOnce()).accept(PrepGuiPage.RUN_HISTORY); @@ -287,6 +339,7 @@ void returnsFalseWhenNoSlotActionMatches() { page -> {}, mock(Runnable.class), mock(Runnable.class), + mock(Runnable.class), mock(Runnable.class)); assertFalse(handled); diff --git a/src/test/java/dev/deepcore/challenge/session/PrepSettingsServiceTest.java b/src/test/java/dev/deepcore/challenge/session/PrepSettingsServiceTest.java index 9c75e83..b97b165 100644 --- a/src/test/java/dev/deepcore/challenge/session/PrepSettingsServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/PrepSettingsServiceTest.java @@ -6,57 +6,50 @@ import dev.deepcore.challenge.ChallengeComponent; import dev.deepcore.challenge.ChallengeManager; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class PrepSettingsServiceTest { + private ChallengeManager challengeManager; + private Runnable syncWorldRules; + private Runnable applySharedVitalsIfEnabled; + private PrepSettingsService service; + + @BeforeEach + void setup() { + challengeManager = mock(ChallengeManager.class); + syncWorldRules = mock(Runnable.class); + applySharedVitalsIfEnabled = mock(Runnable.class); + service = new PrepSettingsService(challengeManager, syncWorldRules, applySharedVitalsIfEnabled); + } @Test - void toggleComponent_togglesAndAppliesDependentUpdates() { - ChallengeManager challengeManager = mock(ChallengeManager.class); - Runnable syncWorldRules = mock(Runnable.class); - Runnable applySharedVitals = mock(Runnable.class); - PrepSettingsService service = new PrepSettingsService(challengeManager, syncWorldRules, applySharedVitals); - - service.toggleComponent(ChallengeComponent.SHARED_HEALTH); - - verify(challengeManager).toggleComponent(ChallengeComponent.SHARED_HEALTH); + void toggleComponent_callsDependencies() { + service.toggleComponent(ChallengeComponent.HEALTH_REFILL); + verify(challengeManager).toggleComponent(ChallengeComponent.HEALTH_REFILL); verify(syncWorldRules).run(); - verify(applySharedVitals).run(); + verify(applySharedVitalsIfEnabled).run(); } @Test - void setHealthRefill_disablesInitialHalfHeartWhenEnabled() { - ChallengeManager challengeManager = mock(ChallengeManager.class); - Runnable syncWorldRules = mock(Runnable.class); - Runnable applySharedVitals = mock(Runnable.class); - PrepSettingsService service = new PrepSettingsService(challengeManager, syncWorldRules, applySharedVitals); - + void setHealthRefill_disablesHalfHeartWhenNecessary() { when(challengeManager.isComponentEnabled(ChallengeComponent.INITIAL_HALF_HEART)) .thenReturn(true); - service.setHealthRefill(true); - verify(challengeManager).setComponentEnabled(ChallengeComponent.HEALTH_REFILL, true); verify(challengeManager).setComponentEnabled(ChallengeComponent.INITIAL_HALF_HEART, false); verify(syncWorldRules).run(); - verify(applySharedVitals).run(); + verify(applySharedVitalsIfEnabled).run(); } @Test - void setInitialHalfHeart_disablesHealthRefillWhenEnabled() { - ChallengeManager challengeManager = mock(ChallengeManager.class); - Runnable syncWorldRules = mock(Runnable.class); - Runnable applySharedVitals = mock(Runnable.class); - PrepSettingsService service = new PrepSettingsService(challengeManager, syncWorldRules, applySharedVitals); - + void setInitialHalfHeart_disablesHealthRefillWhenNecessary() { when(challengeManager.isComponentEnabled(ChallengeComponent.HEALTH_REFILL)) .thenReturn(true); - service.setInitialHalfHeart(true); - verify(challengeManager).setComponentEnabled(ChallengeComponent.INITIAL_HALF_HEART, true); verify(challengeManager).setComponentEnabled(ChallengeComponent.HEALTH_REFILL, false); verify(syncWorldRules).run(); - verify(applySharedVitals).run(); + verify(applySharedVitalsIfEnabled).run(); } } diff --git a/src/test/java/dev/deepcore/challenge/session/RunCompletionServiceTest.java b/src/test/java/dev/deepcore/challenge/session/RunCompletionServiceTest.java index dd766e9..faa9fc8 100644 --- a/src/test/java/dev/deepcore/challenge/session/RunCompletionServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/RunCompletionServiceTest.java @@ -9,9 +9,10 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import dev.deepcore.challenge.ChallengeComponent; +import dev.deepcore.challenge.records.RunRecordsService; import dev.deepcore.challenge.world.WorldResetManager; import dev.deepcore.logging.DeepCoreLogger; -import dev.deepcore.records.RunRecordsService; import java.util.List; import java.util.Set; import java.util.UUID; @@ -41,6 +42,7 @@ void handleEntityDeath_ignoresWhenNotRunning() { Set.of(), () -> null, mock(Runnable.class), + List::of, log); EntityDeathEvent event = mock(EntityDeathEvent.class); @@ -68,6 +70,7 @@ void handleEntityDeath_ignoresWhenEntityIsNotDragon() { Set.of(), () -> null, mock(Runnable.class), + List::of, log); EntityDeathEvent event = mock(EntityDeathEvent.class); @@ -90,7 +93,7 @@ void handleEntityDeath_dragonKillStartsReturnCountdownAndFallbackCompletes() { Runnable fallback = mock(Runnable.class); @SuppressWarnings("unchecked") - Supplier recordsSupplier = () -> null; + Supplier recordsSupplier = () -> null; Supplier worldResetSupplier = () -> null; RunCompletionService service = newService( @@ -101,6 +104,7 @@ void handleEntityDeath_dragonKillStartsReturnCountdownAndFallbackCompletes() { Set.of(UUID.randomUUID()), worldResetSupplier, fallback, + List::of, log); final Runnable[] onComplete = {null}; @@ -148,6 +152,7 @@ void handleEntityDeath_recordsRunWhenRecordsServiceAndRunStartAreAvailable() { Set.of(), () -> null, mock(Runnable.class), + List::of, mock(DeepCoreLogger.class)); EntityDeathEvent event = mock(EntityDeathEvent.class); @@ -155,7 +160,59 @@ void handleEntityDeath_recordsRunWhenRecordsServiceAndRunStartAreAvailable() { service.handleEntityDeath(event); - verify(recordsService).recordRun(anyLong(), eq(100L), eq(200L), eq(300L), eq(400L), eq(500L), eq(List.of())); + verify(recordsService) + .recordRun( + anyLong(), + eq(100L), + eq(200L), + eq(300L), + eq(400L), + eq(500L), + eq(List.of()), + eq(List.of()), + eq("normal")); + } + + @Test + void handleEntityDeath_recordsRunWithEnabledComponentKeys() { + SessionState sessionState = new SessionState(); + sessionState.setPhase(SessionState.Phase.RUNNING); + sessionState.timing().beginRun(1_000L); + + RunProgressService runProgressService = mock(RunProgressService.class); + CompletionReturnService completionReturnService = mock(CompletionReturnService.class); + RunRecordsService recordsService = mock(RunRecordsService.class); + + when(runProgressService.calculateSectionDurations(anyLong(), anyLong(), anyLong())) + .thenReturn(new RunProgressService.SectionDurations(100L, 200L, 300L, 400L, 500L)); + + RunCompletionService service = newService( + sessionState, + runProgressService, + completionReturnService, + () -> recordsService, + Set.of(), + () -> null, + mock(Runnable.class), + () -> List.of(ChallengeComponent.SHARED_INVENTORY, ChallengeComponent.HARDCORE), + mock(DeepCoreLogger.class)); + + EntityDeathEvent event = mock(EntityDeathEvent.class); + when(event.getEntity()).thenReturn(mock(EnderDragon.class)); + + service.handleEntityDeath(event); + + verify(recordsService) + .recordRun( + anyLong(), + eq(100L), + eq(200L), + eq(300L), + eq(400L), + eq(500L), + eq(List.of()), + eq(List.of("shared_inventory", "hardcore")), + eq("normal")); } @Test @@ -175,6 +232,7 @@ void handleEntityDeath_skipsRecordWhenRunStartIsUnset() { Set.of(), () -> null, mock(Runnable.class), + List::of, mock(DeepCoreLogger.class)); EntityDeathEvent event = mock(EntityDeathEvent.class); @@ -183,7 +241,7 @@ void handleEntityDeath_skipsRecordWhenRunStartIsUnset() { service.handleEntityDeath(event); verify(recordsService, never()) - .recordRun(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), any()); + .recordRun(anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), anyLong(), any(), any(), any()); } @Test @@ -210,6 +268,7 @@ void handleEntityDeath_countdownPredicateReflectsPhaseAndDragonState() { Set.of(), () -> null, mock(Runnable.class), + List::of, mock(DeepCoreLogger.class)); EntityDeathEvent event = mock(EntityDeathEvent.class); @@ -227,10 +286,11 @@ private static RunCompletionService newService( SessionState sessionState, RunProgressService runProgressService, CompletionReturnService completionReturnService, - Supplier recordsServiceSupplier, + Supplier recordsServiceSupplier, Set participants, Supplier worldResetManagerSupplier, Runnable endChallengeAndReturnToPrep, + Supplier> enabledComponentsSupplier, DeepCoreLogger log) { return new RunCompletionService( sessionState, @@ -240,6 +300,8 @@ private static RunCompletionService newService( participants, worldResetManagerSupplier, endChallengeAndReturnToPrep, + enabledComponentsSupplier, + () -> "normal", log); } } diff --git a/src/test/java/dev/deepcore/challenge/session/RunHealthCoordinatorServiceTest.java b/src/test/java/dev/deepcore/challenge/session/RunHealthCoordinatorServiceTest.java index 7e00695..f8ff7ec 100644 --- a/src/test/java/dev/deepcore/challenge/session/RunHealthCoordinatorServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/RunHealthCoordinatorServiceTest.java @@ -94,12 +94,45 @@ void handleEntityRegainHealth_healthRefillDisabled_cancels() { EntityRegainHealthEvent event = mock(EntityRegainHealthEvent.class); when(event.getEntity()).thenReturn(player); + when(event.getRegainReason()).thenReturn(EntityRegainHealthEvent.RegainReason.SATIATED); service.handleEntityRegainHealth(event); verify(event).setCancelled(true); } + @Test + void handleEntityRegainHealth_healthRefillDisabled_allowsStatusEffectRegen() { + ChallengeManager challengeManager = mock(ChallengeManager.class); + SharedVitalsService sharedVitals = mock(SharedVitalsService.class); + Player player = mock(Player.class); + + when(challengeManager.isComponentEnabled(ChallengeComponent.INITIAL_HALF_HEART)) + .thenReturn(false); + when(challengeManager.isComponentEnabled(ChallengeComponent.HEALTH_REFILL)) + .thenReturn(false); + + RunHealthCoordinatorService service = newService( + challengeManager, + () -> sharedVitals, + () -> List.of(player), + p -> true, + () -> false, + p -> {}, + 1.0D, + 20.0D); + + EntityRegainHealthEvent event = mock(EntityRegainHealthEvent.class); + when(event.getEntity()).thenReturn(player); + when(event.getRegainReason()).thenReturn(EntityRegainHealthEvent.RegainReason.MAGIC_REGEN); + + service.handleEntityRegainHealth(event); + + // Status effect regen (e.g. Regeneration potion) must not be blocked when + // only natural (SATIATED) regen is disabled. + verify(event, never()).setCancelled(true); + } + @Test void handleEntityRegainHealth_sharedHealth_syncsTargetHealth() { ChallengeManager challengeManager = mock(ChallengeManager.class); diff --git a/src/test/java/dev/deepcore/challenge/session/RunProgressServiceTest.java b/src/test/java/dev/deepcore/challenge/session/RunProgressServiceTest.java index 0ebf578..5ebe95c 100644 --- a/src/test/java/dev/deepcore/challenge/session/RunProgressServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/RunProgressServiceTest.java @@ -163,4 +163,61 @@ void calculateSectionDurations_usesFallbackWhenMilestonesMissing() { assertEquals(777L, durations.netherToEndMs()); assertEquals(777L, durations.endToDragonMs()); } + + @Test + void restore_setsAllMilestoneStateFromSnapshot() { + RunProgressService service = new RunProgressService(); + + service.restore(true, 1_000L, true, 2_000L, true, 3_000L); + + assertTrue(service.hasReachedNether()); + assertEquals(1_000L, service.getNetherReachedMillis()); + assertTrue(service.hasReachedBlazeObjective()); + assertEquals(2_000L, service.getBlazeObjectiveReachedMillis()); + assertTrue(service.hasReachedEnd()); + assertEquals(3_000L, service.getEndReachedMillis()); + assertFalse(service.isDragonKilled()); + } + + @Test + void restore_withFalseFlags_keepsAllMilestonesUnset() { + RunProgressService service = new RunProgressService(); + service.markNetherReached(500L); + + service.restore(false, 0L, false, 0L, false, 0L); + + assertFalse(service.hasReachedNether()); + assertFalse(service.hasReachedBlazeObjective()); + assertFalse(service.hasReachedEnd()); + assertEquals(0L, service.getNetherReachedMillis()); + assertEquals(0L, service.getBlazeObjectiveReachedMillis()); + assertEquals(0L, service.getEndReachedMillis()); + } + + @Test + void reset_clearsAllMilestones() { + RunProgressService service = new RunProgressService(); + service.markNetherReached(1_000L); + service.maybeMarkBlazeObjectiveReached(true, 6, 2_000L); + service.markEndReached(3_000L); + service.markDragonKilled(4_000L); + + service.reset(); + + assertFalse(service.hasReachedNether()); + assertFalse(service.hasReachedBlazeObjective()); + assertFalse(service.hasReachedEnd()); + assertFalse(service.isDragonKilled()); + assertEquals(0L, service.getNetherReachedMillis()); + assertEquals(0L, service.getBlazeObjectiveReachedMillis()); + assertEquals(0L, service.getEndReachedMillis()); + } + + @Test + void maybeMarkBlazeObjectiveReached_requiresNetherFirst() { + RunProgressService service = new RunProgressService(); + + assertTrue(service.maybeMarkBlazeObjectiveReached(true, 6, 1_000L).isEmpty()); + assertFalse(service.hasReachedBlazeObjective()); + } } diff --git a/src/test/java/dev/deepcore/challenge/session/RunSaveVoteServiceTest.java b/src/test/java/dev/deepcore/challenge/session/RunSaveVoteServiceTest.java new file mode 100644 index 0000000..db13a94 --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/session/RunSaveVoteServiceTest.java @@ -0,0 +1,246 @@ +package dev.deepcore.challenge.session; + +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.anyLong; +import static org.mockito.ArgumentMatchers.eq; +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.logging.DeepCoreLogger; +import java.util.List; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class RunSaveVoteServiceTest { + + @Test + void castVote_emptyParticipants_returnsFalseAndSendsError() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + Player voter = mock(Player.class); + RunSaveVoteService service = new RunSaveVoteService(plugin, log, List::of); + + boolean result = service.castVote(voter, mock(Runnable.class)); + + assertFalse(result); + verify(log).sendError(voter, "No active run participants to vote with."); + } + + @Test + void castVote_nonParticipant_returnsFalseAndSendsError() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + Player participant = mock(Player.class); + when(participant.getUniqueId()).thenReturn(UUID.randomUUID()); + Player voter = mock(Player.class); + when(voter.getUniqueId()).thenReturn(UUID.randomUUID()); + + RunSaveVoteService service = new RunSaveVoteService(plugin, log, () -> List.of(participant)); + + boolean result = service.castVote(voter, mock(Runnable.class)); + + assertFalse(result); + verify(log).sendError(voter, "You are not a participant in the current run."); + } + + @Test + void castVote_duplicateVote_returnsFalseAndWarns() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + Player voter = mock(Player.class); + Player other = mock(Player.class); + when(voter.getUniqueId()).thenReturn(UUID.randomUUID()); + when(other.getUniqueId()).thenReturn(UUID.randomUUID()); + when(voter.getName()).thenReturn("Player1"); + + RunSaveVoteService service = new RunSaveVoteService(plugin, log, () -> List.of(voter, other)); + Runnable callback = mock(Runnable.class); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenReturn(mock(BukkitTask.class)); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.broadcastMessage(any())).thenAnswer(inv -> null); + service.castVote(voter, callback); + boolean result = service.castVote(voter, callback); + assertFalse(result); + } + + verify(log).sendWarn(voter, "You have already voted to save this run."); + } + + @Test + void castVote_unanimousVote_triggersCallbackAndClearsVotes() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + Player p1 = mock(Player.class); + Player p2 = mock(Player.class); + when(p1.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p2.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p1.getName()).thenReturn("P1"); + when(p2.getName()).thenReturn("P2"); + + RunSaveVoteService service = new RunSaveVoteService(plugin, log, () -> List.of(p1, p2)); + Runnable callback = mock(Runnable.class); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenReturn(mock(BukkitTask.class)); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.broadcastMessage(any())).thenAnswer(inv -> null); + + service.castVote(p1, callback); + boolean result = service.castVote(p2, callback); + + assertTrue(result); + } + + verify(callback).run(); + } + + @Test + void castVote_singleParticipant_triggersCallbackImmediately() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + Player voter = mock(Player.class); + when(voter.getUniqueId()).thenReturn(UUID.randomUUID()); + when(voter.getName()).thenReturn("Solo"); + + RunSaveVoteService service = new RunSaveVoteService(plugin, log, () -> List.of(voter)); + Runnable callback = mock(Runnable.class); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.broadcastMessage(any())).thenAnswer(inv -> null); + boolean result = service.castVote(voter, callback); + assertTrue(result); + } + + verify(callback).run(); + } + + @Test + void castVote_firstVote_startsTimeoutTask() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + Player p1 = mock(Player.class); + Player p2 = mock(Player.class); + when(p1.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p2.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p1.getName()).thenReturn("P1"); + + RunSaveVoteService service = new RunSaveVoteService(plugin, log, () -> List.of(p1, p2)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask task = mock(BukkitTask.class); + when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenReturn(task); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.broadcastMessage(any())).thenAnswer(inv -> null); + service.castVote(p1, mock(Runnable.class)); + } + + verify(scheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(60L * 20L)); + } + + @Test + void castVote_subsequentVote_doesNotRestartTimeout() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + Player p1 = mock(Player.class); + Player p2 = mock(Player.class); + Player p3 = mock(Player.class); + when(p1.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p2.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p3.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p1.getName()).thenReturn("P1"); + when(p2.getName()).thenReturn("P2"); + + RunSaveVoteService service = new RunSaveVoteService(plugin, log, () -> List.of(p1, p2, p3)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenReturn(mock(BukkitTask.class)); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.broadcastMessage(any())).thenAnswer(inv -> null); + service.castVote(p1, mock(Runnable.class)); + service.castVote(p2, mock(Runnable.class)); + } + + verify(scheduler, org.mockito.Mockito.times(1)).runTaskLater(eq(plugin), any(Runnable.class), anyLong()); + } + + @Test + void clearVotes_cancelsActiveTimeoutTask() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + Player p1 = mock(Player.class); + Player p2 = mock(Player.class); + when(p1.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p2.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p1.getName()).thenReturn("P1"); + + RunSaveVoteService service = new RunSaveVoteService(plugin, log, () -> List.of(p1, p2)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + BukkitTask task = mock(BukkitTask.class); + when(task.isCancelled()).thenReturn(false); + when(scheduler.runTaskLater(eq(plugin), any(Runnable.class), anyLong())).thenReturn(task); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.broadcastMessage(any())).thenAnswer(inv -> null); + service.castVote(p1, mock(Runnable.class)); + } + + service.clearVotes(); + + verify(task).cancel(); + } + + @Test + void clearVotes_withNoActiveTask_doesNotThrow() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + RunSaveVoteService service = new RunSaveVoteService(plugin, log, List::of); + + service.clearVotes(); + } + + @Test + void voteTimeout_expiry_clearsVotesAndBroadcasts() { + JavaPlugin plugin = mock(JavaPlugin.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + Player p1 = mock(Player.class); + Player p2 = mock(Player.class); + when(p1.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p2.getUniqueId()).thenReturn(UUID.randomUUID()); + when(p1.getName()).thenReturn("P1"); + + RunSaveVoteService service = new RunSaveVoteService(plugin, log, () -> List.of(p1, p2)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + org.mockito.ArgumentCaptor taskCaptor = org.mockito.ArgumentCaptor.forClass(Runnable.class); + when(scheduler.runTaskLater(eq(plugin), taskCaptor.capture(), anyLong())) + .thenReturn(mock(BukkitTask.class)); + Runnable callback = mock(Runnable.class); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(() -> Bukkit.broadcastMessage(any())).thenAnswer(inv -> null); + service.castVote(p1, callback); + taskCaptor.getValue().run(); + } + + verify(callback, never()).run(); + } +} diff --git a/src/test/java/dev/deepcore/challenge/session/SavedRunStateServiceTest.java b/src/test/java/dev/deepcore/challenge/session/SavedRunStateServiceTest.java new file mode 100644 index 0000000..972c3b5 --- /dev/null +++ b/src/test/java/dev/deepcore/challenge/session/SavedRunStateServiceTest.java @@ -0,0 +1,340 @@ +package dev.deepcore.challenge.session; + +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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.deepcore.logging.DeepCoreLogger; +import java.io.File; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SavedRunStateServiceTest { + + @TempDir + File tempDir; + + private SavedRunStateService makeService() { + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getDataFolder()).thenReturn(tempDir); + DeepCoreLogger log = mock(DeepCoreLogger.class); + return new SavedRunStateService(plugin, log); + } + + private SavedRunStateService.PlayerSnapshot emptyPlayerSnapshot(String world) { + return new SavedRunStateService.PlayerSnapshot( + world, + 1.0, + 64.0, + 3.0, + 45.0f, + 10.0f, + 18.0, + 20.0, + 16, + 3.5f, + 1.2f, + 0, + 300, + 5, + 0.75f, + 1400, + "SURVIVAL", + false, + false, + new ItemStack[36], + new ItemStack[4], + new ItemStack[1], + List.of()); + } + + @Test + void hasSavedRun_returnsFalseWhenNoFile() { + assertFalse(makeService().hasSavedRun()); + } + + @Test + void hasSavedRun_returnsTrueAfterSave() { + SavedRunStateService service = makeService(); + SavedRunStateService.SavedRunSnapshot snapshot = new SavedRunStateService.SavedRunSnapshot( + 1000L, + 500L, + 50L, + true, + 200L, + true, + 350L, + false, + 0L, + List.of("uuid-a"), + List.of("comp-x"), + "normal", + Map.of()); + + service.saveRun(snapshot); + + assertTrue(service.hasSavedRun()); + } + + @Test + void saveAndLoad_roundTrip_preservesRunLevelFields() throws Exception { + SavedRunStateService service = makeService(); + SavedRunStateService.SavedRunSnapshot original = new SavedRunStateService.SavedRunSnapshot( + 99_000L, + 1_000L, + 250L, + true, + 3_000L, + true, + 5_000L, + true, + 8_000L, + List.of("uuid-1", "uuid-2"), + List.of("HARDCORE", "SHARED_HEALTH"), + "hard", + Map.of()); + + service.saveRun(original); + Optional loaded = service.loadSavedRun(); + + assertTrue(loaded.isPresent()); + SavedRunStateService.SavedRunSnapshot s = loaded.get(); + assertEquals(99_000L, s.savedAtMs()); + assertEquals(1_000L, s.runStartMs()); + assertEquals(250L, s.accumulatedPausedMs()); + assertTrue(s.reachedNether()); + assertEquals(3_000L, s.netherMs()); + assertTrue(s.reachedBlazeObjective()); + assertEquals(5_000L, s.blazeObjectiveMs()); + assertTrue(s.reachedEnd()); + assertEquals(8_000L, s.endMs()); + assertEquals(List.of("uuid-1", "uuid-2"), s.participantUuids()); + assertEquals(List.of("HARDCORE", "SHARED_HEALTH"), s.enabledComponents()); + assertEquals("hard", s.difficulty()); + } + + @Test + void saveAndLoad_roundTrip_preservesPlayerSnapshotFields() { + SavedRunStateService service = makeService(); + SavedRunStateService.PlayerSnapshot ps = emptyPlayerSnapshot("world"); + SavedRunStateService.SavedRunSnapshot snapshot = new SavedRunStateService.SavedRunSnapshot( + 0L, 0L, 0L, false, 0L, false, 0L, false, 0L, List.of(), List.of(), "normal", Map.of("player-uuid", ps)); + + service.saveRun(snapshot); + Optional loaded = service.loadSavedRun(); + + assertTrue(loaded.isPresent()); + Map snapshots = + loaded.get().playerSnapshots(); + assertTrue(snapshots.containsKey("player-uuid")); + SavedRunStateService.PlayerSnapshot restored = snapshots.get("player-uuid"); + assertEquals("world", restored.worldName()); + assertEquals(1.0, restored.x(), 0.001); + assertEquals(64.0, restored.y(), 0.001); + assertEquals(3.0, restored.z(), 0.001); + assertEquals(45.0f, restored.yaw(), 0.001f); + assertEquals(10.0f, restored.pitch(), 0.001f); + assertEquals(18.0, restored.health(), 0.001); + assertEquals(20.0, restored.maxHealth(), 0.001); + assertEquals(16, restored.foodLevel()); + assertEquals(3.5f, restored.saturation(), 0.001f); + assertEquals(1.2f, restored.exhaustion(), 0.001f); + assertEquals(0, restored.fireTicks()); + assertEquals(300, restored.remainingAir()); + assertEquals(5, restored.level()); + assertEquals(0.75f, restored.exp(), 0.001f); + assertEquals(1400, restored.totalExperience()); + assertEquals("SURVIVAL", restored.gameMode()); + assertFalse(restored.allowFlight()); + assertFalse(restored.flying()); + assertTrue(restored.potionEffects().isEmpty()); + } + + @Test + void loadSavedRun_returnsEmptyWhenNoFile() { + assertTrue(makeService().loadSavedRun().isEmpty()); + } + + @Test + void clearSavedRun_deletesFile() { + SavedRunStateService service = makeService(); + service.saveRun(new SavedRunStateService.SavedRunSnapshot( + 0L, 0L, 0L, false, 0L, false, 0L, false, 0L, List.of(), List.of(), "normal", Map.of())); + + assertTrue(service.hasSavedRun()); + service.clearSavedRun(); + assertFalse(service.hasSavedRun()); + } + + @Test + void capturePlayer_capturesPositionAndStats() { + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + World world = mock(World.class); + when(world.getName()).thenReturn("world"); + Location loc = new Location(world, 10.0, 65.0, -5.0, 90.0f, 0.0f); + when(player.getLocation()).thenReturn(loc); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getStorageContents()).thenReturn(new ItemStack[36]); + when(inventory.getArmorContents()).thenReturn(new ItemStack[4]); + when(inventory.getExtraContents()).thenReturn(new ItemStack[1]); + when(player.getHealth()).thenReturn(14.0); + when(player.getFoodLevel()).thenReturn(18); + when(player.getSaturation()).thenReturn(2.5f); + when(player.getExhaustion()).thenReturn(0.8f); + when(player.getFireTicks()).thenReturn(0); + when(player.getRemainingAir()).thenReturn(300); + when(player.getLevel()).thenReturn(3); + when(player.getExp()).thenReturn(0.4f); + when(player.getTotalExperience()).thenReturn(120); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(player.getAllowFlight()).thenReturn(false); + when(player.isFlying()).thenReturn(false); + when(player.getActivePotionEffects()).thenReturn(List.of()); + + SavedRunStateService.PlayerSnapshot snapshot = SavedRunStateService.capturePlayer(player); + + assertEquals("world", snapshot.worldName()); + assertEquals(10.0, snapshot.x(), 0.001); + assertEquals(65.0, snapshot.y(), 0.001); + assertEquals(-5.0, snapshot.z(), 0.001); + assertEquals(14.0, snapshot.health(), 0.001); + assertEquals(18, snapshot.foodLevel()); + assertEquals(2.5f, snapshot.saturation(), 0.001f); + assertEquals("SURVIVAL", snapshot.gameMode()); + assertTrue(snapshot.potionEffects().isEmpty()); + } + + @Test + void applySnapshot_restoresPositionAndStats() { + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + + World world = mock(World.class); + when(world.getName()).thenReturn("world"); + + AttributeInstance attr = mock(AttributeInstance.class); + when(attr.getBaseValue()).thenReturn(20.0); + try { + Attribute maxHealth = resolveMaxHealthAttribute(); + when(player.getAttribute(maxHealth)).thenReturn(attr); + } catch (Exception ignored) { + } + + SavedRunStateService.PlayerSnapshot snapshot = new SavedRunStateService.PlayerSnapshot( + "world", + 5.0, + 70.0, + -3.0, + 180.0f, + 5.0f, + 16.0, + 20.0, + 14, + 1.0f, + 0.5f, + 0, + 300, + 2, + 0.3f, + 60, + "SURVIVAL", + false, + false, + new ItemStack[36], + new ItemStack[4], + new ItemStack[1], + List.of()); + + try (org.mockito.MockedStatic bukkit = + org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(() -> org.bukkit.Bukkit.getWorld("world")).thenReturn(world); + SavedRunStateService.applySnapshot(player, snapshot); + } + + verify(player).setHealth(16.0); + verify(player).setFoodLevel(14); + verify(player).setSaturation(1.0f); + verify(player).setExhaustion(0.5f); + verify(player).setLevel(2); + verify(player).setExp(0.3f); + verify(player).setTotalExperience(60); + verify(player).setGameMode(GameMode.SURVIVAL); + verify(player).setAllowFlight(false); + verify(inventory).setStorageContents(org.mockito.ArgumentMatchers.any()); + verify(inventory).setArmorContents(org.mockito.ArgumentMatchers.any()); + verify(inventory).setExtraContents(org.mockito.ArgumentMatchers.any()); + verify(player).updateInventory(); + } + + @Test + void applySnapshot_clampsBelowZeroHealthToHalfHeart() { + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + + AttributeInstance attr = mock(AttributeInstance.class); + when(attr.getBaseValue()).thenReturn(20.0); + try { + when(player.getAttribute(resolveMaxHealthAttribute())).thenReturn(attr); + } catch (Exception ignored) { + } + + SavedRunStateService.PlayerSnapshot snapshot = new SavedRunStateService.PlayerSnapshot( + "", + 0.0, + 64.0, + 0.0, + 0.0f, + 0.0f, + -5.0, + 20.0, + 20, + 0.0f, + 0.0f, + 0, + 300, + 0, + 0.0f, + 0, + "SURVIVAL", + false, + false, + new ItemStack[36], + new ItemStack[4], + new ItemStack[1], + List.of()); + + try (org.mockito.MockedStatic bukkit = + org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(() -> org.bukkit.Bukkit.getWorld("")).thenReturn(null); + SavedRunStateService.applySnapshot(player, snapshot); + } + + verify(player).setHealth(0.5); + } + + private static Attribute resolveMaxHealthAttribute() throws Exception { + try { + return (Attribute) Attribute.class.getField("MAX_HEALTH").get(null); + } catch (ReflectiveOperationException ignored) { + return (Attribute) Attribute.class.getField("GENERIC_MAX_HEALTH").get(null); + } + } +} diff --git a/src/test/java/dev/deepcore/challenge/session/SessionFailureServiceTest.java b/src/test/java/dev/deepcore/challenge/session/SessionFailureServiceTest.java index 3c06576..578a904 100644 --- a/src/test/java/dev/deepcore/challenge/session/SessionFailureServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/SessionFailureServiceTest.java @@ -121,6 +121,37 @@ void hardcoreFailure_triggersResetFlowWhenConditionsMatch() { } } + @Test + void allPlayersDeadFailure_returnsEarlyWhenSharedHealthEnabled() { + SessionState state = new SessionState(); + state.setPhase(SessionState.Phase.RUNNING); + ChallengeManager manager = mock(ChallengeManager.class); + when(manager.isEnabled()).thenReturn(true); + when(manager.isComponentEnabled(ChallengeComponent.HARDCORE)).thenReturn(false); + when(manager.isComponentEnabled(ChallengeComponent.SHARED_HEALTH)).thenReturn(true); + + UUID participant = UUID.randomUUID(); + ActionBarTickerService actionBar = mock(ActionBarTickerService.class); + Runnable clearActionBar = mock(Runnable.class); + DeepCoreLogger log = mock(DeepCoreLogger.class); + + SessionFailureService service = new SessionFailureService( + state, + manager, + Set.of(participant), + Set.of(), + Set.of(participant), + actionBar, + clearActionBar, + () -> mock(WorldResetManager.class), + log); + + service.handleAllPlayersDeadFailureIfNeeded(); + + verify(actionBar, never()).stop(); + verify(clearActionBar, never()).run(); + } + @Test void allPlayersDeadFailure_triggersResetFlowWhenConditionsMatch() { SessionState state = new SessionState(); diff --git a/src/test/java/dev/deepcore/challenge/session/SessionPlayerLifecycleServiceTest.java b/src/test/java/dev/deepcore/challenge/session/SessionPlayerLifecycleServiceTest.java index 046ef3f..0f6af13 100644 --- a/src/test/java/dev/deepcore/challenge/session/SessionPlayerLifecycleServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/SessionPlayerLifecycleServiceTest.java @@ -38,6 +38,7 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.PlayerInventory; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.scheduler.BukkitScheduler; import org.junit.jupiter.api.Test; @@ -238,6 +239,15 @@ void handlePlayerDeath_respawn_andWorldChange_coverRunBranches() { .thenReturn(true); when(f.isSharedInventoryEnabled.getAsBoolean()).thenReturn(true); + Player survivor = mock(Player.class); + UUID survivorId = UUID.randomUUID(); + when(survivor.getUniqueId()).thenReturn(survivorId); + when(survivor.getGameMode()).thenReturn(GameMode.SURVIVAL); + PlayerInventory survivorInventory = mock(PlayerInventory.class); + when(survivor.getInventory()).thenReturn(survivorInventory); + f.onlineParticipants.add(player); + f.onlineParticipants.add(survivor); + Location runRespawn = mock(Location.class); when(f.respawnRoutingService.resolveRunRespawnLocation(id)).thenReturn(runRespawn); @@ -277,9 +287,9 @@ void handlePlayerDeath_respawn_andWorldChange_coverRunBranches() { verify(f.respawnRoutingService).recordDeathWorld(id, world); verify(f.sessionFailureService).handleHardcoreFailureIfNeeded(); verify(respawnEvent).setRespawnLocation(runRespawn); - verify(player).setGameMode(GameMode.SPECTATOR); - verify(f.log).sendWarn(player, "You were eliminated by hardcore mode."); - verify(f.syncSharedInventoryFromFirstParticipant).run(); + verify(f.endChallengeAndReturnToPrep).run(); + verify(player, never()).setGameMode(GameMode.SPECTATOR); + verify(f.sharedInventorySyncService).syncSharedInventoryFromSourceNow(survivor); verify(f.syncSharedHealthFromFirstParticipant).run(); verify(f.syncSharedHungerFromMostFilledParticipant).run(); verify(f.enforceInventorySlotCap).accept(player); @@ -287,6 +297,304 @@ void handlePlayerDeath_respawn_andWorldChange_coverRunBranches() { verify(f.runStatusService).onParticipantWorldChanged(any(), eq(f.onlineParticipants), anyLong(), anyBoolean()); } + @Test + void handlePlayerChangedWorld_enteringLobbyWorld_restoresDefaultMaxHealth() { + // When a player is teleported back to the lobby (e.g. after the challenge ends), + // restoreDefaultMaxHealth must be called so the half-heart health cap is lifted. + // Previously this was only called in handlePlayerJoin, not on world change. + Fixture f = new Fixture(); + f.sessionState.setPhase(SessionState.Phase.PREP); + + Player player = mock(Player.class); + World lobbyWorld = mock(World.class); + when(player.getWorld()).thenReturn(lobbyWorld); + when(f.worldClassificationService.isLobbyOrLimboWorld(lobbyWorld)).thenReturn(true); + + PlayerChangedWorldEvent event = mock(PlayerChangedWorldEvent.class); + when(event.getPlayer()).thenReturn(player); + + f.service.handlePlayerChangedWorld(event); + + verify(f.restoreDefaultMaxHealth).accept(player); + } + + @Test + void handlePlayerChangedWorld_enteringNonLobbyWorld_doesNotRestoreMaxHealth() { + Fixture f = new Fixture(); + f.sessionState.setPhase(SessionState.Phase.RUNNING); + + Player player = mock(Player.class); + World runWorld = mock(World.class); + when(player.getWorld()).thenReturn(runWorld); + when(f.worldClassificationService.isLobbyOrLimboWorld(runWorld)).thenReturn(false); + when(f.worldClassificationService.isTrainingWorld(runWorld)).thenReturn(false); + when(f.isChallengeActive.test(player)).thenReturn(false); + + PlayerChangedWorldEvent event = mock(PlayerChangedWorldEvent.class); + when(event.getPlayer()).thenReturn(player); + + f.service.handlePlayerChangedWorld(event); + + verify(f.restoreDefaultMaxHealth, never()).accept(any()); + } + + @Test + void handlePlayerRespawn_hardcoreEliminated_setsSpectatorWhenOtherParticipantsStillAlive() { + Fixture f = new Fixture(); + f.sessionState.setPhase(SessionState.Phase.RUNNING); + + Player player = mock(Player.class); + UUID id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + + UUID survivorId = UUID.randomUUID(); + f.participants.add(id); + f.participants.add(survivorId); + f.eliminatedPlayers.add(id); + when(f.isChallengeActive.test(player)).thenReturn(true); + when(f.challengeManager.isComponentEnabled(ChallengeComponent.HARDCORE)).thenReturn(true); + + PlayerRespawnEvent respawnEvent = mock(PlayerRespawnEvent.class); + when(respawnEvent.getPlayer()).thenReturn(player); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }) + .when(scheduler) + .runTask(eq(f.plugin), any(Runnable.class)); + + f.service.handlePlayerRespawn(respawnEvent); + } + + verify(player).setGameMode(GameMode.SPECTATOR); + verify(f.log).sendWarn(player, "You were eliminated by hardcore mode."); + verify(f.endChallengeAndReturnToPrep, never()).run(); + } + + @Test + void handlePlayerDeath_whenHardcoreDisabled_tracksDeathWithoutFailureReset() { + Fixture f = new Fixture(); + f.sessionState.setPhase(SessionState.Phase.RUNNING); + + Player player = mock(Player.class); + UUID id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + World world = mock(World.class); + when(player.getWorld()).thenReturn(world); + + f.participants.add(id); + when(f.isChallengeActive.test(player)).thenReturn(true); + when(f.challengeManager.isComponentEnabled(ChallengeComponent.DEGRADING_INVENTORY)) + .thenReturn(true); + when(f.challengeManager.isComponentEnabled(ChallengeComponent.HARDCORE)).thenReturn(false); + + PlayerDeathEvent deathEvent = mock(PlayerDeathEvent.class); + when(deathEvent.getPlayer()).thenReturn(player); + + f.service.handlePlayerDeath(deathEvent); + + verify(f.clearLockedBarrierSlots).accept(player); + verify(f.respawnRoutingService).recordDeathWorld(id, world); + verify(f.sessionFailureService, never()).handleHardcoreFailureIfNeeded(); + verify(f.sessionFailureService).handleAllPlayersDeadFailureIfNeeded(); + org.junit.jupiter.api.Assertions.assertTrue(f.recentlyDeadPlayers.contains(id)); + org.junit.jupiter.api.Assertions.assertFalse(f.eliminatedPlayers.contains(id)); + } + + @Test + void handlePlayerRespawn_runningPhase_overridesOnlyStaleBedRespawn() { + Fixture f = new Fixture(); + f.sessionState.setPhase(SessionState.Phase.RUNNING); + + Player player = mock(Player.class); + UUID id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + f.participants.add(id); + + World currentRunWorld = mock(World.class); + UUID currentRunWorldId = UUID.randomUUID(); + when(currentRunWorld.getUID()).thenReturn(currentRunWorldId); + + Location pluginRespawn = mock(Location.class); + when(pluginRespawn.getWorld()).thenReturn(currentRunWorld); + when(f.respawnRoutingService.resolveRunRespawnLocation(id)).thenReturn(pluginRespawn); + + // Simulate a bed respawn set from a previous run world + World oldWorld = mock(World.class); + when(oldWorld.getUID()).thenReturn(UUID.randomUUID()); + Location staleOldWorldBed = new Location(oldWorld, 100.0D, 70.0D, 100.0D); + + PlayerRespawnEvent respawnEvent = mock(PlayerRespawnEvent.class); + when(respawnEvent.getPlayer()).thenReturn(player); + when(respawnEvent.getRespawnLocation()).thenReturn(staleOldWorldBed); + + f.service.handlePlayerRespawn(respawnEvent); + + verify(f.respawnRoutingService).resolveRunRespawnLocation(id); + verify(respawnEvent).setRespawnLocation(pluginRespawn); + } + + @Test + void handlePlayerRespawn_runningPhase_preservesBedRespawnInCurrentWorld() { + Fixture f = new Fixture(); + f.sessionState.setPhase(SessionState.Phase.RUNNING); + + Player player = mock(Player.class); + UUID id = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(id); + f.participants.add(id); + + World currentWorld = mock(World.class); + UUID currentWorldId = UUID.randomUUID(); + when(currentWorld.getUID()).thenReturn(currentWorldId); + + Location runRespawn = mock(Location.class); + when(runRespawn.getWorld()).thenReturn(currentWorld); + when(f.respawnRoutingService.resolveRunRespawnLocation(id)).thenReturn(runRespawn); + + Location bedRespawn = mock(Location.class); + when(bedRespawn.getWorld()).thenReturn(currentWorld); + + PlayerRespawnEvent respawnEvent = mock(PlayerRespawnEvent.class); + when(respawnEvent.getPlayer()).thenReturn(player); + when(respawnEvent.getRespawnLocation()).thenReturn(bedRespawn); + + f.service.handlePlayerRespawn(respawnEvent); + + verify(f.respawnRoutingService).resolveRunRespawnLocation(id); + verify(respawnEvent, never()).setRespawnLocation(any(Location.class)); + } + + @Test + void handlePlayerRespawn_runningPhase_skipsRespawningPlayerAsSharedInventorySource() { + Fixture f = new Fixture(); + f.sessionState.setPhase(SessionState.Phase.RUNNING); + + Player respawningPlayer = mock(Player.class); + UUID respawningId = UUID.randomUUID(); + when(respawningPlayer.getUniqueId()).thenReturn(respawningId); + when(respawningPlayer.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(f.isChallengeActive.test(respawningPlayer)).thenReturn(true); + when(f.isSharedInventoryEnabled.getAsBoolean()).thenReturn(true); + + Player survivor = mock(Player.class); + UUID survivorId = UUID.randomUUID(); + when(survivor.getUniqueId()).thenReturn(survivorId); + when(survivor.getGameMode()).thenReturn(GameMode.SURVIVAL); + + f.onlineParticipants.add(respawningPlayer); + f.onlineParticipants.add(survivor); + + Location runRespawn = mock(Location.class); + when(f.respawnRoutingService.resolveRunRespawnLocation(respawningId)).thenReturn(runRespawn); + + PlayerRespawnEvent respawnEvent = mock(PlayerRespawnEvent.class); + when(respawnEvent.getPlayer()).thenReturn(respawningPlayer); + when(respawnEvent.getRespawnLocation()).thenReturn(mock(Location.class)); + + BukkitScheduler scheduler = mock(BukkitScheduler.class); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }) + .when(scheduler) + .runTask(eq(f.plugin), any(Runnable.class)); + + f.service.handlePlayerRespawn(respawnEvent); + } + + verify(f.sharedInventorySyncService).syncSharedInventoryFromSourceNow(survivor); + verify(f.sharedInventorySyncService, never()).syncSharedInventoryFromSourceNow(respawningPlayer); + } + + @Test + void handlePlayerDeath_sharedInventoryOn_keepInventoryOff_wipesOtherParticipants() { + Fixture f = new Fixture(); + f.sessionState.setPhase(SessionState.Phase.RUNNING); + + Player dyingPlayer = mock(Player.class); + UUID dyingId = UUID.randomUUID(); + when(dyingPlayer.getUniqueId()).thenReturn(dyingId); + when(dyingPlayer.getWorld()).thenReturn(mock(World.class)); + PlayerInventory dyingInv = mock(PlayerInventory.class); + when(dyingPlayer.getInventory()).thenReturn(dyingInv); + + Player otherA = mock(Player.class); + Player otherB = mock(Player.class); + UUID otherAId = UUID.randomUUID(); + UUID otherBId = UUID.randomUUID(); + when(otherA.getUniqueId()).thenReturn(otherAId); + when(otherB.getUniqueId()).thenReturn(otherBId); + + PlayerInventory invA = mock(PlayerInventory.class); + PlayerInventory invB = mock(PlayerInventory.class); + when(otherA.getInventory()).thenReturn(invA); + when(otherB.getInventory()).thenReturn(invB); + + f.onlineParticipants.add(dyingPlayer); + f.onlineParticipants.add(otherA); + f.onlineParticipants.add(otherB); + + when(f.isChallengeActive.test(dyingPlayer)).thenReturn(true); + when(f.isSharedInventoryEnabled.getAsBoolean()).thenReturn(true); + when(f.challengeManager.isComponentEnabled(ChallengeComponent.KEEP_INVENTORY)) + .thenReturn(false); + when(f.challengeManager.isComponentEnabled(ChallengeComponent.HARDCORE)).thenReturn(false); + + PlayerDeathEvent deathEvent = mock(PlayerDeathEvent.class); + when(deathEvent.getPlayer()).thenReturn(dyingPlayer); + + f.service.handlePlayerDeath(deathEvent); + + verify(deathEvent).setKeepInventory(false); + verify(invA).clear(); + verify(otherA).updateInventory(); + verify(invB).clear(); + verify(otherB).updateInventory(); + verify(dyingInv, never()).clear(); + } + + @Test + void handlePlayerDeath_sharedInventoryOn_keepInventoryOn_doesNotWipeOtherParticipants() { + Fixture f = new Fixture(); + f.sessionState.setPhase(SessionState.Phase.RUNNING); + + Player dyingPlayer = mock(Player.class); + UUID dyingId = UUID.randomUUID(); + when(dyingPlayer.getUniqueId()).thenReturn(dyingId); + when(dyingPlayer.getWorld()).thenReturn(mock(World.class)); + + Player other = mock(Player.class); + when(other.getUniqueId()).thenReturn(UUID.randomUUID()); + PlayerInventory otherInv = mock(PlayerInventory.class); + when(other.getInventory()).thenReturn(otherInv); + + f.onlineParticipants.add(dyingPlayer); + f.onlineParticipants.add(other); + + when(f.isChallengeActive.test(dyingPlayer)).thenReturn(true); + when(f.isSharedInventoryEnabled.getAsBoolean()).thenReturn(true); + when(f.challengeManager.isComponentEnabled(ChallengeComponent.KEEP_INVENTORY)) + .thenReturn(true); + when(f.challengeManager.isComponentEnabled(ChallengeComponent.HARDCORE)).thenReturn(false); + + PlayerDeathEvent deathEvent = mock(PlayerDeathEvent.class); + when(deathEvent.getPlayer()).thenReturn(dyingPlayer); + + f.service.handlePlayerDeath(deathEvent); + + verify(otherInv, never()).clear(); + verify(other, never()).updateInventory(); + } + private static final class Fixture { final JavaPlugin plugin = mock(JavaPlugin.class); final DeepCoreLogger log = mock(DeepCoreLogger.class); diff --git a/src/test/java/dev/deepcore/challenge/session/SessionRulesCoordinatorServiceTest.java b/src/test/java/dev/deepcore/challenge/session/SessionRulesCoordinatorServiceTest.java index f42ed73..9907500 100644 --- a/src/test/java/dev/deepcore/challenge/session/SessionRulesCoordinatorServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/SessionRulesCoordinatorServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.when; import dev.deepcore.challenge.ChallengeComponent; +import dev.deepcore.challenge.ChallengeDifficulty; import dev.deepcore.challenge.ChallengeManager; import dev.deepcore.challenge.world.WorldResetManager; import java.util.List; @@ -22,6 +23,7 @@ void syncWorldRules_appliesKeepInventoryAndLobbyPoliciesWhenAvailable() { when(challengeManager.isEnabled()).thenReturn(true); when(challengeManager.isComponentEnabled(ChallengeComponent.KEEP_INVENTORY)) .thenReturn(true); + when(challengeManager.getDifficulty()).thenReturn(ChallengeDifficulty.NORMAL); WorldResetManager worldResetManager = mock(WorldResetManager.class); RunHealthCoordinatorService healthService = mock(RunHealthCoordinatorService.class); diff --git a/src/test/java/dev/deepcore/challenge/session/SessionTimingStateTest.java b/src/test/java/dev/deepcore/challenge/session/SessionTimingStateTest.java index 23cb158..5c5dc4b 100644 --- a/src/test/java/dev/deepcore/challenge/session/SessionTimingStateTest.java +++ b/src/test/java/dev/deepcore/challenge/session/SessionTimingStateTest.java @@ -1,11 +1,34 @@ package dev.deepcore.challenge.session; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; class SessionTimingStateTest { + @Test + void restoreAndResume_accumulatesPausedMillis() throws InterruptedException { + SessionTimingState state = new SessionTimingState(); + + long now = System.currentTimeMillis(); + long runStartMs = now - 5000L; + long accumulatedPausedMs = 200L; + long savedAtMs = now - 1000L; + + state.restore(runStartMs, accumulatedPausedMs, savedAtMs); + + assertEquals(runStartMs, state.getRunStartMillis()); + assertTrue(state.getAccumulatedPausedMillis() >= accumulatedPausedMs + 1000L - 20L); + + state.beginPause(System.currentTimeMillis()); + Thread.sleep(5); + long resumeAt = System.currentTimeMillis(); + state.resume(resumeAt); + assertEquals(0L, state.getPausedStartedMillis()); + assertTrue(state.getAccumulatedPausedMillis() >= accumulatedPausedMs); + } + @Test void beginRunBeginPauseResumeAndReset_updatesTimingFields() { SessionTimingState state = new SessionTimingState(); diff --git a/src/test/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorServiceTest.java b/src/test/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorServiceTest.java index 0183c27..c580f35 100644 --- a/src/test/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/session/SessionTransitionOrchestratorServiceTest.java @@ -175,6 +175,7 @@ void resetAndShutdownAndEndChallenge_coverStateTransitions() { state.setPhase(SessionState.Phase.RUNNING); JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.isEnabled()).thenReturn(true); DeepCoreLogger log = mock(DeepCoreLogger.class); ChallengeManager manager = mock(ChallengeManager.class); Set ready = new HashSet<>(); diff --git a/src/test/java/dev/deepcore/challenge/session/SidebarModelFactoryTest.java b/src/test/java/dev/deepcore/challenge/session/SidebarModelFactoryTest.java index 025d036..c430851 100644 --- a/src/test/java/dev/deepcore/challenge/session/SidebarModelFactoryTest.java +++ b/src/test/java/dev/deepcore/challenge/session/SidebarModelFactoryTest.java @@ -5,7 +5,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import dev.deepcore.records.RunRecordsService; +import dev.deepcore.challenge.records.RunRecordsService; import org.junit.jupiter.api.Test; class SidebarModelFactoryTest { diff --git a/src/test/java/dev/deepcore/challenge/training/TrainingManagerTest.java b/src/test/java/dev/deepcore/challenge/training/TrainingManagerTest.java index f54d01b..4e2f8b8 100644 --- a/src/test/java/dev/deepcore/challenge/training/TrainingManagerTest.java +++ b/src/test/java/dev/deepcore/challenge/training/TrainingManagerTest.java @@ -5,7 +5,6 @@ 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; @@ -146,7 +145,7 @@ void crossingChallengeEntranceBlock_doesNotTeleportWithoutActiveAttempt() throws } @Test - void playerRespawnInTrainingWorld_usesConfiguredTrainingRespawnSpawn() { + void playerRespawnInTrainingWorld_doesNotOverrideBedRespawnWithoutActiveAttempt() { config.set("training.respawn-spawn.world", "deepcore_gym"); config.set("training.respawn-spawn.x", 12.5D); config.set("training.respawn-spawn.y", 70.0D); @@ -168,13 +167,7 @@ void playerRespawnInTrainingWorld_usesConfiguredTrainingRespawnSpawn() { 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)); + verify(respawnEvent, org.mockito.Mockito.never()).setRespawnLocation(any(Location.class)); } @Test diff --git a/src/test/java/dev/deepcore/challenge/ui/PrepGuiRendererTest.java b/src/test/java/dev/deepcore/challenge/ui/PrepGuiRendererTest.java index cef5ce5..4a66ace 100644 --- a/src/test/java/dev/deepcore/challenge/ui/PrepGuiRendererTest.java +++ b/src/test/java/dev/deepcore/challenge/ui/PrepGuiRendererTest.java @@ -11,8 +11,9 @@ import be.seeseemelk.mockbukkit.MockBukkit; import be.seeseemelk.mockbukkit.ServerMock; import dev.deepcore.challenge.ChallengeComponent; +import dev.deepcore.challenge.ChallengeDifficulty; import dev.deepcore.challenge.ChallengeManager; -import dev.deepcore.records.RunRecord; +import dev.deepcore.challenge.records.RunRecord; import java.lang.reflect.Method; import java.time.Instant; import java.time.ZoneOffset; @@ -104,10 +105,15 @@ void populatePages_renderExpectedControlsAndHistoryPaging() { when(challengeManager.isComponentEnabled(ChallengeComponent.HARDCORE)).thenReturn(true); Inventory categories = Bukkit.createInventory(null, 54, "categories"); - 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()); + renderer.populateCategoriesPage(categories, true, 2, 3, true, ChallengeDifficulty.NORMAL, false); + assertEquals(Material.EMERALD_BLOCK, categories.getItem(48).getType()); + assertEquals(Material.RECOVERY_COMPASS, categories.getItem(30).getType()); + assertTrue(categories.getItem(30).getItemMeta().getLore().get(0).contains("refresh pedestal preview")); + assertEquals(Material.ENDER_PEARL, categories.getItem(32).getType()); + + Inventory categoriesLocked = Bukkit.createInventory(null, 54, "categories-locked"); + renderer.populateCategoriesPage(categoriesLocked, true, 2, 3, true, ChallengeDifficulty.NORMAL, true); + assertEquals(Material.BARRIER, categoriesLocked.getItem(30).getType()); Inventory inventoryPage = Bukkit.createInventory(null, 54, "inventory"); renderer.populateInventoryPage(inventoryPage, challengeManager, false, 1, 3, false); @@ -130,7 +136,9 @@ void populatePages_renderExpectedControlsAndHistoryPaging() { 3_000L, 4_000L, 5_000L, - "A,B")) + "A,B", + "", + "")) .toList(); DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC); LongFunction durationFormatter = millis -> millis + "ms"; diff --git a/src/test/java/dev/deepcore/challenge/vitals/SharedVitalsServiceTest.java b/src/test/java/dev/deepcore/challenge/vitals/SharedVitalsServiceTest.java index 482e716..669d42e 100644 --- a/src/test/java/dev/deepcore/challenge/vitals/SharedVitalsServiceTest.java +++ b/src/test/java/dev/deepcore/challenge/vitals/SharedVitalsServiceTest.java @@ -265,6 +265,41 @@ void syncSharedHealthFromFirstParticipant_noopsWhenAllParticipantsAreSpectators( verify(s2, never()).setHealth(any(Double.class)); } + @Test + void syncHealthAcrossParticipants_skipsDeadPlayersAsRecipients() { + JavaPlugin plugin = mock(JavaPlugin.class); + Player alive = mock(Player.class); + Player dead = mock(Player.class); + + when(alive.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(dead.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(alive.getHealth()).thenReturn(10.0D); + when(dead.getHealth()).thenReturn(0.0D); + + org.bukkit.attribute.AttributeInstance aliveAttr = mock(org.bukkit.attribute.AttributeInstance.class); + when(alive.getAttribute(maxHealthAttribute())).thenReturn(aliveAttr); + when(aliveAttr.getValue()).thenReturn(20.0D); + + SharedVitalsService service = new SharedVitalsService(plugin, () -> List.of(alive, dead)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }) + .when(scheduler) + .runTask(eq(plugin), any(Runnable.class)); + + service.syncHealthAcrossParticipants(15.0D); + } + + verify(alive).setHealth(15.0D); + verify(dead, never()).setHealth(any(Double.class)); + } + @Test void syncHealthAcrossParticipants_clampsHealthAndSkipsSpectators() { JavaPlugin plugin = mock(JavaPlugin.class); @@ -361,6 +396,44 @@ void syncHealthAcrossParticipants_withHurtEffect_animatesOnlyNonSourceWhenHealth verify(source, never()).playHurtAnimation(any(Float.class)); } + @Test + void syncSharedHealthFromFirstParticipant_skipsDeadPlayersAsSource() { + JavaPlugin plugin = mock(JavaPlugin.class); + Player dead = mock(Player.class); + Player alive = mock(Player.class); + + when(dead.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(alive.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(dead.getHealth()).thenReturn(0.0D); + when(alive.getHealth()).thenReturn(15.0D); + + org.bukkit.attribute.AttributeInstance deadAttr = mock(org.bukkit.attribute.AttributeInstance.class); + org.bukkit.attribute.AttributeInstance aliveAttr = mock(org.bukkit.attribute.AttributeInstance.class); + when(dead.getAttribute(maxHealthAttribute())).thenReturn(deadAttr); + when(alive.getAttribute(maxHealthAttribute())).thenReturn(aliveAttr); + when(deadAttr.getValue()).thenReturn(20.0D); + when(aliveAttr.getValue()).thenReturn(20.0D); + + SharedVitalsService service = new SharedVitalsService(plugin, () -> List.of(dead, alive)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }) + .when(scheduler) + .runTask(eq(plugin), any(Runnable.class)); + + service.syncSharedHealthFromFirstParticipant(); + } + + verify(dead).setHealth(15.0D); + verify(alive).setHealth(15.0D); + } + @Test void syncSharedHealthFromFirstParticipant_usesFirstActiveParticipantHealth() { JavaPlugin plugin = mock(JavaPlugin.class); diff --git a/src/test/java/dev/deepcore/records/RunRecordTest.java b/src/test/java/dev/deepcore/records/RunRecordTest.java deleted file mode 100644 index 3f82a78..0000000 --- a/src/test/java/dev/deepcore/records/RunRecordTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package dev.deepcore.records; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import org.junit.jupiter.api.Test; - -class RunRecordTest { - - @Test - void gettersAndParticipants_areExposed() { - RunRecord record = new RunRecord(1000L, 2000L, 300L, 400L, 500L, 600L, 700L, "Alice, Bob , Charlie"); - - assertEquals(1000L, record.getTimestamp()); - assertEquals(2000L, record.getOverallTimeMs()); - assertEquals(300L, record.getOverworldToNetherMs()); - assertEquals(400L, record.getNetherToBlazeRodsMs()); - assertEquals(500L, record.getBlazeRodsToEndMs()); - assertEquals(600L, record.getNetherToEndMs()); - assertEquals(700L, record.getEndToDragonMs()); - assertEquals("Alice, Bob , Charlie", record.getParticipantsCsv()); - assertEquals(List.of("Alice", "Bob", "Charlie"), record.getParticipants()); - } - - @Test - void getParticipants_handlesBlankAndEmptyEntries() { - RunRecord blank = new RunRecord(0L, 0L, 0L, 0L, 0L, 0L, 0L, " "); - RunRecord sparse = new RunRecord(0L, 0L, 0L, 0L, 0L, 0L, 0L, "A,, ,B"); - - assertTrue(blank.getParticipants().isEmpty()); - assertEquals(List.of("A", "B"), sparse.getParticipants()); - } - - @Test - void toString_containsCoreFields() { - RunRecord record = new RunRecord(1L, 2L, 3L, 4L, 5L, 6L, 7L, "P1,P2"); - String text = record.toString(); - - assertTrue(text.contains("overallTimeMs=2")); - assertTrue(text.contains("participantsCsv='P1,P2'")); - } -}