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();
+ }
}
}