From f31edd7a0c3adc5bb9f2ffa899fc0aae96fbe0da Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:39:45 +0000 Subject: [PATCH 1/2] fix: a patient who switches character mid-operation takes the failure penalty Switching after sedation or cutting ended the operation without the healing penalty, while quitting applied it. The session end fires while the outgoing character is still loaded, so the penalty now lands on it for both. A permakill still applies none. Co-Authored-By: Claude Opus 5.5 --- .../managers/SurgeryCompletionHandler.java | 11 +++++----- .../surgery/SurgeryLifecycleTest.java | 2 +- .../managers/SurgeryCompletionTest.java | 20 ++++++++++--------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java index a943f2d..33a1a7e 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java @@ -133,7 +133,8 @@ public void handleEnd(Player surgeon, CharacterSession ending, Reason reason) { Inventory menu = surgeryMenu(surgeon, outcome); boolean patientEnded = ending.equals(outcome.patientSession()); String extra = null; - if (outcome.operated() && (!patientEnded || reason == Reason.QUIT)) { + // A patient who quits or switches mid-operation pays the penalty; a permakill does not. + if (outcome.operated() && (!patientEnded || reason != Reason.DEATH)) { extra = lengthenHealing(outcome, patientEnded ? ending : null); } // Account-named commands never cross a session-end boundary. @@ -141,11 +142,11 @@ public void handleEnd(Player surgeon, CharacterSession ending, Reason reason) { reportFailure(surgeon, outcome, uiUpdater.getMessage(patientEnded ? "failure-patient-left" : "failure-gave-up"), extra); } - private String lengthenHealing(Outcome outcome, CharacterSession quittingPatient) { + private String lengthenHealing(Outcome outcome, CharacterSession leavingPatient) { 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 (leavingPatient != null && leavingPatient.equals(outcome.patientSession())) { + patient = Bukkit.getPlayer(leavingPatient.ref().ownerId()); + // During QUIT or SWITCH the outgoing character is still loaded, but its session is already invalid. if (patient == null || !patient.isOnline() || Characters.currentSession(patient).isPresent()) { return null; } diff --git a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java index 2c1401b..1029dd4 100644 --- a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java +++ b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java @@ -168,7 +168,7 @@ void menusWaitForBothReadyCharactersAndNeverUseProviderAbsenceAsReadiness() thro } @ParameterizedTest - @CsvSource({"patient,QUIT,true", "patient,SWITCH,false", "patient,DEATH,false", "patient,SHUTDOWN,false", + @CsvSource({"patient,QUIT,true", "patient,SWITCH,true", "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 { diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java index baf40ad..2c1569d 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java @@ -303,16 +303,17 @@ void providerLossCannotApplyPenaltyCommandOrCloseToAccounts() { assertTrue(closes.isEmpty()); } - @Test - void patientQuitPenalizesTheInvalidatedOutgoingBodyOnceWithoutCommands() { + @ParameterizedTest + @EnumSource(value = Reason.class, names = {"QUIT", "SWITCH"}) + void patientLeavingPenalizesTheInvalidatedOutgoingCharacterOnceWithoutCommands(Reason reason) { state.setOperated(surgeonId, true); 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); + completion.handleEnd(surgeon, outgoing, reason); + completion.handleEnd(surgeon, outgoing, reason); injuries.verify(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)); verifyNoCommand(); assertFalse(state.hasSession(surgeonId)); @@ -321,8 +322,8 @@ void patientQuitPenalizesTheInvalidatedOutgoingBodyOnceWithoutCommands() { } @ParameterizedTest - @EnumSource(value = Reason.class, names = {"SWITCH", "DEATH", "SHUTDOWN"}) - void otherPatientEndReasonsNeverPenalizeTheAccount(Reason reason) { + @EnumSource(value = Reason.class, names = {"DEATH", "SHUTDOWN"}) + void patientDeathOrShutdownNeverPenalizesTheAccount(Reason reason) { state.setOperated(surgeonId, true); config.set("commands.surgery-failure", "damage %player% 1"); completion.handleEnd(surgeon, characters.invalidate(patient), reason); @@ -355,12 +356,13 @@ void surgeonEndPenalizesTheCurrentPatientExceptDuringShutdown(Reason reason) { assertTrue(closes.isEmpty()); } - @Test - void outgoingPatientQuitCannotPenalizeAReplacementSession() { + @ParameterizedTest + @EnumSource(value = Reason.class, names = {"QUIT", "SWITCH"}) + void outgoingPatientCannotPenalizeAReplacementSession(Reason reason) { state.setOperated(surgeonId, true); CharacterSession outgoing = characters.invalidate(patient); characters.activate(patient); - completion.handleEnd(surgeon, outgoing, Reason.QUIT); + completion.handleEnd(surgeon, outgoing, reason); injuries.verifyNoInteractions(); assertFalse(state.hasSession(surgeonId)); } From 6a946b5e033ae10f6bcf64746c5b757e4018c912 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:53:52 +0000 Subject: [PATCH 2/2] fix: warn about an old RPCharacters and tell a departed physician why surgery failed With TLibs 2.3.0 but RPCharacters older than 2.16.0 there is no character provider, so every offer failed as "Both characters must be ready" with nothing logged. Surgery now warns on the first tick when RPCharacters is enabled but provides no character identity. The failure report required the physician's session to be current, so a physician who switched character or was permakilled heard nothing. It is an account message, so it now reaches the physician whenever they are online. Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/surgery/SurgeryPlugin.java | 12 ++++++++++ .../managers/SurgeryCompletionHandler.java | 9 +++---- .../surgery/SurgeryLifecycleTest.java | 22 +++++++++++++++++ .../managers/SurgeryCompletionTest.java | 24 +++++++++++++++++-- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java b/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java index 06ad38c..76aeef4 100644 --- a/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java +++ b/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java @@ -43,9 +43,21 @@ public void onEnable() { getCommand("surgery").setExecutor(surgeryCommand); getCommand("surgery").setTabCompleter(surgeryCommand); + // The first tick runs once every plugin has enabled. + getServer().getScheduler().runTask(this, this::warnIfNoCharacterProvider); + getLogger().info("surgery has been enabled!"); } + /** RPCharacters before 2.16.0 registers no TLibs character provider, so every offer would fail. */ + void warnIfNoCharacterProvider() { + if (getServer().getPluginManager().isPluginEnabled("RPCharacters") + && !net.tfminecraft.tlibs.character.Characters.available()) { + getLogger().warning("RPCharacters provides no character identity, so every surgery offer will fail." + + " Surgery requires RPCharacters 2.16.0 or newer."); + } + } + @Override public void onDisable() { // Unregister listeners first: closing menus below would otherwise fire diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java index 33a1a7e..2e9ef20 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java @@ -105,7 +105,7 @@ public void failSurgery(Player surgeon, String message) { String extra = outcome.operated() ? lengthenHealing(outcome, null) : null; executeCompletionCommand(surgeon, outcome, false); closeMenuNextTick(surgeon, outcome, menu); - reportFailure(surgeon, outcome, message, extra); + reportFailure(surgeon, message, extra); } public void handleAbandonment(Player surgeon) { @@ -139,7 +139,7 @@ public void handleEnd(Player surgeon, CharacterSession ending, Reason reason) { } // 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); + reportFailure(surgeon, uiUpdater.getMessage(patientEnded ? "failure-patient-left" : "failure-gave-up"), extra); } private String lengthenHealing(Outcome outcome, CharacterSession leavingPatient) { @@ -176,8 +176,9 @@ private String lengthenHealing(Outcome outcome, CharacterSession leavingPatient) return extra; } - private void reportFailure(Player surgeon, Outcome outcome, String message, String extra) { - if (!isCurrent(surgeon, outcome.surgeonSession())) { + /** Tells the account, so a physician who switched away or was permakilled still learns the outcome. */ + private void reportFailure(Player surgeon, String message, String extra) { + if (!surgeon.isOnline() || Bukkit.getPlayer(surgeon.getUniqueId()) != surgeon) { return; } surgeon.playSound(surgeon.getLocation(), Sound.ENTITY_WITHER_DEATH, 1.0f, 0.8f); diff --git a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java index 1029dd4..9af1fc6 100644 --- a/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java +++ b/src/test/java/net/tfminecraft/surgery/SurgeryLifecycleTest.java @@ -143,6 +143,28 @@ void missingCharacterApiDisablesBeforeInitializationWithAnUpgradeMessage() { } } + @Test + void warnsAfterStartupOnlyWhenEnabledRPCharactersProvidesNoIdentity() { + SurgeryPlugin plugin = MockBukkit.load(SurgeryPlugin.class); + java.util.List warnings = new java.util.ArrayList<>(); + plugin.getLogger().addHandler(new java.util.logging.Handler() { + @Override public void publish(java.util.logging.LogRecord record) { + if (record.getLevel() == java.util.logging.Level.WARNING) warnings.add(record.getMessage()); + } + @Override public void flush() {} + @Override public void close() {} + }); + server.getScheduler().performOneTick(); + assertTrue(warnings.isEmpty()); + characters.unregister(); + plugin.warnIfNoCharacterProvider(); + assertEquals(1, warnings.size()); + assertTrue(warnings.getFirst().contains("RPCharacters 2.16.0 or newer")); + server.getPluginManager().disablePlugin(server.getPluginManager().getPlugin("RPCharacters")); + plugin.warnIfNoCharacterProvider(); + assertEquals(1, warnings.size()); + } + @Test void missingSharedItemApiDisablesPluginBeforeCommandRegistration() { tlibs.when(TLibs::getItemAPI).thenReturn(null); diff --git a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java index 2c1569d..1d4f0e9 100644 --- a/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java +++ b/src/test/java/net/tfminecraft/surgery/managers/SurgeryCompletionTest.java @@ -287,7 +287,7 @@ void switchedSurgeonSkipsCommandButUnchangedPatientTakesPenalty() { verifyNoCommand(); injuries.verify(() -> HealingInjuries.extend(patient, "leg", 3_600_000L)); verify(patient).sendMessage("patient-surgery-failed"); - verify(surgeon, never()).sendMessage(anyString()); + verify(surgeon).sendMessage("failure-gave-up"); assertTrue(closes.isEmpty()); } @@ -352,10 +352,30 @@ void surgeonEndPenalizesTheCurrentPatientExceptDuringShutdown(Reason reason) { } verifyNoCommand(); assertFalse(state.hasSession(surgeonId)); - verify(surgeon, never()).sendMessage(anyString()); + if (reason == Reason.SHUTDOWN) { + verify(surgeon, never()).sendMessage(anyString()); + } else { + // The physician's own session has ended, but the account still hears why. + verify(surgeon).sendMessage("surgery-failed"); + verify(surgeon).sendMessage("failure-gave-up"); + verify(surgeon).sendMessage("surgery-failed-penalty"); + } assertTrue(closes.isEmpty()); } + @Test + void failureMessagesReachOnlyTheOnlineAccount() { + state.setOperated(surgeonId, true); + when(surgeon.isOnline()).thenReturn(false); + completion.handleEnd(surgeon, characters.invalidate(surgeon), Reason.QUIT); + characters.activate(surgeon); + when(surgeon.isOnline()).thenReturn(true); + beginOperation("leg", "Broken leg"); + bukkit.when(() -> Bukkit.getPlayer(surgeonId)).thenReturn(mock(Player.class)); + completion.failSurgery(surgeon, "stale player"); + verify(surgeon, never()).sendMessage(anyString()); + } + @ParameterizedTest @EnumSource(value = Reason.class, names = {"QUIT", "SWITCH"}) void outgoingPatientCannotPenalizeAReplacementSession(Reason reason) {