From a904a9e97203e99cfe48693792cd52f83479e3dc Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:41:38 +0000 Subject: [PATCH] feat: bind surgery consent and operations to character sessions --- pom.xml | 4 +- .../tfminecraft/surgery/SurgeryPlugin.java | 10 + .../surgery/commands/SurgeryCommand.java | 19 +- .../surgery/listeners/PlayerListener.java | 39 +- .../managers/SurgeryCompletionHandler.java | 260 ++++++------- .../surgery/managers/SurgeryItemHandler.java | 13 +- .../managers/SurgeryMechanicsManager.java | 8 +- .../surgery/managers/SurgeryMenuBuilder.java | 2 +- .../surgery/managers/SurgeryMenuHolder.java | 23 +- .../surgery/managers/SurgeryMenuManager.java | 140 +++---- .../managers/SurgeryRequestManager.java | 48 ++- .../surgery/managers/SurgeryStateManager.java | 65 ++-- .../surgery/procedures/Ailments.java | 9 - src/main/resources/messages.yml | 2 + .../surgery/SurgeryLifecycleTest.java | 281 ++++++++------ .../tfminecraft/surgery/TestCharacters.java | 35 ++ .../surgery/commands/SurgeryCommandTest.java | 71 +++- .../managers/SurgeryCompletionTest.java | 347 ++++++++++++++---- .../managers/SurgeryItemHandlerTest.java | 36 ++ .../managers/SurgeryMechanicsManagerTest.java | 127 ++++--- .../managers/SurgeryPresentationTest.java | 1 + .../managers/SurgeryStateAndRequestTest.java | 188 ++++++++-- 22 files changed, 1129 insertions(+), 599 deletions(-) create mode 100644 src/test/java/net/tfminecraft/surgery/TestCharacters.java diff --git a/pom.xml b/pom.xml index 1564c1e..3334243 100644 --- a/pom.xml +++ b/pom.xml @@ -15,8 +15,8 @@ 21 UTF-8 - 2.4.0 - 2.0.0 + 2.16.0 + 2.3.0 diff --git a/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java b/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java index 86a8615..06ad38c 100644 --- a/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java +++ b/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java @@ -16,6 +16,13 @@ public class SurgeryPlugin extends JavaPlugin { @Override public void onEnable() { + try { + net.tfminecraft.tlibs.character.Characters.available(); + } catch (LinkageError error) { + getLogger().severe("Surgery requires TLibs 2.3.0 or newer. Update TLibs and restart the server."); + getServer().getPluginManager().disablePlugin(this); + return; + } instance = this; saveDefaultConfig(); @@ -45,6 +52,9 @@ public void onDisable() { // the abandonment handler, which tries to schedule a task on a // disabling plugin (IllegalPluginAccessException) HandlerList.unregisterAll(this); + if (surgeryMenuManager != null) { + surgeryMenuManager.shutdown(); + } // Close open surgery menus. After a reload the holder class comes from // a new classloader, so the instanceof check in the listener no longer diff --git a/src/main/java/net/tfminecraft/surgery/commands/SurgeryCommand.java b/src/main/java/net/tfminecraft/surgery/commands/SurgeryCommand.java index 21cbe7d..404dd61 100644 --- a/src/main/java/net/tfminecraft/surgery/commands/SurgeryCommand.java +++ b/src/main/java/net/tfminecraft/surgery/commands/SurgeryCommand.java @@ -9,6 +9,8 @@ import net.tfminecraft.surgery.managers.SurgeryUIUpdater; import net.tfminecraft.surgery.procedures.Ailments; import net.tfminecraft.surgery.procedures.Durations; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.command.Command; @@ -71,7 +73,9 @@ private void offer(Player surgeon, String patientName) { surgeon.sendMessage(uiUpdater.getMessage("command-player-not-found", "&cPlayer not found or not online!")); return; } - if (!checkCanStart(surgeon, patient)) { + CharacterSession surgeonSession = Characters.currentSession(surgeon).orElse(null); + CharacterSession patientSession = Characters.currentSession(patient).orElse(null); + if (!checkCanStart(surgeon, patient, surgeonSession, patientSession)) { return; } @@ -88,7 +92,7 @@ private void offer(Player surgeon, String patientName) { } int timeoutSeconds = Math.max(10, plugin.getConfig().getInt("request-timeout-seconds", 60)); - requests.offer(patient.getUniqueId(), surgeon.getUniqueId(), ailment.traitId(), + requests.offer(patientSession, surgeonSession, ailment.traitId(), now + timeoutSeconds * 1000L); String ailmentName = ChatColor.stripColor(ailment.displayName()); @@ -125,7 +129,7 @@ private void accept(Player patient) { patient.sendMessage(uiUpdater.getMessage("request-surgeon-gone")); return; } - if (!checkCanStart(surgeon, patient)) { + if (!checkCanStart(surgeon, patient, request.surgeonSession(), request.patientSession())) { patient.sendMessage(uiUpdater.getMessage("request-cannot-start").replace("%surgeon%", surgeon.getName())); return; } @@ -162,7 +166,14 @@ private boolean canOperate(Player surgeon) { // ============================================== // Checks shared by offering and accepting; messages the surgeon on failure // ============================================== - private boolean checkCanStart(Player surgeon, Player patient) { + private boolean checkCanStart(Player surgeon, Player patient, + CharacterSession surgeonSession, CharacterSession patientSession) { + if (surgeonSession == null || patientSession == null + || !Characters.isCurrent(surgeonSession) || !Characters.isCurrent(patientSession)) { + surgeon.sendMessage(uiUpdater.getMessage("command-character-required", + "&cBoth characters must be ready before surgery.")); + return false; + } // Block re-entry: opening a second menu fires the old menu's close event, // which fails the surgery and wipes the freshly initialized session if (menuManager.getStateManager().hasSession(surgeon.getUniqueId())) { diff --git a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java index 7ac56f2..1e36de5 100644 --- a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java +++ b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java @@ -4,13 +4,11 @@ import org.bukkit.event.Listener; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; -import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.inventory.ItemStack; -import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; -import net.tfminecraft.rpcharacters.permadeath.CharacterPermakillEvent; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; import net.tfminecraft.surgery.managers.SurgeryMenuManager; public class PlayerListener implements Listener { @@ -21,34 +19,9 @@ public PlayerListener(SurgeryMenuManager menuManager) { this.menuManager = menuManager; } - // LOW runs before RPCharacters unloads the quitting player's characters, - // so the failure command and penalty still find the same character - @EventHandler(priority = EventPriority.LOW) - public void onPlayerQuit(PlayerQuitEvent event) { - // Fail an in-progress surgery (quitting is not a free escape from the - // failure command) and remove leftover state so nothing leaks - menuManager.handleSurgeonQuit(event.getPlayer()); - - // The quitting player may also be someone's patient; fail that surgeon's - // surgery now instead of on their next move - menuManager.handlePatientQuit(event.getPlayer()); - - // Pending offers to or from the quitting player can no longer be answered - menuManager.getRequestManager().forget(event.getPlayer().getUniqueId()); - } - - @EventHandler - public void onCharacterActivated(CharacterActivatedEvent event) { - // The operation belongs to the characters who began it; a switch or - // permakill ends it before the result can land on another character - menuManager.handleCharacterActivated(event.getOwnerUuid(), event.getCharacter().getId()); - } - - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onCharacterPermakill(CharacterPermakillEvent event) { - // The kill is certain once no listener cancelled it; the last - // character's death fires no activation, so it is handled here - menuManager.handleCharacterPermakilled(event.getPlayer().getUniqueId(), event.getCharacter().getId()); + @EventHandler(priority = EventPriority.LOWEST) + public void onSessionEnd(CharacterSessionEndEvent event) { + menuManager.handleSessionEnd(event); } @EventHandler @@ -76,7 +49,7 @@ public void onInventoryClick(InventoryClickEvent event) { int slot = event.getSlot(); // Handle the click - menuManager.handleItemClick(player, clickedItem, slot); + menuManager.handleItemClick(player, event.getInventory(), clickedItem, slot); } } } @@ -89,7 +62,7 @@ public void onInventoryClose(InventoryCloseEvent event) { if (event.getPlayer() instanceof Player) { Player player = (Player) event.getPlayer(); // Handle abandonment (giving up on surgery) - menuManager.handleSurgeryAbandonment(player); + menuManager.handleSurgeryAbandonment(player, event.getInventory()); } } } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java index 65cd961..a943f2d 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java @@ -2,9 +2,14 @@ import net.tfminecraft.surgery.procedures.Ailments; import net.tfminecraft.surgery.procedures.Durations; +import net.tfminecraft.surgery.managers.SurgeryStateManager.Outcome; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent.Reason; +import net.tfminecraft.tlibs.character.Characters; import org.bukkit.Bukkit; import org.bukkit.Sound; import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; import org.bukkit.plugin.java.JavaPlugin; import java.util.UUID; @@ -60,69 +65,92 @@ public boolean isSurgerySuccessful(UUID playerId) { return true; } - // ============================================== - // Handles successful surgery completion - // ============================================== public void handleSuccess(Player surgeon) { - UUID surgeonId = surgeon.getUniqueId(); - String traitId = stateManager.getTraitId(surgeonId); - String ailment = stateManager.getAilmentName(surgeonId); - Player patient = patientOf(surgeonId); - - // The ailment may have healed on its own while the operation was running. - // Do not announce a success the cure did not actually perform. - if (patient == null || !Ailments.cure(patient, traitId)) { - stateManager.cleanup(surgeonId); - closeMenuNextTick(surgeon); - surgeon.sendMessage(uiUpdater.getMessage("ailment-already-healed").replace("%ailment%", ailment)); + Outcome outcome = stateManager.finish(surgeon.getUniqueId()); + if (outcome == null) { + return; + } + Inventory menu = surgeryMenu(surgeon, outcome); + Player patient = currentPatient(outcome); + if (!isCurrent(surgeon, outcome.surgeonSession()) || patient == null || outcome.traitId() == null + || !Ailments.cure(patient, outcome.traitId())) { + closeMenuNextTick(surgeon, outcome, menu); + if (isCurrent(surgeon, outcome.surgeonSession())) { + surgeon.sendMessage(uiUpdater.getMessage("ailment-already-healed") + .replace("%ailment%", ailmentName(outcome))); + } return; } - executeCompletionCommand(surgeon, true); - stateManager.cleanup(surgeonId); - closeMenuNextTick(surgeon); - - surgeon.playSound(surgeon.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f, 1.0f); - surgeon.sendMessage(uiUpdater.getMessage("surgery-successful")); - surgeon.sendMessage(uiUpdater.getMessage("surgery-successful-subtitle") - .replace("%ailment%", ailment).replace("%patient%", patient.getName())); - patient.sendMessage(uiUpdater.getMessage("patient-surgery-successful") - .replace("%ailment%", ailment).replace("%surgeon%", surgeon.getName())); + executeCompletionCommand(surgeon, outcome, true); + closeMenuNextTick(surgeon, outcome, menu); + if (isCurrent(surgeon, outcome.surgeonSession())) { + surgeon.playSound(surgeon.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f, 1.0f); + surgeon.sendMessage(uiUpdater.getMessage("surgery-successful")); + surgeon.sendMessage(uiUpdater.getMessage("surgery-successful-subtitle") + .replace("%ailment%", ailmentName(outcome)).replace("%patient%", patientName(outcome))); + } + if (isCurrent(patient, outcome.patientSession())) { + patient.sendMessage(uiUpdater.getMessage("patient-surgery-successful") + .replace("%ailment%", ailmentName(outcome)).replace("%surgeon%", surgeon.getName())); + } } - // ============================================== - // Fails the surgery with a message - // ============================================== public void failSurgery(Player surgeon, String message) { - UUID surgeonId = surgeon.getUniqueId(); - - // Check if player is actually in surgery (prevent duplicate messages) - if (!stateManager.hasSession(surgeonId)) { return; } - - executeCompletionCommand(surgeon, false); - // Only a patient who was actually sedated or cut is left worse off - String extra = stateManager.hasOperated(surgeonId) ? lengthenHealing(surgeon) : null; - - stateManager.cleanup(surgeonId); + Outcome outcome = stateManager.finish(surgeon.getUniqueId()); + if (outcome == null) { + return; + } + Inventory menu = surgeryMenu(surgeon, outcome); + String extra = outcome.operated() ? lengthenHealing(outcome, null) : null; + executeCompletionCommand(surgeon, outcome, false); + closeMenuNextTick(surgeon, outcome, menu); + reportFailure(surgeon, outcome, message, extra); + } - closeMenuNextTick(surgeon); - surgeon.playSound(surgeon.getLocation(), Sound.ENTITY_WITHER_DEATH, 1.0f, 0.8f); - surgeon.sendMessage(uiUpdater.getMessage("surgery-failed")); - surgeon.sendMessage(message); - if (extra != null) { - surgeon.sendMessage(uiUpdater.getMessage("surgery-failed-penalty").replace("%extra%", extra)); + public void handleAbandonment(Player surgeon) { + if (stateManager.hasOperated(surgeon.getUniqueId())) { + failSurgery(surgeon, uiUpdater.getMessage("failure-gave-up")); + return; + } + Outcome outcome = stateManager.finish(surgeon.getUniqueId()); + if (outcome != null && isCurrent(surgeon, outcome.surgeonSession())) { + surgeon.sendMessage(uiUpdater.getMessage("surgery-cancelled")); } } - // ============================================== - // Adds the failure penalty to the patient's healing time - // Returns the added time for messages, or null if nothing was added - // ============================================== - private String lengthenHealing(Player surgeon) { + /** Ends only the operation involving this exact outgoing session. */ + public void handleEnd(Player surgeon, CharacterSession ending, Reason reason) { UUID surgeonId = surgeon.getUniqueId(); - Player patient = patientOf(surgeonId); - String traitId = stateManager.getTraitId(surgeonId); - if (patient == null || traitId == null) { + if (ending == null || (!ending.equals(stateManager.getSurgeonSession(surgeonId)) + && !ending.equals(stateManager.getPatientSession(surgeonId)))) { + return; + } + Outcome outcome = stateManager.finish(surgeonId); + if (outcome == null || reason == Reason.SHUTDOWN) { + return; + } + Inventory menu = surgeryMenu(surgeon, outcome); + boolean patientEnded = ending.equals(outcome.patientSession()); + String extra = null; + if (outcome.operated() && (!patientEnded || reason == Reason.QUIT)) { + extra = lengthenHealing(outcome, patientEnded ? ending : null); + } + // Account-named commands never cross a session-end boundary. + closeMenuNextTick(surgeon, outcome, menu); + reportFailure(surgeon, outcome, uiUpdater.getMessage(patientEnded ? "failure-patient-left" : "failure-gave-up"), extra); + } + + private String lengthenHealing(Outcome outcome, CharacterSession quittingPatient) { + Player patient = currentPatient(outcome); + if (quittingPatient != null && quittingPatient.equals(outcome.patientSession())) { + patient = Bukkit.getPlayer(quittingPatient.ref().ownerId()); + // During QUIT the outgoing body is still loaded, but its session is already invalid. + if (patient == null || !patient.isOnline() || Characters.currentSession(patient).isPresent()) { + return null; + } + } + if (patient == null || outcome.traitId() == null) { return null; } long penalty = Durations.parseMs(plugin.getConfig().getString("failure-healing-penalty", "12h")); @@ -133,109 +161,83 @@ private String lengthenHealing(Player surgeon) { if (penalty == 0L) { return null; } - long remaining = Ailments.extend(patient, traitId, penalty); + long remaining = Ailments.extend(patient, outcome.traitId(), penalty); if (remaining < 0L) { return null; } String extra = Durations.formatHours(penalty); - patient.sendMessage(uiUpdater.getMessage("patient-surgery-failed") - .replace("%ailment%", stateManager.getAilmentName(surgeonId)) - .replace("%extra%", extra) - .replace("%remaining%", Durations.formatHours(remaining))); + if (isCurrent(patient, outcome.patientSession())) { + patient.sendMessage(uiUpdater.getMessage("patient-surgery-failed") + .replace("%ailment%", ailmentName(outcome)) + .replace("%extra%", extra) + .replace("%remaining%", Durations.formatHours(remaining))); + } return extra; } - // ============================================== - // Closes the menu on the next tick; calling closeInventory directly inside - // an InventoryClickEvent handler is undefined behavior per the Bukkit docs - // ============================================== - private void closeMenuNextTick(Player player) { - Bukkit.getScheduler().runTask(plugin, (Runnable) player::closeInventory); - } - - // ============================================== - // Handles the surgeon closing the menu before finishing - // Stopping before the patient is sedated or cut does no harm; after that, the surgery fails - // ============================================== - public void handleAbandonment(Player player) { - UUID playerId = player.getUniqueId(); - if (!stateManager.hasSession(playerId)) { + private void reportFailure(Player surgeon, Outcome outcome, String message, String extra) { + if (!isCurrent(surgeon, outcome.surgeonSession())) { return; } - if (stateManager.hasOperated(playerId)) { - failSurgery(player, uiUpdater.getMessage("failure-gave-up")); - } else { - stateManager.cleanup(playerId); - player.sendMessage(uiUpdater.getMessage("surgery-cancelled")); + surgeon.playSound(surgeon.getLocation(), Sound.ENTITY_WITHER_DEATH, 1.0f, 0.8f); + surgeon.sendMessage(uiUpdater.getMessage("surgery-failed")); + surgeon.sendMessage(message); + if (extra != null) { + surgeon.sendMessage(uiUpdater.getMessage("surgery-failed-penalty").replace("%extra%", extra)); } } - // ============================================== - // Handles the surgeon disconnecting mid-surgery - // Runs the failure command and penalty once treatment has begun (no - // free escape by logging out) but skips the surgeon's messages, sounds, and - // menu close, which are pointless for a quitting player - // ============================================== - public void handleQuit(Player player) { - UUID playerId = player.getUniqueId(); - if (stateManager.hasOperated(playerId)) { - executeCompletionCommand(player, false); - lengthenHealing(player); + private Inventory surgeryMenu(Player surgeon, Outcome outcome) { + Inventory menu = surgeon.getOpenInventory().getTopInventory(); + return menu.getHolder() instanceof SurgeryMenuHolder holder && holder.matches(surgeon, outcome.operationId()) + ? menu : null; + } + + /** Defer closing out of click handlers, retaining the exact operation's inventory and actor. */ + private void closeMenuNextTick(Player surgeon, Outcome outcome, Inventory menu) { + if (menu == null || !plugin.isEnabled() || !isCurrent(surgeon, outcome.surgeonSession())) { + return; } - stateManager.cleanup(playerId); + Bukkit.getScheduler().runTask(plugin, () -> { + if (plugin.isEnabled() && isCurrent(surgeon, outcome.surgeonSession()) + && surgeon.getOpenInventory().getTopInventory() == menu) { + surgeon.closeInventory(); + } + }); } - // ============================================== - // The patient, or null once they have left or their character was - // switched away or permakilled, so a cure or penalty never lands on - // a different character - // ============================================== - private Player patientOf(UUID surgeonId) { - UUID patientId = stateManager.getPatientUuid(surgeonId); - Player patient = patientId == null ? null : Bukkit.getPlayer(patientId); - if (patient == null || !stateManager.isOriginalPatient(surgeonId, Ailments.characterId(patient))) { + private Player currentPatient(Outcome outcome) { + CharacterSession token = outcome.patientSession(); + if (token == null) { return null; } - return patient; + Player patient = Bukkit.getPlayer(token.ref().ownerId()); + return patient != null && isCurrent(patient, token) ? patient : null; } - // ============================================== - // Whether both participants are still the characters the operation began - // on; a patient who logged off counts as unchanged - // ============================================== - private boolean sameCharacters(Player surgeon) { - UUID surgeonId = surgeon.getUniqueId(); - if (!stateManager.isOriginalSurgeon(surgeonId, Ailments.characterId(surgeon))) { - return false; - } - UUID patientId = stateManager.getPatientUuid(surgeonId); - Player patient = patientId == null ? null : Bukkit.getPlayer(patientId); - return patient == null - ? !stateManager.hasPatientChanged(surgeonId) - : stateManager.isOriginalPatient(surgeonId, Ailments.characterId(patient)); + private boolean isCurrent(Player player, CharacterSession token) { + return token != null && token.ref().ownerId().equals(player.getUniqueId()) && player.isOnline() + && Bukkit.getPlayer(player.getUniqueId()) == player && Characters.isCurrent(token); } - // ============================================== - // Executes the configured command for surgery completion (success or failure) - // Commands name accounts, so they are skipped once either participant has - // changed character; they would land on whoever is now active - // ============================================== - private void executeCompletionCommand(Player surgeon, boolean success) { - UUID surgeonId = surgeon.getUniqueId(); - if (!sameCharacters(surgeon)) { + private void executeCompletionCommand(Player surgeon, Outcome outcome, boolean success) { + if (!isCurrent(surgeon, outcome.surgeonSession()) || currentPatient(outcome) == null) { return; } - String patientName = stateManager.getPatientName(surgeonId); - - String configKey = success ? "commands.surgery-success" : "commands.surgery-failure"; - String command = plugin.getConfig().getString(configKey, ""); - + String key = success ? "commands.surgery-success" : "commands.surgery-failure"; + String command = plugin.getConfig().getString(key, ""); if (command != null && !command.isEmpty()) { - command = command.replace("%surgeon%", surgeon.getName()); - command = command.replace("%player%", patientName); - command = command.replace("%ailment%", stateManager.getAilmentName(surgeonId)); - + command = command.replace("%surgeon%", surgeon.getName()) + .replace("%player%", patientName(outcome)).replace("%ailment%", ailmentName(outcome)); Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); } } + + private String patientName(Outcome outcome) { + return outcome.patientName() == null ? "Unknown" : outcome.patientName(); + } + + private String ailmentName(Outcome outcome) { + return outcome.ailmentName() == null ? "Unknown ailment" : outcome.ailmentName(); + } } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java index 2cfc49a..6fd7680 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java @@ -71,6 +71,12 @@ public void handleItemClick(Player player, ItemStack clickedItem, int slot) { } UUID playerId = player.getUniqueId(); + if (!stateManager.isCurrent(playerId)) { + completionHandler.failSurgery(player, uiUpdater.getMessage("failure-patient-left", + "&cThe patient is no longer on the operating table!")); + return; + } + long operation = stateManager.getOperationId(playerId); // Special check for scalpel - cannot be used when patient is awake if (tool == SurgeryTool.SCALPEL && stateManager.getStatus(playerId).equals("Awake")) { @@ -86,7 +92,7 @@ public void handleItemClick(Player player, ItemStack clickedItem, int slot) { // Move effects may have ended the surgery (death timers, fever, bleed-out); // the session is cleaned up and the menu is closed, so stop here - if (!stateManager.hasSession(playerId)) { + if (stateManager.getOperationId(playerId) != operation) { return; } @@ -95,7 +101,7 @@ public void handleItemClick(Player player, ItemStack clickedItem, int slot) { // Tool handling can also end the operation (for example, chloroform // misuse). Completion owns the final feedback once the session ends. - if (!stateManager.hasSession(playerId)) { + if (stateManager.getOperationId(playerId) != operation) { return; } @@ -119,6 +125,7 @@ public void handleItemClick(Player player, ItemStack clickedItem, int slot) { private void updateMenu(Player player, SurgeryTool tool) { Inventory menu = player.getOpenInventory().getTopInventory(); UUID playerId = player.getUniqueId(); + long operation = stateManager.getOperationId(playerId); boolean skillFail = isSkillFail(playerId); // Consume the previous sponge's protection after this move's roll. @@ -143,7 +150,7 @@ private void updateMenu(Player player, SurgeryTool tool) { }; // Failing to use chloroform correctly can end the surgery outright - if (!stateManager.hasSession(playerId)) { + if (stateManager.getOperationId(playerId) != operation) { return; } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java index 8a2ec1d..ec99afb 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java @@ -1,6 +1,5 @@ package net.tfminecraft.surgery.managers; -import net.tfminecraft.surgery.procedures.Ailments; import net.tfminecraft.surgery.procedures.Complication; import net.tfminecraft.surgery.procedures.Procedure; import net.tfminecraft.tlibs.objects.api.ItemAPI; @@ -189,16 +188,13 @@ public void processMoveEffects(Player player, SurgeryTool clickedTool) { // ============================================== private boolean isPatientPresent(Player surgeon) { UUID patientUuid = stateManager.getPatientUuid(surgeon.getUniqueId()); - if (patientUuid == null) { - return true; + if (patientUuid == null || !stateManager.isCurrent(surgeon.getUniqueId())) { + return false; } Player patient = Bukkit.getPlayer(patientUuid); if (patient == null || !patient.isOnline() || patient.isDead()) { return false; } - if (!stateManager.isOriginalPatient(surgeon.getUniqueId(), Ailments.characterId(patient))) { - return false; - } if (!patient.getWorld().equals(surgeon.getWorld())) { return false; } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuBuilder.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuBuilder.java index 2f4f4e0..b19a60f 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuBuilder.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuBuilder.java @@ -34,7 +34,7 @@ public SurgeryMenuBuilder(JavaPlugin plugin, ItemAPI api, SurgeryStateManager st // Builds and opens the surgery menu for a player // ============================================== public void buildAndOpenMenu(Player player) { - Inventory menu = new SurgeryMenuHolder().getInventory(); + Inventory menu = new SurgeryMenuHolder(player.getUniqueId(), stateManager.getOperationId(player.getUniqueId())).getInventory(); // Tools on the table from the start; the tincture appears after the // thermometer, and the dressing, smelling salts, silver wire, splint, diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuHolder.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuHolder.java index eac340b..954c95b 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuHolder.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuHolder.java @@ -1,26 +1,27 @@ package net.tfminecraft.surgery.managers; +import java.util.UUID; import org.bukkit.Bukkit; +import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; -// ============================================== -// Marker holder for the surgery menu inventory -// Lets listeners identify the menu by holder type instead of matching the -// title string, which any other inventory could share -// ============================================== +/** Identifies one operation, so an old close or click cannot reach a replacement menu. */ public class SurgeryMenuHolder implements InventoryHolder { - private final Inventory inventory; + private final UUID surgeonId; + private final long operationId; - // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - public SurgeryMenuHolder() { + public SurgeryMenuHolder(UUID surgeonId, long operationId) { + this.surgeonId = surgeonId; + this.operationId = operationId; this.inventory = Bukkit.createInventory(this, 54, "Surgery Menu"); } - @Override - public Inventory getInventory() { - return inventory; + public boolean matches(Player player, long currentOperation) { + return operationId != 0 && operationId == currentOperation && surgeonId.equals(player.getUniqueId()); } + + @Override public Inventory getInventory() { return inventory; } } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java index 62d6cd3..9c641f1 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java @@ -1,7 +1,9 @@ package net.tfminecraft.surgery.managers; import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; -import net.tfminecraft.surgery.procedures.Ailments; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; import net.tfminecraft.surgery.procedures.ProcedureRegistry; import net.tfminecraft.tlibs.objects.api.ItemAPI; import net.tfminecraft.tlibs.TLibs; @@ -9,7 +11,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.java.JavaPlugin; -import java.util.Objects; +import org.bukkit.inventory.Inventory; import java.util.UUID; // ============================================== @@ -17,11 +19,11 @@ // Delegates manager files for different responsibilities // ============================================== public class SurgeryMenuManager { - + private final JavaPlugin plugin; private final SurgeryItemsConfig itemsConfig; private ItemAPI api; - + // Specialized managers private final ProcedureRegistry procedures = new ProcedureRegistry(); private final SurgeryRequestManager requestManager = new SurgeryRequestManager(); @@ -31,12 +33,12 @@ public class SurgeryMenuManager { private SurgeryCompletionHandler completionHandler; private SurgeryItemHandler itemHandler; private SurgeryMechanicsManager mechanicsManager; - + public SurgeryMenuManager(JavaPlugin plugin, SurgeryItemsConfig itemsConfig) { this.plugin = plugin; this.itemsConfig = itemsConfig; } - + // ============================================== // Initializes all managers and loads TLibs API // ============================================== @@ -57,128 +59,100 @@ public void initialize() { mechanicsManager = new SurgeryMechanicsManager(plugin, api, stateManager, uiUpdater, completionHandler, itemsConfig); menuBuilder = new SurgeryMenuBuilder(plugin, api, stateManager, uiUpdater, itemsConfig); itemHandler = new SurgeryItemHandler(plugin, api, stateManager, uiUpdater, mechanicsManager, completionHandler, itemsConfig); - + // Initialize any managers that need config itemHandler.initialize(); - + plugin.getLogger().info("[Surgery] Surgery menu manager initialized!"); } - + // ============================================== // Opens the surgery menu for the surgeon, operating on the patient's ailment // ============================================== public void openSurgeryMenu(Player surgeon, Player patient, HealingInjury ailment) { + CharacterSession surgeonSession = Characters.currentSession(surgeon).orElse(null); + CharacterSession patientSession = Characters.currentSession(patient).orElse(null); + if (surgeonSession == null || patientSession == null || stateManager.hasSession(surgeon.getUniqueId()) + || stateManager.isPatientInSurgery(patient.getUniqueId())) { + return; + } stateManager.setPatientName(surgeon.getUniqueId(), patient.getName()); stateManager.setPatientUuid(surgeon.getUniqueId(), patient.getUniqueId()); - stateManager.setSurgeonCharacterId(surgeon.getUniqueId(), Ailments.characterId(surgeon)); - stateManager.setPatientCharacterId(surgeon.getUniqueId(), Ailments.characterId(patient)); + stateManager.setSurgeonSession(surgeon.getUniqueId(), surgeonSession); + stateManager.setPatientSession(surgeon.getUniqueId(), patientSession); stateManager.setAilment(surgeon.getUniqueId(), ailment.traitId(), ailment.displayName(), procedures.get(ailment.traitId())); menuBuilder.buildAndOpenMenu(surgeon); } - + // ============================================== // Checks if an inventory is the surgery menu (by holder, not title) // ============================================== public boolean isSurgeryMenu(org.bukkit.inventory.Inventory inventory) { return inventory != null && inventory.getHolder() instanceof SurgeryMenuHolder; } - + // ============================================== // Handles item clicks within the surgery menu // Delegates to the item handler // ============================================== - public void handleItemClick(Player player, ItemStack clickedItem, int slot) { - itemHandler.handleItemClick(player, clickedItem, slot); - } - - // ============================================== - // Handles menu abandonment (player closed menu early) - // Called from PlayerListener - // ============================================== - public void handleSurgeryAbandonment(Player player) { - completionHandler.handleAbandonment(player); - } - - // ============================================== - // Handles the surgeon quitting the server - // Called from PlayerListener; fails an in-progress surgery and cleans up - // ============================================== - public void handleSurgeonQuit(Player player) { - completionHandler.handleQuit(player); + public void handleItemClick(Player player, Inventory inventory, ItemStack clickedItem, int slot) { + if (ownsMenu(player, inventory)) { + itemHandler.handleItemClick(player, clickedItem, slot); + } } - // ============================================== - // Handles the patient quitting the server - // Fails the surgeon's surgery immediately instead of waiting for - // their next move to notice the patient is gone - // ============================================== - public void handlePatientQuit(Player patient) { - UUID surgeonId = stateManager.findSurgeonForPatient(patient.getUniqueId()); - if (surgeonId == null) { - return; - } - failForMissingPatient(surgeonId); + private boolean ownsMenu(Player player, Inventory inventory) { + return inventory.getHolder() instanceof SurgeryMenuHolder holder + && holder.matches(player, stateManager.getOperationId(player.getUniqueId())); } // ============================================== - // Handles a player's active character changing - // A surgeon who changes character gives up; a patient who changes - // character has left the table, the same as quitting + // Handles menu abandonment (player closed menu early) + // Called from PlayerListener // ============================================== - public void handleCharacterActivated(UUID ownerId, String characterId) { - if (stateManager.hasSession(ownerId)) { - endForSurgeonChange(ownerId); - } - UUID surgeonId = stateManager.findSurgeonForPatient(ownerId); - if (surgeonId != null && !Objects.equals(characterId, stateManager.getPatientCharacterId(surgeonId))) { - endForPatientChange(surgeonId); + public void handleSurgeryAbandonment(Player player, Inventory inventory) { + if (ownsMenu(player, inventory)) { + completionHandler.handleAbandonment(player); } } - // ============================================== - // Handles a character being permakilled - // Ends any operation that character is part of, including when it was - // the account's last character and no other character takes over - // ============================================== - public void handleCharacterPermakilled(UUID ownerId, String characterId) { - if (stateManager.hasSession(ownerId) && Objects.equals(characterId, stateManager.getSurgeonCharacterId(ownerId))) { - endForSurgeonChange(ownerId); - } - UUID surgeonId = stateManager.findSurgeonForPatient(ownerId); - if (surgeonId != null && Objects.equals(characterId, stateManager.getPatientCharacterId(surgeonId))) { - endForPatientChange(surgeonId); + public void handleSessionEnd(CharacterSessionEndEvent event) { + CharacterSession ending = event.getSession(); + UUID owner = ending.ref().ownerId(); + requestManager.forget(ending); + endOperation(owner, ending, event.getReason()); + UUID surgeon = stateManager.findSurgeonForPatient(owner); + if (surgeon != null) { + endOperation(surgeon, ending, event.getReason()); } } - // The surgeon gives up; an unchanged patient still takes the failure penalty - private void endForSurgeonChange(UUID surgeonId) { - stateManager.markSurgeonChanged(surgeonId); + private void endOperation(UUID surgeonId, CharacterSession ending, CharacterSessionEndEvent.Reason reason) { + if (!ending.equals(stateManager.getSurgeonSession(surgeonId)) + && !ending.equals(stateManager.getPatientSession(surgeonId))) { + return; + } Player surgeon = plugin.getServer().getPlayer(surgeonId); if (surgeon == null) { stateManager.cleanup(surgeonId); return; } - completionHandler.handleAbandonment(surgeon); - surgeon.closeInventory(); - } - - // The patient's character is gone, so nothing lands on the account - private void endForPatientChange(UUID surgeonId) { - stateManager.markPatientChanged(surgeonId); - failForMissingPatient(surgeonId); + Inventory inventory = surgeon.getOpenInventory().getTopInventory(); + boolean close = ownsMenu(surgeon, inventory); + completionHandler.handleEnd(surgeon, ending, reason); + if (close && surgeon.getOpenInventory().getTopInventory() == inventory) { + surgeon.closeInventory(); + } } - private void failForMissingPatient(UUID surgeonId) { - Player surgeon = plugin.getServer().getPlayer(surgeonId); - if (surgeon != null && surgeon.isOnline()) { - completionHandler.failSurgery(surgeon, uiUpdater.getMessage("failure-patient-left", - "&cThe patient is no longer on the operating table!")); - } else { - stateManager.cleanup(surgeonId); + public void shutdown() { + requestManager.clear(); + if (stateManager != null) { + stateManager.clear(); } } - + // ============================================== // Getters for accessing individual managers // ============================================== diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryRequestManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryRequestManager.java index 4b2aa07..446e55b 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryRequestManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryRequestManager.java @@ -2,21 +2,45 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.UUID; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; + // ============================================== // Pending surgery offers, keyed by patient. A failed surgery lengthens the // patient's healing time, so nobody is operated on without agreeing to it // ============================================== public class SurgeryRequestManager { - public record Request(UUID surgeonId, String traitId, long expiresAt) { + public record Request(CharacterSession surgeonSession, CharacterSession patientSession, + String traitId, long expiresAt) { + public Request { + Objects.requireNonNull(surgeonSession, "surgeonSession"); + Objects.requireNonNull(patientSession, "patientSession"); + } + + public UUID surgeonId() { + return surgeonSession.ref().ownerId(); + } + + public boolean isCurrent() { + return Characters.isCurrent(surgeonSession) && Characters.isCurrent(patientSession); + } } private final Map requests = new HashMap<>(); - public void offer(UUID patientId, UUID surgeonId, String traitId, long expiresAt) { - requests.put(patientId, new Request(surgeonId, traitId, expiresAt)); + public void offer(CharacterSession patientSession, CharacterSession surgeonSession, + String traitId, long expiresAt) { + if (patientSession == null || surgeonSession == null) { + return; + } + Request request = new Request(surgeonSession, patientSession, traitId, expiresAt); + if (request.isCurrent()) { + requests.put(patientSession.ref().ownerId(), request); + } } // ============================================== @@ -27,7 +51,7 @@ public Request pending(UUID patientId, long now) { if (request == null) { return null; } - if (request.expiresAt() < now) { + if (request.expiresAt() < now || !request.isCurrent()) { requests.remove(patientId); return null; } @@ -39,14 +63,16 @@ public Request pending(UUID patientId, long now) { // ============================================== public Request take(UUID patientId, long now) { Request request = requests.remove(patientId); - return request == null || request.expiresAt() < now ? null : request; + return request == null || request.expiresAt() < now || !request.isCurrent() ? null : request; } - // ============================================== - // Drops every request the player sent or received (used on quit) - // ============================================== - public void forget(UUID playerId) { - requests.remove(playerId); - requests.values().removeIf(request -> request.surgeonId().equals(playerId)); + /** Drop only consent involving the ending session; a late end cannot erase a new offer. */ + public void forget(CharacterSession ending) { + requests.values().removeIf(request -> ending.equals(request.surgeonSession()) + || ending.equals(request.patientSession())); + } + + public void clear() { + requests.clear(); } } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java index a4ef438..010abb9 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java @@ -4,7 +4,8 @@ import java.util.HashMap; import java.util.Map; -import java.util.Objects; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; import java.util.UUID; // ============================================== @@ -20,12 +21,9 @@ public class SurgeryStateManager { private static class SurgerySession { String patientName; UUID patientUuid; - // Both participants' characters when the operation began - String surgeonCharacterId; - String patientCharacterId; - // Set once that participant's character was switched away or permakilled - Boolean surgeonChanged; - Boolean patientChanged; + long operationId; + CharacterSession surgeonSession; + CharacterSession patientSession; String traitId; String ailmentName; Procedure procedure; @@ -56,9 +54,29 @@ private static class SurgerySession { } private final Map sessions = new HashMap<>(); + private long nextOperationId; + + public record Outcome(long operationId, CharacterSession surgeonSession, CharacterSession patientSession, + String patientName, String traitId, String ailmentName, boolean operated) {} + + /** Detach before calling external code, which may synchronously end or start another operation. */ + public Outcome finish(UUID playerId) { + SurgerySession s = sessions.remove(playerId); + return s == null ? null : new Outcome(s.operationId, s.surgeonSession, s.patientSession, + (s.patientName == null ? "Unknown" : s.patientName), s.traitId, + (s.ailmentName == null ? "Unknown ailment" : s.ailmentName), Boolean.TRUE.equals(s.operated)); + } + + public void clear() { + sessions.clear(); + } private SurgerySession session(UUID playerId) { - return sessions.computeIfAbsent(playerId, k -> new SurgerySession()); + return sessions.computeIfAbsent(playerId, k -> { + SurgerySession created = new SurgerySession(); + created.operationId = ++nextOperationId; + return created; + }); } private SurgerySession get(UUID playerId) { @@ -96,8 +114,9 @@ private SurgerySession get(UUID playerId) { public int getRedTempCounter(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.redTempCounter == null ? 0 : s.redTempCounter; } public String getPatientName(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.patientName == null ? "Unknown" : s.patientName; } public UUID getPatientUuid(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.patientUuid; } - public String getPatientCharacterId(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.patientCharacterId; } - public String getSurgeonCharacterId(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.surgeonCharacterId; } + public CharacterSession getPatientSession(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.patientSession; } + public CharacterSession getSurgeonSession(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.surgeonSession; } + public long getOperationId(UUID playerId) { SurgerySession s = get(playerId); return s == null ? 0L : s.operationId; } public boolean hasSession(UUID playerId) { return sessions.containsKey(playerId); } // ============================================== @@ -116,21 +135,19 @@ public UUID findSurgeonForPatient(UUID patientUuid) { return null; } - // ============================================== - // Whether a participant is still the character the operation began on, - // given their current character ID - // ============================================== - public boolean isOriginalSurgeon(UUID playerId, String characterId) { - SurgerySession s = get(playerId); - return s != null && !Boolean.TRUE.equals(s.surgeonChanged) && Objects.equals(s.surgeonCharacterId, characterId); + public boolean isOriginalSurgeon(UUID playerId) { + CharacterSession session = getSurgeonSession(playerId); + return session != null && Characters.isCurrent(session); } - public boolean isOriginalPatient(UUID playerId, String characterId) { - SurgerySession s = get(playerId); - return s != null && !Boolean.TRUE.equals(s.patientChanged) && Objects.equals(s.patientCharacterId, characterId); + public boolean isOriginalPatient(UUID playerId) { + CharacterSession session = getPatientSession(playerId); + return session != null && Characters.isCurrent(session); } - public boolean hasPatientChanged(UUID playerId) { SurgerySession s = get(playerId); return s != null && Boolean.TRUE.equals(s.patientChanged); } + public boolean isCurrent(UUID playerId) { + return isOriginalSurgeon(playerId) && isOriginalPatient(playerId); + } // ============================================== // Setters @@ -166,10 +183,8 @@ public void setAilment(UUID playerId, String traitId, String ailmentName, Proced public void setRedTempCounter(UUID playerId, int count) { session(playerId).redTempCounter = count; } public void setPatientName(UUID playerId, String name) { session(playerId).patientName = name; } public void setPatientUuid(UUID playerId, UUID patientUuid) { session(playerId).patientUuid = patientUuid; } - public void setPatientCharacterId(UUID playerId, String characterId) { session(playerId).patientCharacterId = characterId; } - public void setSurgeonCharacterId(UUID playerId, String characterId) { session(playerId).surgeonCharacterId = characterId; } - public void markSurgeonChanged(UUID playerId) { SurgerySession s = get(playerId); if (s != null) { s.surgeonChanged = true; } } - public void markPatientChanged(UUID playerId) { SurgerySession s = get(playerId); if (s != null) { s.patientChanged = true; } } + public void setPatientSession(UUID playerId, CharacterSession token) { session(playerId).patientSession = token; } + public void setSurgeonSession(UUID playerId, CharacterSession token) { session(playerId).surgeonSession = token; } public void removeCollapseCountdown(UUID playerId) { SurgerySession s = get(playerId); if (s != null) { s.collapseCountdown = null; } } // ============================================== diff --git a/src/main/java/net/tfminecraft/surgery/procedures/Ailments.java b/src/main/java/net/tfminecraft/surgery/procedures/Ailments.java index 1a0e407..b35f955 100644 --- a/src/main/java/net/tfminecraft/surgery/procedures/Ailments.java +++ b/src/main/java/net/tfminecraft/surgery/procedures/Ailments.java @@ -2,9 +2,6 @@ import net.tfminecraft.rpcharacters.api.HealingInjuries; import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; -import net.tfminecraft.rpcharacters.managers.PlayerManager; -import net.tfminecraft.rpcharacters.objects.PlayerData; -import net.tfminecraft.rpcharacters.objects.RPCharacter; import org.bukkit.entity.Player; import java.util.Comparator; @@ -45,10 +42,4 @@ public static long extend(Player patient, String traitId, long extraMs) { return HealingInjuries.extend(patient, traitId, extraMs); } - // The patient's active character ID, or null without one - public static String characterId(Player patient) { - PlayerData data = PlayerManager.get(patient); - RPCharacter character = data == null ? null : data.getActiveCharacter(); - return character == null ? null : character.getId(); - } } diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 1dfd5ef..87cc4d4 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -263,3 +263,5 @@ skill-fail-transfusion: - "The brass syringe jammed." - "The needle came out of the vein." - "You drew blood out instead of putting it in!" + +command-character-required: "&cBoth characters must be ready before surgery." diff --git a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java index 1d9c986..2c1401b 100644 --- a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java +++ b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java @@ -1,27 +1,30 @@ package net.tfminecraft.surgery; import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; -import net.tfminecraft.rpcharacters.lifecycle.CharacterActivatedEvent; -import net.tfminecraft.rpcharacters.objects.RPCharacter; -import net.tfminecraft.rpcharacters.permadeath.CharacterPermakillEvent; -import net.tfminecraft.rpcharacters.permadeath.PermakillCause; import net.tfminecraft.surgery.listeners.PlayerListener; import net.tfminecraft.surgery.managers.*; import net.tfminecraft.surgery.procedures.Ailments; import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent.Reason; +import net.tfminecraft.tlibs.character.Characters; import net.tfminecraft.tlibs.objects.api.ItemAPI; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; import org.bukkit.event.inventory.*; -import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockbukkit.mockbukkit.MockBukkit; import org.mockbukkit.mockbukkit.ServerMock; import org.mockbukkit.mockbukkit.entity.PlayerMock; @@ -35,12 +38,14 @@ class SurgeryLifecycleTest { private ServerMock server; private MockedStatic tlibs; + private TestCharacters characters; @BeforeEach void setUp() { server = MockBukkit.mock(); MockBukkit.createMockPlugin("TLibs"); MockBukkit.createMockPlugin("RPCharacters"); + characters = new TestCharacters(); ItemAPI api = mock(ItemAPI.class, RETURNS_DEEP_STUBS); when(api.getCreator().getItemFromPath(anyString())).thenReturn(new ItemStack(Material.PAPER)); tlibs = mockStatic(TLibs.class); @@ -50,11 +55,12 @@ void setUp() { @AfterEach void tearDown() throws Exception { try { - // MockBukkit leaves the top inventory null after closeInventory; - // the live server restores the player's crafting inventory. + if (SurgeryPlugin.getInstance() != null) HandlerList.unregisterAll(SurgeryPlugin.getInstance()); + // Restore MockBukkit's null post-close top inventories before plugin shutdown. for (Player player : server.getOnlinePlayers()) { player.openInventory(server.createInventory(null, 9)); } + characters.close(); MockBukkit.unmock(); } finally { if (tlibs != null) tlibs.close(); @@ -70,8 +76,18 @@ private SurgeryMenuManager manager(SurgeryPlugin plugin) throws Exception { return (SurgeryMenuManager) field.get(plugin); } + private PlayerMock readyPlayer() { + PlayerMock player = server.addPlayer(); + characters.activate(player); + return player; + } + + private void open(SurgeryMenuManager menus, Player surgeon, Player patient) { + menus.openSurgeryMenu(surgeon, patient, new HealingInjury("leg", "Leg", 1_000)); + } + @Test - void enablesManagersRegistersCommandAndClosesOnlySurgeryMenusOnShutdown() throws Exception { + void enablesManagersRegistersCommandAndClearsOperationsAndOffersOnShutdown() throws Exception { SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); assertSame(plugin, SurgeryPlugin.getInstance()); assertTrue(plugin.isEnabled()); @@ -85,149 +101,186 @@ void enablesManagersRegistersCommandAndClosesOnlySurgeryMenusOnShutdown() throws assertNotNull(menus.getCompletionHandler()); assertNotNull(menus.getMechanicsManager()); assertNotNull(menus.getItemHandler()); - PlayerMock surgeon = server.addPlayer(), patient = server.addPlayer(), other = server.addPlayer(); + PlayerMock surgeon = readyPlayer(), patient = readyPlayer(), other = readyPlayer(); patient.openInventory(server.createInventory(null, 9)); Inventory ordinary = server.createInventory(null, 9); other.openInventory(ordinary); - menus.openSurgeryMenu(surgeon, patient, new HealingInjury("leg", "Leg", 1_000)); + open(menus, surgeon, patient); + CharacterSession surgeonSession = Characters.currentSession(surgeon).orElseThrow(); + CharacterSession otherSession = Characters.currentSession(other).orElseThrow(); + menus.getRequestManager().offer(otherSession, surgeonSession, "arm", Long.MAX_VALUE); assertEquals(patient.getUniqueId(), menus.getStateManager().getPatientUuid(surgeon.getUniqueId())); assertEquals("leg", menus.getStateManager().getTraitId(surgeon.getUniqueId())); - assertTrue(menus.isSurgeryMenu(surgeon.getOpenInventory().getTopInventory())); + Inventory surgery = surgeon.getOpenInventory().getTopInventory(); + assertTrue(menus.isSurgeryMenu(surgery)); assertFalse(menus.isSurgeryMenu(null)); assertFalse(menus.isSurgeryMenu(ordinary)); - menus.handleItemClick(surgeon, null, 0); + menus.handleItemClick(surgeon, surgery, null, 0); plugin.onDisable(); assertFalse(menus.isSurgeryMenu(surgeon.getOpenInventory().getTopInventory())); assertSame(ordinary, other.getOpenInventory().getTopInventory()); + assertFalse(menus.getStateManager().hasSession(surgeon.getUniqueId())); + assertNull(menus.getRequestManager().pending(other.getUniqueId(), 0)); + } + + @Test + void missingCharacterApiDisablesBeforeInitializationWithAnUpgradeMessage() { + SurgeryPlugin startup = mock(SurgeryPlugin.class); + org.bukkit.Server startupServer = mock(org.bukkit.Server.class); + org.bukkit.plugin.PluginManager plugins = mock(org.bukkit.plugin.PluginManager.class); + java.util.logging.Logger logger = mock(java.util.logging.Logger.class); + when(startup.getServer()).thenReturn(startupServer); + when(startupServer.getPluginManager()).thenReturn(plugins); + when(startup.getLogger()).thenReturn(logger); + doCallRealMethod().when(startup).onEnable(); + try (var unavailable = mockStatic(Characters.class)) { + unavailable.when(Characters::available).thenThrow(new NoClassDefFoundError("CharacterIdentity")); + startup.onEnable(); + verify(plugins).disablePlugin(startup); + verify(logger).severe("Surgery requires TLibs 2.3.0 or newer. Update TLibs and restart the server."); + verify(startup, never()).saveDefaultConfig(); + verify(startup, never()).getCommand(anyString()); + } } @Test - void missingSharedApiDisablesPluginBeforeCommandRegistration() { + void missingSharedItemApiDisablesPluginBeforeCommandRegistration() { tlibs.when(TLibs::getItemAPI).thenReturn(null); SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); assertFalse(plugin.isEnabled()); } @Test - void managerDelegatesAbandonmentAndDisconnectsWithoutLeakingSessions() throws Exception { + void menusWaitForBothReadyCharactersAndNeverUseProviderAbsenceAsReadiness() throws Exception { SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); SurgeryMenuManager menus = manager(plugin); - SurgeryStateManager state = menus.getStateManager(); PlayerMock surgeon = server.addPlayer(), patient = server.addPlayer(); - menus.handlePatientQuit(patient); - state.setPatientName(surgeon.getUniqueId(), "Patient"); - menus.handleSurgeryAbandonment(surgeon); - assertFalse(state.hasSession(surgeon.getUniqueId())); - state.setPatientName(surgeon.getUniqueId(), "Patient"); - menus.handleSurgeonQuit(surgeon); - assertFalse(state.hasSession(surgeon.getUniqueId())); - state.setPatientUuid(surgeon.getUniqueId(), patient.getUniqueId()); - menus.handlePatientQuit(patient); - assertFalse(state.hasSession(surgeon.getUniqueId())); - UUID missingSurgeon = UUID.randomUUID(); - state.setPatientUuid(missingSurgeon, patient.getUniqueId()); - menus.handlePatientQuit(patient); - assertFalse(state.hasSession(missingSurgeon)); - server.getScheduler().performOneTick(); + assertTrue(plugin.isEnabled()); + open(menus, surgeon, patient); + assertFalse(menus.getStateManager().hasSession(surgeon.getUniqueId())); + characters.activate(surgeon); + open(menus, surgeon, patient); + assertFalse(menus.getStateManager().hasSession(surgeon.getUniqueId())); + characters.activate(patient); + characters.unregister(); + open(menus, surgeon, patient); + assertFalse(menus.getStateManager().hasSession(surgeon.getUniqueId())); } - @Test - void characterChangeEndsTheOperationForSurgeonOrSwitchedPatient() throws Exception { + @ParameterizedTest + @CsvSource({"patient,QUIT,true", "patient,SWITCH,false", "patient,DEATH,false", "patient,SHUTDOWN,false", + "surgeon,QUIT,true", "surgeon,SWITCH,true", "surgeon,DEATH,true", "surgeon,SHUTDOWN,false"}) + void sessionEndUsesOutgoingIdentityAndAppliesOnlyTheAllowedPenalty(String participant, Reason reason, + boolean penalized) throws Exception { SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); + plugin.getConfig().set("commands.surgery-failure", "damage %player% 1"); SurgeryMenuManager menus = manager(plugin); - SurgeryStateManager state = menus.getStateManager(); - PlayerMock surgeon = server.addPlayer(), patient = server.addPlayer(); - menus.openSurgeryMenu(surgeon, patient, new HealingInjury("leg", "Leg", 1_000)); - assertNull(state.getPatientCharacterId(surgeon.getUniqueId())); - menus.handleCharacterActivated(patient.getUniqueId(), null); - assertTrue(state.hasSession(surgeon.getUniqueId())); - menus.handleCharacterActivated(patient.getUniqueId(), "other"); - assertFalse(state.hasSession(surgeon.getUniqueId())); - surgeon.assertSaid(menus.getUiUpdater().getMessage("surgery-failed")); - server.getScheduler().performOneTick(); + PlayerMock surgeon = readyPlayer(), patient = readyPlayer(); + try (MockedStatic ailments = mockStatic(Ailments.class); + MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { + ailments.when(() -> Ailments.extend(any(), anyString(), anyLong())).thenReturn(1L); + open(menus, surgeon, patient); + menus.getStateManager().setOperated(surgeon.getUniqueId(), true); + Player endingPlayer = participant.equals("patient") ? patient : surgeon; + CharacterSession ending = characters.invalidate(endingPlayer); + assertFalse(Characters.isCurrent(ending)); + CharacterSessionEndEvent event = new CharacterSessionEndEvent(endingPlayer, ending, reason); + server.getPluginManager().callEvent(event); + assertFalse(menus.getStateManager().hasSession(surgeon.getUniqueId())); + assertFalse(menus.isSurgeryMenu(surgeon.getOpenInventory().getTopInventory())); + // Duplicate delivery must not apply another penalty. + server.getPluginManager().callEvent(event); + ailments.verify(() -> Ailments.extend(patient, "leg", 12 * 3_600_000L), times(penalized ? 1 : 0)); + bukkit.verify(() -> Bukkit.dispatchCommand(any(), anyString()), never()); + server.getScheduler().performOneTick(); + } + } - menus.openSurgeryMenu(surgeon, patient, new HealingInjury("leg", "Leg", 1_000)); - menus.handleCharacterActivated(surgeon.getUniqueId(), "other"); - assertFalse(state.hasSession(surgeon.getUniqueId())); - assertFalse(menus.isSurgeryMenu(surgeon.getOpenInventory().getTopInventory())); - UUID offlineSurgeon = UUID.randomUUID(); - state.setPatientName(offlineSurgeon, "Patient"); - menus.handleCharacterActivated(offlineSurgeon, "other"); - assertFalse(state.hasSession(offlineSurgeon)); + @ParameterizedTest + @ValueSource(strings = {"patient", "surgeon"}) + void lateOldEndCannotRemoveNewOperationOrNewConsentAfterSwitchingBack(String participant) throws Exception { + SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); + SurgeryMenuManager menus = manager(plugin); + PlayerMock surgeon = readyPlayer(), patient = readyPlayer(); + open(menus, surgeon, patient); + Player actor = participant.equals("patient") ? patient : surgeon; + CharacterSession original = characters.invalidate(actor); + CharacterSessionEndEvent oldEnd = new CharacterSessionEndEvent(actor, original, Reason.SWITCH); + server.getPluginManager().callEvent(oldEnd); + characters.activate(actor); + characters.activate(actor, original.ref().characterId()); + open(menus, surgeon, patient); + long operation = menus.getStateManager().getOperationId(surgeon.getUniqueId()); + Inventory currentMenu = surgeon.getOpenInventory().getTopInventory(); + menus.getRequestManager().offer(Characters.currentSession(patient).orElseThrow(), + Characters.currentSession(surgeon).orElseThrow(), "arm", Long.MAX_VALUE); + SurgeryRequestManager.Request request = menus.getRequestManager().pending(patient.getUniqueId(), 0); + assertNotNull(request); + server.getPluginManager().callEvent(oldEnd); server.getScheduler().performOneTick(); - - SurgeryMenuManager mocked = mock(SurgeryMenuManager.class); - RPCharacter character = mock(RPCharacter.class); - when(character.getId()).thenReturn("next"); - new PlayerListener(mocked).onCharacterActivated( - new CharacterActivatedEvent(patient, patient.getUniqueId(), character, null)); - verify(mocked).handleCharacterActivated(patient.getUniqueId(), "next"); + assertEquals(operation, menus.getStateManager().getOperationId(surgeon.getUniqueId())); + assertSame(currentMenu, surgeon.getOpenInventory().getTopInventory()); + assertSame(request, menus.getRequestManager().pending(patient.getUniqueId(), 0)); } - private CharacterPermakillEvent permakill(Player player, String characterId) { - RPCharacter character = mock(RPCharacter.class); - when(character.getId()).thenReturn(characterId); - return new CharacterPermakillEvent(player, character, PermakillCause.OTHER); + @Test + void staleOrForeignMenusCannotClickOrAbandonTheReplacementOperation() throws Exception { + SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); + SurgeryMenuManager menus = manager(plugin); + PlayerMock surgeon = readyPlayer(), patient = readyPlayer(), stranger = readyPlayer(); + open(menus, surgeon, patient); + Inventory oldMenu = surgeon.getOpenInventory().getTopInventory(); + menus.handleSurgeryAbandonment(surgeon, oldMenu); + open(menus, surgeon, patient); + Inventory currentMenu = surgeon.getOpenInventory().getTopInventory(); + long operation = menus.getStateManager().getOperationId(surgeon.getUniqueId()); + menus.handleSurgeryAbandonment(surgeon, oldMenu); + menus.handleItemClick(surgeon, oldMenu, new ItemStack(Material.PAPER), SurgeryTool.SCALPEL.getSlot()); + menus.handleSurgeryAbandonment(stranger, currentMenu); + menus.handleItemClick(surgeon, server.createInventory(null, 9), new ItemStack(Material.PAPER), 0); + assertEquals(operation, menus.getStateManager().getOperationId(surgeon.getUniqueId())); + assertFalse(menus.getStateManager().hasOperated(surgeon.getUniqueId())); + assertSame(currentMenu, surgeon.getOpenInventory().getTopInventory()); + // Neither duplicate opens nor a second surgeon may replace the operation. + open(menus, surgeon, patient); + open(menus, stranger, patient); + assertEquals(operation, menus.getStateManager().getOperationId(surgeon.getUniqueId())); + assertFalse(menus.getStateManager().hasSession(stranger.getUniqueId())); + menus.handleSurgeryAbandonment(surgeon, currentMenu); + assertFalse(menus.getStateManager().hasSession(surgeon.getUniqueId())); } @Test - void permakillEndsOnlyTheDyingCharactersOperation() throws Exception { + void patientEndCleansAnOperationWhoseSurgeonIsAlreadyAbsent() throws Exception { SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); - plugin.getConfig().set("commands.surgery-failure", "damage %player% 1"); SurgeryMenuManager menus = manager(plugin); - SurgeryStateManager state = menus.getStateManager(); - PlayerMock surgeon = server.addPlayer(), patient = server.addPlayer(); - UUID surgeonId = surgeon.getUniqueId(); - HealingInjury leg = new HealingInjury("leg", "Leg", 1_000); - try (MockedStatic ailments = mockStatic(Ailments.class, CALLS_REAL_METHODS); - MockedStatic bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS)) { - ailments.when(() -> Ailments.characterId(patient)).thenReturn("patient"); - ailments.when(() -> Ailments.characterId(surgeon)).thenReturn("doctor"); - ailments.when(() -> Ailments.extend(any(), anyString(), anyLong())).thenReturn(1L); - menus.openSurgeryMenu(surgeon, patient, leg); - assertEquals("doctor", state.getSurgeonCharacterId(surgeonId)); - state.setOperated(surgeonId, true); - - // Another of the patient's characters dying leaves the operation alone - server.getPluginManager().callEvent(permakill(patient, "spare")); - CharacterPermakillEvent cancelled = permakill(patient, "patient"); - cancelled.setCancelled(true); - server.getPluginManager().callEvent(cancelled); - assertTrue(state.hasSession(surgeonId)); - - // The patient's last character dies and nobody takes over - server.getPluginManager().callEvent(permakill(patient, "patient")); - assertFalse(state.hasSession(surgeonId)); - surgeon.assertSaid(menus.getUiUpdater().getMessage("surgery-failed")); - ailments.verify(() -> Ailments.extend(any(), anyString(), anyLong()), never()); - server.getScheduler().performOneTick(); - - // The surgeon dies; the unchanged patient still takes the penalty - menus.openSurgeryMenu(surgeon, patient, leg); - state.setOperated(surgeonId, true); - server.getPluginManager().callEvent(permakill(surgeon, "doctor")); - assertFalse(state.hasSession(surgeonId)); - ailments.verify(() -> Ailments.extend(patient, "leg", 12 * 3_600_000L)); - bukkit.verify(() -> Bukkit.dispatchCommand(any(), anyString()), never()); - server.getScheduler().performOneTick(); - } + PlayerMock patient = readyPlayer(); + UUID surgeonId = UUID.randomUUID(); + CharacterSession ending = characters.invalidate(patient); + menus.getStateManager().setPatientUuid(surgeonId, patient.getUniqueId()); + menus.getStateManager().setPatientSession(surgeonId, ending); + server.getPluginManager().callEvent(new CharacterSessionEndEvent(patient, ending, Reason.QUIT)); + assertFalse(menus.getStateManager().hasSession(surgeonId)); } @Test - void quitRunsBeforeRPCharactersUnloadsTheCharacter() throws Exception { - EventHandler handler = PlayerListener.class.getMethod("onPlayerQuit", PlayerQuitEvent.class) + void sessionEndListenerRunsBeforeOtherCleanupAndRoutesTheExactEvent() throws Exception { + EventHandler handler = PlayerListener.class.getMethod("onSessionEnd", CharacterSessionEndEvent.class) .getAnnotation(EventHandler.class); - assertEquals(EventPriority.LOW, handler.priority()); + assertEquals(EventPriority.LOWEST, handler.priority()); + SurgeryMenuManager menus = mock(SurgeryMenuManager.class); + PlayerMock player = readyPlayer(); + CharacterSessionEndEvent event = new CharacterSessionEndEvent(player, characters.invalidate(player), Reason.QUIT); + new PlayerListener(menus).onSessionEnd(event); + verify(menus).handleSessionEnd(event); } @Test void listenersPreventMenuItemMovementAndRouteOnlyTopInventoryActions() { SurgeryMenuManager menus = mock(SurgeryMenuManager.class); - SurgeryRequestManager requests = new SurgeryRequestManager(); - when(menus.getRequestManager()).thenReturn(requests); PlayerListener listener = new PlayerListener(menus); - PlayerMock player = server.addPlayer(); - Inventory top = new SurgeryMenuHolder().getInventory(); + PlayerMock player = readyPlayer(); + Inventory top = new SurgeryMenuHolder(player.getUniqueId(), 1).getInventory(); top.setItem(1, new ItemStack(Material.PAPER)); player.openInventory(top); when(menus.isSurgeryMenu(top)).thenReturn(true); @@ -235,7 +288,7 @@ void listenersPreventMenuItemMovementAndRouteOnlyTopInventoryActions() { 1, ClickType.LEFT, InventoryAction.PICKUP_ALL); listener.onInventoryClick(click); assertTrue(click.isCancelled()); - verify(menus).handleItemClick(player, top.getItem(1), 1); + verify(menus).handleItemClick(player, top, top.getItem(1), 1); InventoryClickEvent bottom = new InventoryClickEvent(player.getOpenInventory(), InventoryType.SlotType.CONTAINER, 54, ClickType.LEFT, InventoryAction.PICKUP_ALL); listener.onInventoryClick(bottom); @@ -244,27 +297,19 @@ void listenersPreventMenuItemMovementAndRouteOnlyTopInventoryActions() { -999, ClickType.LEFT, InventoryAction.NOTHING); listener.onInventoryClick(outside); assertTrue(outside.isCancelled()); - verify(menus, times(1)).handleItemClick(any(), any(), anyInt()); + verify(menus, times(1)).handleItemClick(any(), any(), any(), anyInt()); InventoryDragEvent drag = mock(InventoryDragEvent.class); when(drag.getInventory()).thenReturn(top); listener.onInventoryDrag(drag); verify(drag).setCancelled(true); listener.onInventoryClose(new InventoryCloseEvent(player.getOpenInventory())); - verify(menus).handleSurgeryAbandonment(player); - requests.offer(player.getUniqueId(), UUID.randomUUID(), "leg", Long.MAX_VALUE); - PlayerQuitEvent quit = mock(PlayerQuitEvent.class); - when(quit.getPlayer()).thenReturn(player); - listener.onPlayerQuit(quit); - verify(menus).handleSurgeonQuit(player); - verify(menus).handlePatientQuit(player); - assertNull(requests.pending(player.getUniqueId(), 0)); - + verify(menus).handleSurgeryAbandonment(player, top); when(menus.isSurgeryMenu(top)).thenReturn(false); click.setCancelled(false); listener.onInventoryClick(click); assertFalse(click.isCancelled()); listener.onInventoryClose(new InventoryCloseEvent(player.getOpenInventory())); - verify(menus, times(1)).handleSurgeryAbandonment(player); + verify(menus, times(1)).handleSurgeryAbandonment(player, top); clearInvocations(drag); listener.onInventoryDrag(drag); verify(drag, never()).setCancelled(anyBoolean()); diff --git a/src/test/java/net/tfminecraft/surgery/TestCharacters.java b/src/test/java/net/tfminecraft/surgery/TestCharacters.java new file mode 100644 index 0000000..d0ca6d6 --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/TestCharacters.java @@ -0,0 +1,35 @@ +package net.tfminecraft.surgery; + +import java.util.*; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import org.mockbukkit.mockbukkit.MockBukkit; +import net.tfminecraft.tlibs.character.*; + +/** Real shared facade with a controllable provider for lifecycle regression tests. */ +public final class TestCharacters implements CharacterIdentity, AutoCloseable { + private final Plugin plugin = MockBukkit.createMockPlugin("SurgeryIdentityTest"); + private final Map sessions = new HashMap<>(); + private long next; + + public TestCharacters() { + Bukkit.getServicesManager().register(CharacterIdentity.class, this, plugin, ServicePriority.Normal); + } + public CharacterSession activate(Player player) { return activate(player, UUID.randomUUID()); } + public CharacterSession activate(Player player, UUID id) { + CharacterSession token = new CharacterSession(new CharacterRef(player.getUniqueId(), id), ++next); + sessions.put(player.getUniqueId(), token); + return token; + } + public CharacterSession invalidate(Player player) { return sessions.remove(player.getUniqueId()); } + public void unregister() { Bukkit.getServicesManager().unregister(CharacterIdentity.class, this); } + @Override public void close() { unregister(); } + @Override public Optional currentSession(Player player) { return Optional.ofNullable(sessions.get(player.getUniqueId())); } + @Override public boolean isCurrent(CharacterSession token) { return token.equals(sessions.get(token.ref().ownerId())); } + @Override public Optional info(UUID id) { return Optional.empty(); } + @Override public Optional> characters(UUID owner) { return Optional.empty(); } + @Override public Optional lastPlayed(UUID owner) { return Optional.empty(); } + @Override public String displayName(Player player) { return player.getName(); } +} diff --git a/src/test/java/net/tfminecraft/surgery/commands/SurgeryCommandTest.java b/src/test/java/net/tfminecraft/surgery/commands/SurgeryCommandTest.java index ffebf00..f21d33a 100644 --- a/src/test/java/net/tfminecraft/surgery/commands/SurgeryCommandTest.java +++ b/src/test/java/net/tfminecraft/surgery/commands/SurgeryCommandTest.java @@ -3,11 +3,14 @@ import net.kyori.adventure.text.Component; import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; import net.tfminecraft.surgery.SurgeryPlugin; +import net.tfminecraft.surgery.TestCharacters; import net.tfminecraft.surgery.managers.SurgeryMenuManager; import net.tfminecraft.surgery.managers.SurgeryRequestManager; import net.tfminecraft.surgery.managers.SurgeryStateManager; import net.tfminecraft.surgery.managers.SurgeryUIUpdater; import net.tfminecraft.surgery.procedures.Ailments; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; @@ -42,6 +45,8 @@ class SurgeryCommandTest { private MockedStatic bukkit; private MockedStatic ailments; private World world; + private TestCharacters characters; + private CharacterSession surgeonSession, patientSession; @BeforeEach void setUp() { @@ -49,6 +54,9 @@ void setUp() { world = server.addSimpleWorld("world"); surgeon = player("Surgeon"); patient = player("Patient"); + characters = new TestCharacters(); + surgeonSession = characters.activate(surgeon); + patientSession = characters.activate(patient); menus = mock(SurgeryMenuManager.class); state = new SurgeryStateManager(); requests = new SurgeryRequestManager(); @@ -77,6 +85,7 @@ void setUp() { void tearDown() { ailments.close(); bukkit.close(); + characters.close(); MockBukkit.unmock(); } @@ -101,7 +110,8 @@ private void message(Player player, String prefix) { } private void offerRequest() { - requests.offer(patient.getUniqueId(), surgeon.getUniqueId(), "leg", Long.MAX_VALUE); + requests.offer(Characters.currentSession(patient).orElse(null), + Characters.currentSession(surgeon).orElse(null), "leg", Long.MAX_VALUE); } @Test @@ -159,12 +169,29 @@ void offersRequireAnAilmentAndDoNotReplacePendingConsent() { assertSame(old, requests.pending(patient.getUniqueId(), 0)); } + @ParameterizedTest + @ValueSource(strings = {"surgeon", "patient", "provider"}) + void offersRequireBothReadyCharactersBeforeLookingUpInjuries(String unavailable) { + switch (unavailable) { + case "surgeon" -> characters.invalidate(surgeon); + case "patient" -> characters.invalidate(patient); + default -> characters.unregister(); + } + run(surgeon, "Patient"); + message(surgeon, "command-character-required"); + ailments.verifyNoInteractions(); + assertNull(requests.pending(patient.getUniqueId(), 0)); + verify(menus, never()).openSurgeryMenu(any(), any(), any()); + } + @Test void offersStripFormattingDescribePenaltyAndProvideConsentButtons() { long before = System.currentTimeMillis(); run(surgeon, "Patient"); var request = requests.pending(patient.getUniqueId(), before); assertEquals(surgeon.getUniqueId(), request.surgeonId()); + assertEquals(surgeonSession, request.surgeonSession()); + assertEquals(patientSession, request.patientSession()); assertEquals("leg", request.traitId()); assertTrue(request.expiresAt() >= before + 60_000); ArgumentCaptor messages = ArgumentCaptor.forClass(String.class); @@ -229,6 +256,48 @@ void acceptingRechecksStateAndInjuryThenOpensTheMatchingProcedure() { assertNull(requests.pending(patient.getUniqueId(), 0)); } + @ParameterizedTest + @ValueSource(strings = {"surgeon-switch-back", "patient-switch-back", "surgeon-reconnect", + "patient-reconnect", "surgeon-pending", "patient-pending", "provider"}) + void acceptingRejectsConsentFromEitherPreviousCharacterSession(String changed) { + run(surgeon, "Patient"); + assertNotNull(requests.pending(patient.getUniqueId(), 0)); + ailments.clearInvocations(); + if (changed.equals("provider")) { + characters.unregister(); + } else { + Player participant = changed.startsWith("surgeon") ? surgeon : patient; + CharacterSession original = participant == surgeon ? surgeonSession : patientSession; + characters.invalidate(participant); + if (changed.endsWith("switch-back")) { + characters.activate(participant); + characters.activate(participant, original.ref().characterId()); + } else if (changed.endsWith("reconnect")) { + characters.activate(participant, original.ref().characterId()); + } + } + run(patient, "accept"); + message(patient, "request-none"); + ailments.verifyNoInteractions(); + verify(menus, never()).openSurgeryMenu(any(), any(), any()); + assertNull(requests.pending(patient.getUniqueId(), 0)); + } + + @ParameterizedTest + @ValueSource(strings = {"surgeon", "patient"}) + void acceptanceRechecksCapturedSessionsAfterResolvingTheSurgeon(String changed) { + offerRequest(); + bukkit.when(() -> Bukkit.getPlayer(surgeon.getUniqueId())).thenAnswer(call -> { + characters.invalidate(changed.equals("surgeon") ? surgeon : patient); + return surgeon; + }); + run(patient, "accept"); + message(surgeon, "command-character-required"); + message(patient, "request-cannot-start"); + ailments.verifyNoInteractions(); + verify(menus, never()).openSurgeryMenu(any(), any(), any()); + } + @Test void denyingNotifiesTheSurgeonWhenStillAvailable() { run(patient, "DeNy"); diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java index 0bc5adb..baf40ad 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java @@ -2,24 +2,33 @@ import net.tfminecraft.rpcharacters.api.HealingInjuries; import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; +import net.tfminecraft.surgery.TestCharacters; import net.tfminecraft.surgery.procedures.Ailments; import net.tfminecraft.surgery.procedures.Durations; import net.tfminecraft.surgery.procedures.Procedure; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterSessionEndEvent.Reason; import org.bukkit.Bukkit; import org.bukkit.Sound; import org.bukkit.command.ConsoleCommandSender; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockbukkit.mockbukkit.MockBukkit; -import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import java.lang.reflect.Modifier; +import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.UUID; @@ -38,43 +47,69 @@ class SurgeryCompletionTest { private SurgeryCompletionHandler completion; private BukkitScheduler scheduler; private ConsoleCommandSender console; + private InventoryView view; + private Inventory menu; + private TestCharacters characters; private MockedStatic bukkit; private MockedStatic injuries; + private final List closes = new ArrayList<>(); @BeforeEach void setUp() { MockBukkit.mock(); + characters = new TestCharacters(); surgeon = mock(Player.class); patient = mock(Player.class); when(surgeon.getUniqueId()).thenReturn(surgeonId); when(patient.getUniqueId()).thenReturn(patientId); when(surgeon.getName()).thenReturn("Surgeon"); when(patient.getName()).thenReturn("Patient"); + when(surgeon.isOnline()).thenReturn(true); + when(patient.isOnline()).thenReturn(true); + characters.activate(surgeon); + characters.activate(patient); plugin = mock(JavaPlugin.class); config = new YamlConfiguration(); when(plugin.getConfig()).thenReturn(config); when(plugin.getLogger()).thenReturn(mock(Logger.class)); + when(plugin.isEnabled()).thenReturn(true); state = new SurgeryStateManager(); - state.setPatientUuid(surgeonId, patientId); - state.setPatientName(surgeonId, "Patient"); - state.setAilment(surgeonId, "leg", "Broken leg", new Procedure("Fracture", 2, 0, 0, Set.of())); ui = mock(SurgeryUIUpdater.class); when(ui.getMessage(anyString())).thenAnswer(c -> c.getArgument(0)); completion = new SurgeryCompletionHandler(plugin, state, ui); scheduler = mock(BukkitScheduler.class); + when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> { + closes.add(call.getArgument(1)); + return mock(BukkitTask.class); + }); console = mock(ConsoleCommandSender.class); bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(patient); + bukkit.when(() -> Bukkit.getPlayer(surgeonId)).thenReturn(surgeon); bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); bukkit.when(Bukkit::getConsoleSender).thenReturn(console); bukkit.when(() -> Bukkit.dispatchCommand(eq(console), anyString())).thenReturn(true); + view = mock(InventoryView.class); + when(surgeon.getOpenInventory()).thenReturn(view); + beginOperation("leg", "Broken leg"); injuries = mockStatic(HealingInjuries.class); } + private void beginOperation(String traitId, String ailment) { + state.setPatientUuid(surgeonId, patientId); + state.setPatientName(surgeonId, "Patient"); + state.setSurgeonSession(surgeonId, characters.currentSession(surgeon).orElseThrow()); + state.setPatientSession(surgeonId, characters.currentSession(patient).orElseThrow()); + state.setAilment(surgeonId, traitId, ailment, new Procedure("Fracture", 2, 0, 0, Set.of())); + menu = new SurgeryMenuHolder(surgeonId, state.getOperationId(surgeonId)).getInventory(); + when(view.getTopInventory()).thenReturn(menu); + } + @AfterEach void tearDown() { injuries.close(); bukkit.close(); + characters.close(); MockBukkit.unmock(); } @@ -117,6 +152,7 @@ void successfulCureRunsRewardOnceAndClosesOnNextTick() { injuries.when(() -> HealingInjuries.cure(patient, "leg")).thenReturn(true); config.set("commands.surgery-success", "reward %surgeon% %player% %ailment%"); completion.handleSuccess(surgeon); + completion.handleSuccess(surgeon); injuries.verify(() -> HealingInjuries.cure(patient, "leg")); bukkit.verify(() -> Bukkit.dispatchCommand(console, "reward Surgeon Patient Broken leg")); assertFalse(state.hasSession(surgeonId)); @@ -132,22 +168,31 @@ void missingPatientOrAlreadyHealedAilmentDoesNotRewardSuccess() { verify(surgeon).sendMessage("ailment-already-healed"); verify(surgeon, never()).sendMessage("surgery-successful"); verifyDeferredClose(); - state.setPatientUuid(surgeonId, null); - state.setAilment(surgeonId, "leg", "Leg", null); + beginOperation("leg", "Leg"); + state.setPatientSession(surgeonId, null); completion.handleSuccess(surgeon); assertFalse(state.hasSession(surgeonId)); injuries.verify(() -> HealingInjuries.cure(patient, "leg"), times(1)); } @Test - void failureExtendsHealingAndRunsCommandBeforeCleaningSession() { + void failureDetachesBeforePenaltyAndRunsCommandAfterward() { state.setOperated(surgeonId, true); config.set("failure-healing-penalty", "90m"); config.set("commands.surgery-failure", "penalty %player% %surgeon% %ailment%"); - injuries.when(() -> HealingInjuries.extend(patient, "leg", 5_400_000L)).thenReturn(7_200_000L); + List effects = new ArrayList<>(); + injuries.when(() -> HealingInjuries.extend(patient, "leg", 5_400_000L)).thenAnswer(call -> { + assertFalse(state.hasSession(surgeonId)); + effects.add("penalty"); + return 7_200_000L; + }); + bukkit.when(() -> Bukkit.dispatchCommand(console, "penalty Patient Surgeon Broken leg")).thenAnswer(call -> { + assertFalse(state.hasSession(surgeonId)); + effects.add("command"); + return true; + }); completion.failSurgery(surgeon, "failure reason"); - injuries.verify(() -> HealingInjuries.extend(patient, "leg", 5_400_000L)); - bukkit.verify(() -> Bukkit.dispatchCommand(console, "penalty Patient Surgeon Broken leg")); + assertEquals(List.of("penalty", "command"), effects); verify(surgeon).sendMessage("failure reason"); verify(surgeon).sendMessage("surgery-failed-penalty"); verify(patient).sendMessage("patient-surgery-failed"); @@ -166,8 +211,7 @@ void invalidPenaltyUsesDefaultAndHarmlessFailuresDoNotExtendInjuries() { verify(plugin.getLogger()).warning("Invalid failure-healing-penalty, using 12h"); injuries.verify(() -> HealingInjuries.extend(patient, "leg", 43_200_000L)); verify(surgeon, never()).sendMessage("surgery-failed-penalty"); - state.setPatientUuid(surgeonId, patientId); - state.setAilment(surgeonId, "leg", "Leg", null); + beginOperation("leg", "Leg"); completion.failSurgery(surgeon, "not operated"); injuries.verifyNoMoreInteractions(); } @@ -176,17 +220,17 @@ void invalidPenaltyUsesDefaultAndHarmlessFailuresDoNotExtendInjuries() { void zeroPenaltyMissingPatientAndMissingTraitAreSafe() { state.setOperated(surgeonId, true); config.set("failure-healing-penalty", "0h"); - completion.handleQuit(surgeon); + completion.failSurgery(surgeon, "zero penalty"); + config.set("failure-healing-penalty", "1h"); + beginOperation(null, "Unknown"); state.setOperated(surgeonId, true); - state.setPatientUuid(surgeonId, patientId); - completion.handleQuit(surgeon); + completion.failSurgery(surgeon, "no trait"); + beginOperation("leg", "Leg"); state.setOperated(surgeonId, true); - state.setAilment(surgeonId, "leg", "Leg", null); - completion.handleQuit(surgeon); - completion.handleQuit(surgeon); + bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(null); + completion.failSurgery(surgeon, "no patient"); assertFalse(state.hasSession(surgeonId)); injuries.verifyNoInteractions(); - verifyNoInteractions(scheduler); } @Test @@ -196,6 +240,7 @@ void abandonmentBeforeTreatmentCancelsAndAfterTreatmentFails() { verify(surgeon).sendMessage("surgery-cancelled"); completion.handleAbandonment(surgeon); verify(surgeon, times(1)).sendMessage("surgery-cancelled"); + beginOperation("leg", "Broken leg"); state.setOperated(surgeonId, true); completion.handleAbandonment(surgeon); verify(surgeon).sendMessage("failure-gave-up"); @@ -203,89 +248,236 @@ void abandonmentBeforeTreatmentCancelsAndAfterTreatmentFails() { } @Test - void failureOnASwitchedPatientSkipsCommandAndPenalty() { - state.setPatientCharacterId(surgeonId, "original"); + void failureOnAPatientWhoSwitchedAwayAndBackSkipsCommandAndPenalty() { + UUID original = state.getPatientSession(surgeonId).ref().characterId(); + characters.activate(patient); + characters.activate(patient, original); state.setOperated(surgeonId, true); config.set("commands.surgery-failure", "damage %player% 1"); - try (MockedStatic characters = mockStatic(Ailments.class, CALLS_REAL_METHODS)) { - characters.when(() -> Ailments.characterId(patient)).thenReturn("other"); - completion.failSurgery(surgeon, "failed"); - } - bukkit.verify(() -> Bukkit.dispatchCommand(eq(console), anyString()), never()); + completion.failSurgery(surgeon, "failed"); + verifyNoCommand(); injuries.verifyNoInteractions(); verify(surgeon).sendMessage("failed"); verify(patient, never()).sendMessage(anyString()); assertFalse(state.hasSession(surgeonId)); } - @Test - void cureSkipsASwitchedPatient() { - state.setPatientCharacterId(surgeonId, "original"); + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void cureRequiresBothOriginalSessionsEvenAfterSwitchingBack(boolean patientChanges) { + Player changing = patientChanges ? patient : surgeon; + CharacterSession original = characters.currentSession(changing).orElseThrow(); + characters.activate(changing); + characters.activate(changing, original.ref().characterId()); config.set("commands.surgery-success", "reward %player%"); - injuries.when(() -> HealingInjuries.cure(patient, "leg")).thenReturn(true); - try (MockedStatic characters = mockStatic(Ailments.class, CALLS_REAL_METHODS)) { - characters.when(() -> Ailments.characterId(patient)).thenReturn("other"); - completion.handleSuccess(surgeon); - } + completion.handleSuccess(surgeon); injuries.verifyNoInteractions(); - bukkit.verify(() -> Bukkit.dispatchCommand(eq(console), anyString()), never()); - verify(surgeon).sendMessage("ailment-already-healed"); - verify(patient, never()).sendMessage(anyString()); - } - - @Test - void cureLandsOnTheUnchangedPatient() { - state.setPatientCharacterId(surgeonId, "original"); - state.setSurgeonCharacterId(surgeonId, "doctor"); - config.set("commands.surgery-success", "reward %player%"); - injuries.when(() -> HealingInjuries.cure(patient, "leg")).thenReturn(true); - try (MockedStatic characters = mockStatic(Ailments.class, CALLS_REAL_METHODS)) { - characters.when(() -> Ailments.characterId(patient)).thenReturn("original"); - characters.when(() -> Ailments.characterId(surgeon)).thenReturn("doctor"); - completion.handleSuccess(surgeon); - } - injuries.verify(() -> HealingInjuries.cure(patient, "leg")); - bukkit.verify(() -> Bukkit.dispatchCommand(console, "reward Patient")); - verify(patient).sendMessage("patient-surgery-successful"); + verifyNoCommand(); + assertFalse(state.hasSession(surgeonId)); } @Test void switchedSurgeonSkipsCommandButUnchangedPatientTakesPenalty() { - state.setSurgeonCharacterId(surgeonId, "doctor"); + characters.activate(surgeon); state.setOperated(surgeonId, true); config.set("failure-healing-penalty", "1h"); config.set("commands.surgery-failure", "damage %surgeon% 1"); injuries.when(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)).thenReturn(3_600_000L); - try (MockedStatic characters = mockStatic(Ailments.class, CALLS_REAL_METHODS)) { - characters.when(() -> Ailments.characterId(surgeon)).thenReturn("farmer"); - completion.handleAbandonment(surgeon); - } - bukkit.verify(() -> Bukkit.dispatchCommand(eq(console), anyString()), never()); + completion.handleAbandonment(surgeon); + verifyNoCommand(); injuries.verify(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)); verify(patient).sendMessage("patient-surgery-failed"); - verify(surgeon).sendMessage("failure-gave-up"); + verify(surgeon, never()).sendMessage(anyString()); + assertTrue(closes.isEmpty()); } @Test - void markedCharacterChangesSkipCommandsEvenForALoggedOffPatient() { + void providerLossCannotApplyPenaltyCommandOrCloseToAccounts() { state.setOperated(surgeonId, true); - config.set("failure-healing-penalty", "0h"); config.set("commands.surgery-failure", "damage %player% 1"); - state.markSurgeonChanged(surgeonId); - completion.failSurgery(surgeon, "surgeon died"); + characters.unregister(); + completion.failSurgery(surgeon, "provider gone"); + assertFalse(state.hasSession(surgeonId)); + injuries.verifyNoInteractions(); + verifyNoCommand(); + assertTrue(closes.isEmpty()); + } - bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(null); - state.setPatientUuid(surgeonId, patientId); + @Test + void patientQuitPenalizesTheInvalidatedOutgoingBodyOnceWithoutCommands() { state.setOperated(surgeonId, true); - state.markPatientChanged(surgeonId); - completion.failSurgery(surgeon, "patient died"); - bukkit.verify(() -> Bukkit.dispatchCommand(eq(console), anyString()), never()); + config.set("failure-healing-penalty", "1h"); + config.set("commands.surgery-failure", "damage %player% 1"); + CharacterSession outgoing = characters.invalidate(patient); + assertFalse(characters.isCurrent(outgoing)); + injuries.when(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)).thenReturn(7_200_000L); + completion.handleEnd(surgeon, outgoing, Reason.QUIT); + completion.handleEnd(surgeon, outgoing, Reason.QUIT); + injuries.verify(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)); + verifyNoCommand(); + assertFalse(state.hasSession(surgeonId)); + verify(patient, never()).sendMessage(anyString()); + verifyDeferredClose(); + } - state.setPatientUuid(surgeonId, patientId); - state.setPatientName(surgeonId, "Patient"); + @ParameterizedTest + @EnumSource(value = Reason.class, names = {"SWITCH", "DEATH", "SHUTDOWN"}) + void otherPatientEndReasonsNeverPenalizeTheAccount(Reason reason) { + state.setOperated(surgeonId, true); + config.set("commands.surgery-failure", "damage %player% 1"); + completion.handleEnd(surgeon, characters.invalidate(patient), reason); + injuries.verifyNoInteractions(); + verifyNoCommand(); + assertFalse(state.hasSession(surgeonId)); + if (reason == Reason.SHUTDOWN) { + assertTrue(closes.isEmpty()); + verify(surgeon, never()).sendMessage(anyString()); + } else { + verifyDeferredClose(); + } + } + + @ParameterizedTest + @EnumSource(Reason.class) + void surgeonEndPenalizesTheCurrentPatientExceptDuringShutdown(Reason reason) { + state.setOperated(surgeonId, true); + config.set("failure-healing-penalty", "1h"); + config.set("commands.surgery-failure", "damage %surgeon% 1"); + completion.handleEnd(surgeon, characters.invalidate(surgeon), reason); + if (reason == Reason.SHUTDOWN) { + injuries.verifyNoInteractions(); + } else { + injuries.verify(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)); + } + verifyNoCommand(); + assertFalse(state.hasSession(surgeonId)); + verify(surgeon, never()).sendMessage(anyString()); + assertTrue(closes.isEmpty()); + } + + @Test + void outgoingPatientQuitCannotPenalizeAReplacementSession() { + state.setOperated(surgeonId, true); + CharacterSession outgoing = characters.invalidate(patient); + characters.activate(patient); + completion.handleEnd(surgeon, outgoing, Reason.QUIT); + injuries.verifyNoInteractions(); + assertFalse(state.hasSession(surgeonId)); + } + + @Test + void oldEndEventCannotDetachANewerOperation() { + CharacterSession old = characters.invalidate(patient); + state.finish(surgeonId); + characters.activate(patient); + beginOperation("arm", "Broken arm"); + long operation = state.getOperationId(surgeonId); + completion.handleEnd(surgeon, old, Reason.QUIT); + assertEquals(operation, state.getOperationId(surgeonId)); + assertEquals("arm", state.getTraitId(surgeonId)); + injuries.verifyNoInteractions(); + assertTrue(closes.isEmpty()); + } + + @Test + void reentrantCureCannotRepeatOrEraseAndCloseANewOperation() { + long original = state.getOperationId(surgeonId); + Inventory oldMenu = menu; + injuries.when(() -> HealingInjuries.cure(patient, "leg")).thenAnswer(call -> { + assertFalse(state.hasSession(surgeonId)); + completion.handleSuccess(surgeon); + beginOperation("arm", "Broken arm"); + return true; + }); + completion.handleSuccess(surgeon); + injuries.verify(() -> HealingInjuries.cure(patient, "leg")); + assertNotEquals(original, state.getOperationId(surgeonId)); + assertEquals("arm", state.getTraitId(surgeonId)); + assertNotSame(oldMenu, menu); + assertEquals(1, closes.size()); + closes.removeFirst().run(); + verify(surgeon, never()).closeInventory(); + assertTrue(state.hasSession(surgeonId)); + } + + @Test + void reentrantFailureCommandCannotRepeatPenaltyOrClearTheNextOperation() { + state.setOperated(surgeonId, true); + config.set("failure-healing-penalty", "1h"); + config.set("commands.surgery-failure", "penalty %player% %ailment%"); + List effects = new ArrayList<>(); + injuries.when(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)).thenAnswer(call -> { + assertFalse(state.hasSession(surgeonId)); + effects.add("penalty"); + return 3_600_000L; + }); + bukkit.when(() -> Bukkit.dispatchCommand(console, "penalty Patient Broken leg")).thenAnswer(call -> { + effects.add("command"); + completion.failSurgery(surgeon, "duplicate"); + beginOperation("arm", "Broken arm"); + return true; + }); + completion.failSurgery(surgeon, "failed"); + assertEquals(List.of("penalty", "command"), effects); + injuries.verify(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)); + assertEquals("arm", state.getTraitId(surgeonId)); + verify(surgeon, never()).sendMessage("duplicate"); + closes.removeFirst().run(); + verify(surgeon, never()).closeInventory(); + assertTrue(state.hasSession(surgeonId)); + } + + @Test + void reentrantPenaltySwitchSuppressesTheFollowingAccountCommand() { state.setOperated(surgeonId, true); - completion.failSurgery(surgeon, "patient logged off"); - bukkit.verify(() -> Bukkit.dispatchCommand(console, "damage Patient 1")); + config.set("failure-healing-penalty", "1h"); + config.set("commands.surgery-failure", "damage %player% 1"); + injuries.when(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)).thenAnswer(call -> { + completion.failSurgery(surgeon, "duplicate"); + characters.activate(patient); + return 3_600_000L; + }); + completion.failSurgery(surgeon, "failed"); + injuries.verify(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)); + verifyNoCommand(); + verify(patient, never()).sendMessage(anyString()); + } + + @ParameterizedTest + @ValueSource(strings = {"switch-back", "offline", "reconnect", "new-menu", "disabled"}) + void deferredCloseRejectsChangedActorsAndInventories(String change) { + injuries.when(() -> HealingInjuries.cure(patient, "leg")).thenReturn(true); + completion.handleSuccess(surgeon); + assertEquals(1, closes.size()); + switch (change) { + case "switch-back" -> { + UUID character = characters.currentSession(surgeon).orElseThrow().ref().characterId(); + characters.activate(surgeon); + characters.activate(surgeon, character); + } + case "offline" -> when(surgeon.isOnline()).thenReturn(false); + case "reconnect" -> bukkit.when(() -> Bukkit.getPlayer(surgeonId)).thenReturn(mock(Player.class)); + case "new-menu" -> { + Inventory other = Bukkit.createInventory(null, 9, "Other menu"); + when(view.getTopInventory()).thenReturn(other); + } + case "disabled" -> when(plugin.isEnabled()).thenReturn(false); + default -> fail(change); + } + closes.removeFirst().run(); + verify(surgeon, never()).closeInventory(); + } + + @Test + void unrelatedMenuAndDisabledPluginDoNotScheduleAClosure() { + Inventory unrelated = new SurgeryMenuHolder(surgeonId, state.getOperationId(surgeonId) + 1).getInventory(); + when(view.getTopInventory()).thenReturn(unrelated); + completion.failSurgery(surgeon, "failed"); + assertTrue(closes.isEmpty()); + beginOperation("leg", "Leg"); + when(plugin.isEnabled()).thenReturn(false); + completion.failSurgery(surgeon, "disabled"); + assertTrue(closes.isEmpty()); } @Test @@ -306,11 +498,14 @@ void ailmentQueriesSelectWorstAndFindCaseInsensitively() throws Exception { } } + private void verifyNoCommand() { + bukkit.verify(() -> Bukkit.dispatchCommand(eq(console), anyString()), never()); + } + private void verifyDeferredClose() { verify(surgeon, never()).closeInventory(); - ArgumentCaptor close = ArgumentCaptor.forClass(Runnable.class); - verify(scheduler).runTask(eq(plugin), close.capture()); - close.getValue().run(); + assertEquals(1, closes.size()); + closes.removeFirst().run(); verify(surgeon).closeInventory(); } } diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryItemHandlerTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryItemHandlerTest.java index 62768a5..adc55cd 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryItemHandlerTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryItemHandlerTest.java @@ -30,6 +30,7 @@ import static org.mockito.Mockito.*; class SurgeryItemHandlerTest { + private net.tfminecraft.surgery.TestCharacters identities; private final UUID id = UUID.fromString("00000000-0000-0000-0000-000000000001"); private YamlConfiguration config; private SurgeryStateManager state; @@ -45,6 +46,7 @@ class SurgeryItemHandlerTest { @BeforeEach void setUp() { ServerMock server = MockBukkit.mock(); + identities = new net.tfminecraft.surgery.TestCharacters(); config = new YamlConfiguration(); config.set("skill-fail.base-chance", 0.0); config.set("skill-fail.bleeding-chance", 0.0); @@ -58,6 +60,7 @@ void setUp() { state.setAilment(id, "trait", "Ailment", new Procedure("Procedure", 2, 1, 1, Set.of())); ui = mock(SurgeryUIUpdater.class); when(ui.getMessage(anyString())).thenAnswer(call -> call.getArgument(0)); + when(ui.getMessage(anyString(), anyString())).thenAnswer(call -> call.getArgument(0)); when(ui.getMessageList(anyString())).thenAnswer(call -> List.of(call.getArgument(0, String.class))); when(ui.createInfoBlock(any(), anyString(), anyString())).thenReturn(new ItemStack(Material.PAPER)); mechanics = mock(SurgeryMechanicsManager.class); @@ -72,6 +75,10 @@ void setUp() { inventory = mock(PlayerInventory.class); InventoryView view = mock(InventoryView.class); when(player.getUniqueId()).thenReturn(id); + Player patient = mock(Player.class); + when(patient.getUniqueId()).thenReturn(UUID.randomUUID()); + state.setSurgeonSession(id, identities.activate(player)); + state.setPatientSession(id, identities.activate(patient)); when(player.getInventory()).thenReturn(inventory); when(player.getOpenInventory()).thenReturn(view); when(player.getLocation()).thenReturn(new Location(server.addSimpleWorld("world"), 0, 64, 0)); @@ -83,9 +90,22 @@ void setUp() { @AfterEach void tearDown() { + identities.close(); MockBukkit.unmock(); } + @Test + void staleSessionCannotConsumeToolsOrCreateMoreOperationState() { + var original = state.getSurgeonSession(id); + identities.activate(player, original.ref().characterId()); + ItemStack stock = new ItemStack(Material.PAPER, 2); + when(inventory.getItem(0)).thenReturn(stock); + handler.handleItemClick(player, stock, SurgeryTool.SCALPEL.getSlot()); + assertEquals(2, stock.getAmount()); + verify(completion).failSurgery(eq(player), anyString()); + verifyNoInteractions(mechanics); + } + private void click(SurgeryTool tool) { ItemStack stock = new ItemStack(Material.PAPER, 2); when(inventory.getItem(0)).thenReturn(stock); @@ -313,6 +333,22 @@ void chloroformSedatesAwakePatientsAndCanBeReusedAfterCooldown() { verifyNoInteractions(completion); } + @Test + void chloroformFailureCannotWriteIntoAnOperationStartedByItsCompletionCommand() { + state.setUnconsciousTimer(id, 1); + long original = state.getOperationId(id); + doAnswer(call -> { + state.cleanup(id); + state.setSkillFail(id, "Replacement state"); + return null; + }).when(completion).failSurgery(player, "failure-anesthetic-misuse"); + click(SurgeryTool.CHLOROFORM); + assertNotEquals(original, state.getOperationId(id)); + assertEquals("Replacement state", state.getSkillFail(id)); + verify(ui, never()).createInfoBlock(any(), anyString(), anyString()); + verify(completion, never()).handleSuccess(any()); + } + @Test void chloroformMisuseEndsTheSessionWithoutSuccessFeedback() { state.setUnconsciousTimer(id, 1); diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java index f608308..c394a54 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java @@ -1,6 +1,7 @@ package net.tfminecraft.surgery.managers; -import net.tfminecraft.surgery.procedures.Ailments; +import net.tfminecraft.surgery.TestCharacters; +import net.tfminecraft.tlibs.character.CharacterSession; import net.tfminecraft.surgery.procedures.Complication; import net.tfminecraft.surgery.procedures.Procedure; import net.tfminecraft.tlibs.objects.api.ItemAPI; @@ -17,6 +18,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.MockedStatic; import org.mockbukkit.mockbukkit.MockBukkit; import org.mockbukkit.mockbukkit.ServerMock; @@ -39,6 +42,9 @@ class SurgeryMechanicsManagerTest { private Player player; private Inventory menu; private World world; + private Player patient; + private TestCharacters characters; + private MockedStatic bukkit; @BeforeEach void setUp() { @@ -64,15 +70,31 @@ void setUp() { player = mock(Player.class); InventoryView view = mock(InventoryView.class); when(player.getUniqueId()).thenReturn(id); + when(player.isOnline()).thenReturn(true); when(player.getOpenInventory()).thenReturn(view); when(player.getWorld()).thenReturn(world); when(player.getLocation()).thenReturn(new Location(world, 0, 64, 0)); when(view.getTopInventory()).thenReturn(menu); + patient = mock(Player.class); + UUID patientId = UUID.randomUUID(); + when(patient.getUniqueId()).thenReturn(patientId); + when(patient.isOnline()).thenReturn(true); + when(patient.getWorld()).thenReturn(world); + when(patient.getLocation()).thenReturn(new Location(world, 1, 64, 0)); + characters = new TestCharacters(); + state.setSurgeonSession(id, characters.activate(player)); + state.setPatientSession(id, characters.activate(patient)); + state.setPatientUuid(id, patientId); + bukkit = mockStatic(Bukkit.class, CALLS_REAL_METHODS); + bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(patient); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); mechanics = new SurgeryMechanicsManager(plugin, api, state, ui, completion, items); } @AfterEach void tearDown() { + bukkit.close(); + characters.close(); MockBukkit.unmock(); } @@ -220,68 +242,55 @@ void bleedingDirtySiteRaisesFeverWithoutAnIncision() { @Test void patientMustRemainOnlineInTheSameWorldAndInsideConfiguredRange() { - UUID patientId = UUID.fromString("00000000-0000-0000-0000-000000000003"); - state.setPatientUuid(id, patientId); - Player patient = mock(Player.class); - try (MockedStatic bukkit = mockStatic(Bukkit.class)) { - move(); - verify(completion).failSurgery(player, "failure-patient-left"); - assertEquals(0, state.getMoveCount(id)); - clearInvocations(completion); - bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(patient); - move(); - verify(completion).failSurgery(player, "failure-patient-left"); - clearInvocations(completion); - when(patient.isOnline()).thenReturn(true); - when(patient.getWorld()).thenReturn(mock(World.class)); - move(); - verify(completion).failSurgery(player, "failure-patient-left"); - clearInvocations(completion); - when(patient.getWorld()).thenReturn(world); - when(patient.getLocation()).thenReturn(new Location(world, 10, 64, 0)); - move(); - assertEquals(1, state.getMoveCount(id)); - verifyNoInteractions(completion); - when(patient.getLocation()).thenReturn(new Location(world, 10.01, 64, 0)); - move(); - verify(completion).failSurgery(player, "failure-patient-left"); - } - } - - @Test - void deadOrSwitchedPatientIsNotOnTheTable() { - UUID patientId = UUID.fromString("00000000-0000-0000-0000-000000000004"); - state.setPatientUuid(id, patientId); - state.setPatientCharacterId(id, "patient"); - Player patient = mock(Player.class); + UUID patientId = patient.getUniqueId(); + bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(null); + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + assertEquals(0, state.getMoveCount(id)); + clearInvocations(completion); + bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(patient); + when(patient.isOnline()).thenReturn(false); + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + clearInvocations(completion); when(patient.isOnline()).thenReturn(true); + when(patient.getWorld()).thenReturn(mock(World.class)); + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + clearInvocations(completion); when(patient.getWorld()).thenReturn(world); - when(patient.getLocation()).thenReturn(new Location(world, 1, 64, 0)); - try (MockedStatic bukkit = mockStatic(Bukkit.class); - MockedStatic ailments = mockStatic(Ailments.class)) { - bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(patient); - ailments.when(() -> Ailments.characterId(patient)).thenReturn("patient"); - move(); - assertEquals(1, state.getMoveCount(id)); - verifyNoInteractions(completion); - - when(patient.isDead()).thenReturn(true); - move(); - verify(completion).failSurgery(player, "failure-patient-left"); - clearInvocations(completion); - - when(patient.isDead()).thenReturn(false); - ailments.when(() -> Ailments.characterId(patient)).thenReturn(null); - move(); - verify(completion).failSurgery(player, "failure-patient-left"); - clearInvocations(completion); + when(patient.getLocation()).thenReturn(new Location(world, 10, 64, 0)); + move(); + assertEquals(1, state.getMoveCount(id)); + verifyNoInteractions(completion); + when(patient.getLocation()).thenReturn(new Location(world, 10.01, 64, 0)); + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + } - ailments.when(() -> Ailments.characterId(patient)).thenReturn("patient"); - state.markPatientChanged(id); - move(); - verify(completion).failSurgery(player, "failure-patient-left"); - assertEquals(1, state.getMoveCount(id)); + @ParameterizedTest + @ValueSource(strings = {"dead", "patient-switch-back", "surgeon-switch-back", "patient-pending", + "surgeon-pending", "provider-lost", "missing-patient-owner"}) + void treatmentCannotContinueAfterEitherParticipantSessionChanges(String change) { + move(); + assertEquals(1, state.getMoveCount(id)); + verifyNoInteractions(completion); + switch (change) { + case "dead" -> when(patient.isDead()).thenReturn(true); + case "provider-lost" -> characters.unregister(); + case "missing-patient-owner" -> state.setPatientUuid(id, null); + default -> { + Player participant = change.startsWith("patient") ? patient : player; + CharacterSession original = characters.invalidate(participant); + if (change.endsWith("switch-back")) { + characters.activate(participant); + characters.activate(participant, original.ref().characterId()); + } + } } + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + assertEquals(1, state.getMoveCount(id)); } @Test diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryPresentationTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryPresentationTest.java index ca64ff7..1454d81 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryPresentationTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryPresentationTest.java @@ -197,6 +197,7 @@ void menusInitializePatientStateAndOnlyStartingTools() { config.set("temperature.rising-temp-max", 104); try (MockedStatic randomness = mockStatic(ThreadLocalRandom.class)) { randomness.when(ThreadLocalRandom::current).thenReturn(random); + state.setPatientName(player.getUniqueId(), "Patient"); builder.buildAndOpenMenu(player); Inventory menu = player.getOpenInventory().getTopInventory(); assertInstanceOf(SurgeryMenuHolder.class, menu.getHolder()); diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryStateAndRequestTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryStateAndRequestTest.java index 8c94b7b..087c8fc 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryStateAndRequestTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryStateAndRequestTest.java @@ -1,12 +1,24 @@ package net.tfminecraft.surgery.managers; import net.tfminecraft.surgery.procedures.Procedure; +import net.tfminecraft.tlibs.character.CharacterIdentity; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import org.bukkit.plugin.ServicePriority; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import java.util.HashMap; +import java.util.Map; import java.util.Set; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; class SurgeryStateAndRequestTest { @Test @@ -122,38 +134,158 @@ private static void assertDefaults(SurgeryStateManager state, UUID id) { @Test void pendingOffersExpireAreConsumedOnceAndCanBeReplaced() { SurgeryRequestManager requests = new SurgeryRequestManager(); - UUID patient = UUID.randomUUID(), surgeon = UUID.randomUUID(); - assertNull(requests.pending(patient, 0)); - assertNull(requests.take(patient, 0)); - requests.offer(patient, surgeon, "leg", 100); - var request = requests.pending(patient, 99); - assertEquals(surgeon, request.surgeonId()); - assertEquals("leg", request.traitId()); - assertEquals(100, request.expiresAt()); - assertSame(request, requests.pending(patient, 100)); - assertSame(request, requests.take(patient, 100)); - assertNull(requests.take(patient, 100)); - requests.offer(patient, surgeon, "leg", 100); - assertNull(requests.pending(patient, 101)); - assertNull(requests.pending(patient, 99)); - requests.offer(patient, surgeon, "leg", 100); - assertNull(requests.take(patient, 101)); - requests.offer(patient, surgeon, "leg", 100); - requests.offer(patient, surgeon, "arm", 200); - assertEquals("arm", requests.pending(patient, 150).traitId()); + try (ConsentCharacters characters = new ConsentCharacters()) { + CharacterSession patient = characters.start(), surgeon = characters.start(); + UUID patientId = patient.ref().ownerId(); + assertNull(requests.pending(patientId, 0)); + assertNull(requests.take(patientId, 0)); + requests.offer(patient, surgeon, "leg", 100); + var request = requests.pending(patientId, 99); + assertEquals(surgeon.ref().ownerId(), request.surgeonId()); + assertEquals(surgeon, request.surgeonSession()); + assertEquals(patient, request.patientSession()); + assertTrue(request.isCurrent()); + assertEquals("leg", request.traitId()); + assertEquals(100, request.expiresAt()); + assertSame(request, requests.pending(patientId, 100)); + assertSame(request, requests.take(patientId, 100)); + assertNull(requests.take(patientId, 100)); + requests.offer(patient, surgeon, "leg", 100); + assertNull(requests.pending(patientId, 101)); + assertNull(requests.pending(patientId, 99)); + requests.offer(patient, surgeon, "leg", 100); + assertNull(requests.take(patientId, 101)); + requests.offer(patient, surgeon, "leg", 100); + requests.offer(patient, surgeon, "arm", 200); + assertEquals("arm", requests.pending(patientId, 150).traitId()); + } } @Test void quittingForgetsBothSentAndReceivedOffersOnlyForThatPlayer() { SurgeryRequestManager requests = new SurgeryRequestManager(); - UUID quitting = UUID.randomUUID(), first = UUID.randomUUID(), second = UUID.randomUUID(); - UUID unrelated = UUID.randomUUID(); - requests.offer(quitting, first, "leg", 100); - requests.offer(first, quitting, "arm", 100); - requests.offer(second, unrelated, "eye", 100); - requests.forget(quitting); - assertNull(requests.pending(quitting, 0)); - assertNull(requests.pending(first, 0)); - assertEquals(unrelated, requests.pending(second, 0).surgeonId()); + try (ConsentCharacters characters = new ConsentCharacters()) { + CharacterSession quitting = characters.start(), first = characters.start(); + CharacterSession second = characters.start(), unrelated = characters.start(); + requests.offer(quitting, first, "leg", 100); + requests.offer(first, quitting, "arm", 100); + requests.offer(second, unrelated, "eye", 100); + requests.forget(quitting); + assertNull(requests.pending(quitting.ref().ownerId(), 0)); + assertNull(requests.pending(first.ref().ownerId(), 0)); + assertEquals(unrelated.ref().ownerId(), requests.pending(second.ref().ownerId(), 0).surgeonId()); + requests.clear(); + assertNull(requests.pending(second.ref().ownerId(), 0)); + } + } + + @ParameterizedTest + @ValueSource(strings = {"patient", "surgeon", "provider"}) + void pendingAndTakeDiscardConsentWhenEitherCapturedSessionEnds(String changed) { + try (ConsentCharacters characters = new ConsentCharacters()) { + SurgeryRequestManager pending = new SurgeryRequestManager(); + SurgeryRequestManager taking = new SurgeryRequestManager(); + CharacterSession patient = characters.start(), surgeon = characters.start(); + UUID patientId = patient.ref().ownerId(); + pending.offer(patient, surgeon, "leg", 100); + taking.offer(patient, surgeon, "leg", 100); + var captured = pending.pending(patientId, 0); + if (changed.equals("provider")) { + characters.server.getServicesManager().unregister(CharacterIdentity.class, characters.identity); + } else { + CharacterSession original = changed.equals("patient") ? patient : surgeon; + UUID ownerId = original.ref().ownerId(); + characters.sessions.put(ownerId, new CharacterSession(new CharacterRef(ownerId, UUID.randomUUID()), 2)); + characters.sessions.put(ownerId, new CharacterSession(original.ref(), 3)); + } + assertFalse(captured.isCurrent()); + assertNull(pending.pending(patientId, 0)); + assertNull(taking.take(patientId, 0)); + characters.register(); + characters.sessions.put(patientId, patient); + characters.sessions.put(surgeon.ref().ownerId(), surgeon); + assertNull(pending.pending(patientId, 0)); + assertNull(taking.pending(patientId, 0)); + } + } + + @Test + void offersRequireBothReadyCurrentSessionsWithoutReplacingValidConsent() { + try (ConsentCharacters characters = new ConsentCharacters()) { + SurgeryRequestManager requests = new SurgeryRequestManager(); + CharacterSession patient = characters.start(), surgeon = characters.start(); + UUID patientId = patient.ref().ownerId(); + requests.offer(null, surgeon, "leg", 100); + requests.offer(patient, null, "leg", 100); + assertNull(requests.pending(patientId, 0)); + requests.offer(patient, surgeon, "leg", 100); + CharacterSession stalePatient = new CharacterSession(patient.ref(), 0); + CharacterSession staleSurgeon = new CharacterSession(surgeon.ref(), 0); + requests.offer(stalePatient, surgeon, "arm", 100); + requests.offer(patient, staleSurgeon, "arm", 100); + assertEquals("leg", requests.pending(patientId, 0).traitId()); + requests.clear(); + characters.server.getServicesManager().unregister(CharacterIdentity.class, characters.identity); + requests.offer(patient, surgeon, "leg", 100); + characters.register(); + assertNull(requests.pending(patientId, 0)); + } + } + + @Test + void operationTokensRejectReactivatedCharactersAndFinishDetachesOnlyOnce() { + try (ConsentCharacters characters = new ConsentCharacters()) { + SurgeryStateManager state = new SurgeryStateManager(); + CharacterSession surgeon = characters.start(), patient = characters.start(); + UUID id = surgeon.ref().ownerId(); + assertNull(state.getSurgeonSession(id)); assertNull(state.getPatientSession(id)); + assertEquals(0, state.getOperationId(id)); assertNull(state.finish(id)); + assertFalse(state.isOriginalSurgeon(id)); assertFalse(state.isOriginalPatient(id)); + state.setSurgeonSession(id, surgeon); state.setPatientSession(id, patient); + assertTrue(state.isCurrent(id)); + long operation = state.getOperationId(id); + characters.sessions.put(patient.ref().ownerId(), new CharacterSession(patient.ref(), 2)); + assertFalse(state.isCurrent(id)); + var outcome = state.finish(id); + assertEquals(operation, outcome.operationId()); + assertEquals(surgeon, outcome.surgeonSession()); assertEquals(patient, outcome.patientSession()); + assertEquals("Unknown", outcome.patientName()); assertEquals("Unknown ailment", outcome.ailmentName()); + assertFalse(outcome.operated()); assertFalse(state.hasSession(id)); + assertNull(state.finish(id)); + state.setPatientName(id, "Replacement"); + assertNotEquals(operation, state.getOperationId(id)); + state.clear(); assertFalse(state.hasSession(id)); + } + } + + private static final class ConsentCharacters implements AutoCloseable { + private final ServerMock server = MockBukkit.mock(); + private final CharacterIdentity identity = mock(CharacterIdentity.class); + private final Map sessions = new HashMap<>(); + private final org.bukkit.plugin.Plugin plugin = MockBukkit.createMockPlugin(); + + private ConsentCharacters() { + when(identity.isCurrent(any())).thenAnswer(call -> { + CharacterSession session = call.getArgument(0); + return session.equals(sessions.get(session.ref().ownerId())); + }); + register(); + } + + private void register() { + server.getServicesManager().unregister(CharacterIdentity.class, identity); + server.getServicesManager().register(CharacterIdentity.class, identity, plugin, ServicePriority.Normal); + } + + private CharacterSession start() { + CharacterSession session = new CharacterSession(new CharacterRef(UUID.randomUUID(), UUID.randomUUID()), 1); + sessions.put(session.ref().ownerId(), session); + return session; + } + + @Override + public void close() { + MockBukkit.unmock(); + } } }