From 7ae61827788387eeda1a112c85d1602a624204d2 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:45:35 +0000 Subject: [PATCH 1/2] fix: end surgery when the patient or surgeon changes character The operation records the patient's character when it begins. A character switch or permakill now ends it, and the cure or failure penalty only lands on the character who was on the table. Co-Authored-By: Claude Opus 5.5 --- .../surgery/listeners/PlayerListener.java | 8 +++++ .../managers/SurgeryCompletionHandler.java | 11 ++++++- .../surgery/managers/SurgeryMenuManager.java | 23 +++++++++++++ .../surgery/managers/SurgeryStateManager.java | 4 +++ .../surgery/procedures/Ailments.java | 10 ++++++ .../surgery/SurgeryLifecycleTest.java | 32 +++++++++++++++++++ .../managers/SurgeryCompletionTest.java | 23 +++++++++++++ 7 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java index 88bc21b..f34a6f9 100644 --- a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java +++ b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java @@ -8,6 +8,7 @@ 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.surgery.managers.SurgeryMenuManager; public class PlayerListener implements Listener { @@ -32,6 +33,13 @@ public void onPlayerQuit(PlayerQuitEvent event) { 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 public void onInventoryDrag(InventoryDragEvent event) { // Block drags entirely while the surgery menu is open; drag events are not diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java index 81a40fb..c20378a 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java @@ -7,6 +7,7 @@ import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; +import java.util.Objects; import java.util.UUID; // ============================================== @@ -185,9 +186,17 @@ public void handleQuit(Player player) { stateManager.cleanup(playerId); } + // ============================================== + // The patient, or null once they have left or switched to another + // character, so a cure or penalty never lands on a different character + // ============================================== private Player patientOf(UUID surgeonId) { UUID patientId = stateManager.getPatientUuid(surgeonId); - return patientId == null ? null : Bukkit.getPlayer(patientId); + Player patient = patientId == null ? null : Bukkit.getPlayer(patientId); + if (patient == null || !Objects.equals(stateManager.getPatientCharacterId(surgeonId), Ailments.characterId(patient))) { + return null; + } + return patient; } // ============================================== diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java index ae8a618..be85d45 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java @@ -1,6 +1,7 @@ package net.tfminecraft.surgery.managers; import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; +import net.tfminecraft.surgery.procedures.Ailments; import net.tfminecraft.surgery.procedures.ProcedureRegistry; import net.tfminecraft.tlibs.objects.api.ItemAPI; import net.tfminecraft.tlibs.TLibs; @@ -66,6 +67,7 @@ public void initialize() { public void openSurgeryMenu(Player surgeon, Player patient, HealingInjury ailment) { stateManager.setPatientName(surgeon.getUniqueId(), patient.getName()); stateManager.setPatientUuid(surgeon.getUniqueId(), patient.getUniqueId()); + stateManager.setPatientCharacterId(surgeon.getUniqueId(), Ailments.characterId(patient)); stateManager.setAilment(surgeon.getUniqueId(), ailment.traitId(), ailment.displayName(), procedures.get(ailment.traitId())); menuBuilder.buildAndOpenMenu(surgeon); @@ -112,6 +114,27 @@ public void handlePatientQuit(Player patient) { if (surgeonId == null) { return; } + failForMissingPatient(surgeonId); + } + + // ============================================== + // 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 + // ============================================== + public void handleCharacterActivated(java.util.UUID ownerId, String characterId) { + Player surgeon = plugin.getServer().getPlayer(ownerId); + if (surgeon != null && stateManager.hasSession(ownerId)) { + completionHandler.handleAbandonment(surgeon); + surgeon.closeInventory(); + } + java.util.UUID surgeonId = stateManager.findSurgeonForPatient(ownerId); + if (surgeonId != null && !java.util.Objects.equals(characterId, stateManager.getPatientCharacterId(surgeonId))) { + failForMissingPatient(surgeonId); + } + } + + private void failForMissingPatient(java.util.UUID surgeonId) { Player surgeon = plugin.getServer().getPlayer(surgeonId); if (surgeon != null && surgeon.isOnline()) { completionHandler.failSurgery(surgeon, uiUpdater.getMessage("failure-patient-left", diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java index 11d1130..a434bf6 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java @@ -19,6 +19,8 @@ public class SurgeryStateManager { private static class SurgerySession { String patientName; UUID patientUuid; + // The patient's character when the operation began + String patientCharacterId; String traitId; String ailmentName; Procedure procedure; @@ -89,6 +91,7 @@ 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 boolean hasSession(UUID playerId) { return sessions.containsKey(playerId); } // ============================================== @@ -141,6 +144,7 @@ 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 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 d626ef5..1a0e407 100644 --- a/src/main/java/net/tfminecraft/surgery/procedures/Ailments.java +++ b/src/main/java/net/tfminecraft/surgery/procedures/Ailments.java @@ -2,6 +2,9 @@ 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; @@ -41,4 +44,11 @@ public static boolean cure(Player patient, String traitId) { 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/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java index e603407..fb5074b 100644 --- a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java +++ b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java @@ -1,6 +1,8 @@ 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.surgery.listeners.PlayerListener; import net.tfminecraft.surgery.managers.*; import net.tfminecraft.tlibs.TLibs; @@ -123,6 +125,36 @@ void managerDelegatesAbandonmentAndDisconnectsWithoutLeakingSessions() throws Ex server.getScheduler().performOneTick(); } + @Test + void characterChangeEndsTheOperationForSurgeonOrSwitchedPatient() throws Exception { + SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); + 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(); + + 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())); + menus.handleCharacterActivated(UUID.randomUUID(), "other"); + 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"); + } + @Test void listenersPreventMenuItemMovementAndRouteOnlyTopInventoryActions() { SurgeryMenuManager menus = mock(SurgeryMenuManager.class); diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java index 81b318d..de96479 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java @@ -202,6 +202,29 @@ void abandonmentBeforeTreatmentCancelsAndAfterTreatmentFails() { assertFalse(state.hasSession(surgeonId)); } + @Test + void cureAndPenaltyLandOnlyOnTheCharacterOperatedOn() { + state.setPatientCharacterId(surgeonId, "original"); + state.setOperated(surgeonId, true); + 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.failSurgery(surgeon, "failed"); + completion.handleSuccess(surgeon); + injuries.verifyNoInteractions(); + verify(surgeon).sendMessage("ailment-already-healed"); + verify(patient, never()).sendMessage(anyString()); + + state.setPatientUuid(surgeonId, patientId); + state.setPatientCharacterId(surgeonId, "original"); + state.setAilment(surgeonId, "leg", "Broken leg", null); + characters.when(() -> Ailments.characterId(patient)).thenReturn("original"); + completion.handleSuccess(surgeon); + injuries.verify(() -> HealingInjuries.cure(patient, "leg")); + verify(patient).sendMessage("patient-surgery-successful"); + } + } + @Test void ailmentQueriesSelectWorstAndFindCaseInsensitively() throws Exception { HealingInjury shortInjury = new HealingInjury("arm", "Arm", 1_000); From 99c2c4a88ac3098216bafefed77c85d57d3ed313 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:31:43 +0000 Subject: [PATCH 2/2] fix: end surgery on permakill and skip commands after a character change The operation now records both participants' characters. Configured completion commands name accounts, so they are skipped once either participant has switched character or been permakilled; the built-in failure penalty still lands on a patient who is unchanged. A confirmed permakill ends the operation on its own, which covers an account's last character where no other character takes over. The presence check also turns away a dead patient or one now playing another character. The quit listener runs before RPCharacters unloads the quitting player, so a quit still finds the same character. Co-Authored-By: Claude Opus 5.5 --- .../surgery/listeners/PlayerListener.java | 13 ++- .../managers/SurgeryCompletionHandler.java | 29 ++++++- .../managers/SurgeryMechanicsManager.java | 9 +- .../surgery/managers/SurgeryMenuManager.java | 55 +++++++++--- .../surgery/managers/SurgeryStateManager.java | 27 +++++- .../surgery/SurgeryLifecycleTest.java | 67 ++++++++++++++- .../managers/SurgeryCompletionTest.java | 83 ++++++++++++++++--- .../managers/SurgeryMechanicsManagerTest.java | 37 +++++++++ 8 files changed, 291 insertions(+), 29 deletions(-) diff --git a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java index f34a6f9..7ac56f2 100644 --- a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java +++ b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java @@ -3,12 +3,14 @@ import org.bukkit.entity.Player; 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.surgery.managers.SurgeryMenuManager; public class PlayerListener implements Listener { @@ -19,7 +21,9 @@ public PlayerListener(SurgeryMenuManager menuManager) { this.menuManager = menuManager; } - @EventHandler + // 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 @@ -40,6 +44,13 @@ public void onCharacterActivated(CharacterActivatedEvent event) { 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 public void onInventoryDrag(InventoryDragEvent event) { // Block drags entirely while the surgery menu is open; drag events are not diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java index c20378a..65cd961 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java @@ -7,7 +7,6 @@ import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; -import java.util.Objects; import java.util.UUID; // ============================================== @@ -187,23 +186,45 @@ public void handleQuit(Player player) { } // ============================================== - // The patient, or null once they have left or switched to another - // character, so a cure or penalty never lands on a different character + // 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 || !Objects.equals(stateManager.getPatientCharacterId(surgeonId), Ailments.characterId(patient))) { + if (patient == null || !stateManager.isOriginalPatient(surgeonId, Ailments.characterId(patient))) { return null; } return patient; } + // ============================================== + // 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)); + } + // ============================================== // 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)) { + return; + } String patientName = stateManager.getPatientName(surgeonId); String configKey = success ? "commands.surgery-success" : "commands.surgery-failure"; diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java index 5f1d0e2..8a2ec1d 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java @@ -1,5 +1,6 @@ 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; @@ -182,7 +183,8 @@ public void processMoveEffects(Player player, SurgeryTool clickedTool) { } // ============================================== - // Checks the patient is still online, in the same world, and within range + // Checks the patient is still online, alive, the same character, in the + // same world, and within range // Range gets a configurable multiplier so normal fidgeting doesn't end the surgery // ============================================== private boolean isPatientPresent(Player surgeon) { @@ -191,7 +193,10 @@ private boolean isPatientPresent(Player surgeon) { return true; } Player patient = Bukkit.getPlayer(patientUuid); - if (patient == null || !patient.isOnline()) { + 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())) { diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java index be85d45..62d6cd3 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java @@ -9,6 +9,9 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.java.JavaPlugin; +import java.util.Objects; +import java.util.UUID; + // ============================================== // Main file for the surgery menu system // Delegates manager files for different responsibilities @@ -67,6 +70,7 @@ public void initialize() { public void openSurgeryMenu(Player surgeon, Player patient, HealingInjury ailment) { 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.setAilment(surgeon.getUniqueId(), ailment.traitId(), ailment.displayName(), procedures.get(ailment.traitId())); @@ -110,7 +114,7 @@ public void handleSurgeonQuit(Player player) { // their next move to notice the patient is gone // ============================================== public void handlePatientQuit(Player patient) { - java.util.UUID surgeonId = stateManager.findSurgeonForPatient(patient.getUniqueId()); + UUID surgeonId = stateManager.findSurgeonForPatient(patient.getUniqueId()); if (surgeonId == null) { return; } @@ -122,19 +126,50 @@ public void handlePatientQuit(Player patient) { // A surgeon who changes character gives up; a patient who changes // character has left the table, the same as quitting // ============================================== - public void handleCharacterActivated(java.util.UUID ownerId, String characterId) { - Player surgeon = plugin.getServer().getPlayer(ownerId); - if (surgeon != null && stateManager.hasSession(ownerId)) { - completionHandler.handleAbandonment(surgeon); - surgeon.closeInventory(); + 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); + } + } + + // ============================================== + // 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); } - java.util.UUID surgeonId = stateManager.findSurgeonForPatient(ownerId); - if (surgeonId != null && !java.util.Objects.equals(characterId, stateManager.getPatientCharacterId(surgeonId))) { - failForMissingPatient(surgeonId); + } + + // The surgeon gives up; an unchanged patient still takes the failure penalty + private void endForSurgeonChange(UUID surgeonId) { + stateManager.markSurgeonChanged(surgeonId); + 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); } - private void failForMissingPatient(java.util.UUID surgeonId) { + private void failForMissingPatient(UUID surgeonId) { Player surgeon = plugin.getServer().getPlayer(surgeonId); if (surgeon != null && surgeon.isOnline()) { completionHandler.failSurgery(surgeon, uiUpdater.getMessage("failure-patient-left", diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java index a434bf6..a4ef438 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java @@ -4,6 +4,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.UUID; // ============================================== @@ -19,8 +20,12 @@ public class SurgeryStateManager { private static class SurgerySession { String patientName; UUID patientUuid; - // The patient's character when the operation began + // 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; String traitId; String ailmentName; Procedure procedure; @@ -92,6 +97,7 @@ private SurgerySession get(UUID playerId) { 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 boolean hasSession(UUID playerId) { return sessions.containsKey(playerId); } // ============================================== @@ -110,6 +116,22 @@ 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 isOriginalPatient(UUID playerId, String characterId) { + SurgerySession s = get(playerId); + return s != null && !Boolean.TRUE.equals(s.patientChanged) && Objects.equals(s.patientCharacterId, characterId); + } + + public boolean hasPatientChanged(UUID playerId) { SurgerySession s = get(playerId); return s != null && Boolean.TRUE.equals(s.patientChanged); } + // ============================================== // Setters // ============================================== @@ -145,6 +167,9 @@ public void setAilment(UUID playerId, String traitId, String ailmentName, Proced 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 removeCollapseCountdown(UUID playerId) { SurgerySession s = get(playerId); if (s != null) { s.collapseCountdown = null; } } // ============================================== diff --git a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java index fb5074b..1d9c986 100644 --- a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java +++ b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java @@ -3,12 +3,18 @@ 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.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.inventory.*; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.inventory.Inventory; @@ -144,7 +150,10 @@ void characterChangeEndsTheOperationForSurgeonOrSwitchedPatient() throws Excepti menus.handleCharacterActivated(surgeon.getUniqueId(), "other"); assertFalse(state.hasSession(surgeon.getUniqueId())); assertFalse(menus.isSurgeryMenu(surgeon.getOpenInventory().getTopInventory())); - menus.handleCharacterActivated(UUID.randomUUID(), "other"); + UUID offlineSurgeon = UUID.randomUUID(); + state.setPatientName(offlineSurgeon, "Patient"); + menus.handleCharacterActivated(offlineSurgeon, "other"); + assertFalse(state.hasSession(offlineSurgeon)); server.getScheduler().performOneTick(); SurgeryMenuManager mocked = mock(SurgeryMenuManager.class); @@ -155,6 +164,62 @@ void characterChangeEndsTheOperationForSurgeonOrSwitchedPatient() throws Excepti verify(mocked).handleCharacterActivated(patient.getUniqueId(), "next"); } + 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 permakillEndsOnlyTheDyingCharactersOperation() 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(); + } + } + + @Test + void quitRunsBeforeRPCharactersUnloadsTheCharacter() throws Exception { + EventHandler handler = PlayerListener.class.getMethod("onPlayerQuit", PlayerQuitEvent.class) + .getAnnotation(EventHandler.class); + assertEquals(EventPriority.LOW, handler.priority()); + } + @Test void listenersPreventMenuItemMovementAndRouteOnlyTopInventoryActions() { SurgeryMenuManager menus = mock(SurgeryMenuManager.class); diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java index de96479..0bc5adb 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java @@ -203,26 +203,89 @@ void abandonmentBeforeTreatmentCancelsAndAfterTreatmentFails() { } @Test - void cureAndPenaltyLandOnlyOnTheCharacterOperatedOn() { + void failureOnASwitchedPatientSkipsCommandAndPenalty() { state.setPatientCharacterId(surgeonId, "original"); state.setOperated(surgeonId, true); - injuries.when(() -> HealingInjuries.cure(patient, "leg")).thenReturn(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()); + injuries.verifyNoInteractions(); + verify(surgeon).sendMessage("failed"); + verify(patient, never()).sendMessage(anyString()); + assertFalse(state.hasSession(surgeonId)); + } + + @Test + void cureSkipsASwitchedPatient() { + state.setPatientCharacterId(surgeonId, "original"); + 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); - injuries.verifyNoInteractions(); - verify(surgeon).sendMessage("ailment-already-healed"); - verify(patient, never()).sendMessage(anyString()); + } + injuries.verifyNoInteractions(); + bukkit.verify(() -> Bukkit.dispatchCommand(eq(console), anyString()), never()); + verify(surgeon).sendMessage("ailment-already-healed"); + verify(patient, never()).sendMessage(anyString()); + } - state.setPatientUuid(surgeonId, patientId); - state.setPatientCharacterId(surgeonId, "original"); - state.setAilment(surgeonId, "leg", "Broken leg", null); + @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")); - verify(patient).sendMessage("patient-surgery-successful"); } + injuries.verify(() -> HealingInjuries.cure(patient, "leg")); + bukkit.verify(() -> Bukkit.dispatchCommand(console, "reward Patient")); + verify(patient).sendMessage("patient-surgery-successful"); + } + + @Test + void switchedSurgeonSkipsCommandButUnchangedPatientTakesPenalty() { + state.setSurgeonCharacterId(surgeonId, "doctor"); + 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()); + injuries.verify(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)); + verify(patient).sendMessage("patient-surgery-failed"); + verify(surgeon).sendMessage("failure-gave-up"); + } + + @Test + void markedCharacterChangesSkipCommandsEvenForALoggedOffPatient() { + 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"); + + bukkit.when(() -> Bukkit.getPlayer(patientId)).thenReturn(null); + state.setPatientUuid(surgeonId, patientId); + state.setOperated(surgeonId, true); + state.markPatientChanged(surgeonId); + completion.failSurgery(surgeon, "patient died"); + bukkit.verify(() -> Bukkit.dispatchCommand(eq(console), anyString()), never()); + + state.setPatientUuid(surgeonId, patientId); + state.setPatientName(surgeonId, "Patient"); + state.setOperated(surgeonId, true); + completion.failSurgery(surgeon, "patient logged off"); + bukkit.verify(() -> Bukkit.dispatchCommand(console, "damage Patient 1")); } @Test diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java index 0bcf8fe..f608308 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManagerTest.java @@ -1,5 +1,6 @@ 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; @@ -247,6 +248,42 @@ void patientMustRemainOnlineInTheSameWorldAndInsideConfiguredRange() { } } + @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); + when(patient.isOnline()).thenReturn(true); + 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); + + ailments.when(() -> Ailments.characterId(patient)).thenReturn("patient"); + state.markPatientChanged(id); + move(); + verify(completion).failSurgery(player, "failure-patient-left"); + assertEquals(1, state.getMoveCount(id)); + } + } + @Test void complicationsWaitForExaminationThenHaemorrhageAndShockAddTools() { procedure(2, 0, 0, Complication.HAEMORRHAGE, Complication.SHOCK);