diff --git a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java index 88bc21b..7ac56f2 100644 --- a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java +++ b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java @@ -3,11 +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 { @@ -18,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 @@ -32,6 +37,20 @@ 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(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 81a40fb..65cd961 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java @@ -185,16 +185,46 @@ public void handleQuit(Player player) { stateManager.cleanup(playerId); } + // ============================================== + // 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); - return patientId == null ? null : Bukkit.getPlayer(patientId); + Player patient = patientId == null ? null : Bukkit.getPlayer(patientId); + 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 ae8a618..62d6cd3 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; @@ -8,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 @@ -66,6 +70,8 @@ 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())); menuBuilder.buildAndOpenMenu(surgeon); @@ -108,10 +114,62 @@ 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; } + 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(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); + } + } + + // 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(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..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,6 +20,12 @@ 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; String traitId; String ailmentName; Procedure procedure; @@ -89,6 +96,8 @@ 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 boolean hasSession(UUID playerId) { return sessions.containsKey(playerId); } // ============================================== @@ -107,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 // ============================================== @@ -141,6 +166,10 @@ 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 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..1d9c986 100644 --- a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java +++ b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java @@ -1,12 +1,20 @@ 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.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; @@ -123,6 +131,95 @@ 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())); + UUID offlineSurgeon = UUID.randomUUID(); + state.setPatientName(offlineSurgeon, "Patient"); + menus.handleCharacterActivated(offlineSurgeon, "other"); + assertFalse(state.hasSession(offlineSurgeon)); + 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"); + } + + 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 81b318d..0bc5adb 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java @@ -202,6 +202,92 @@ void abandonmentBeforeTreatmentCancelsAndAfterTreatmentFails() { assertFalse(state.hasSession(surgeonId)); } + @Test + void failureOnASwitchedPatientSkipsCommandAndPenalty() { + state.setPatientCharacterId(surgeonId, "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()); + 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(); + 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"); + } + + @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 void ailmentQueriesSelectWorstAndFindCaseInsensitively() throws Exception { HealingInjury shortInjury = new HealingInjury("arm", "Arm", 1_000); 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);