diff --git a/README.md b/README.md index b8f8fe2..7995eda 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,18 @@ > A hands-on medical roleplay minigame for TF-Minecraft. -Surgery puts a player's treatment into another player's hands. A nearby patient becomes the subject of an interactive operating-table menu, where the surgeon diagnoses the problem, chooses tools, and manages the patient's changing condition. +Surgery lets a physician mend another character's injuries on the operating table. It treats the healing ailments from [RPCharacters](https://github.com/TF-Minecraft/RPCharacters), such as a broken arm, a broken leg, or a half-blind eye, using Victorian medicine. -Each move matters: opening an incision, cleaning the site, treating a complication, or closing the wound can change what the surgeon needs to do next. +Each move matters: sedating the patient, cleaning the site, opening an incision, setting a bone, or closing the wound can change what the surgeon needs to do next. ## Features -- **Interactive operations** — an inventory menu brings surgical tools and patient status together in one view. -- **Hidden diagnoses** — ultrasound reveals ailments ranging from broken bones and infections to fantastical conditions. -- **Purposeful instruments** — tools include scalpels, anesthetic, sponges, stitches, transfusions, and a defibrillator. +- **Real ailments** — the operation targets the patient's own healing injury. A success cures it; a failure makes it take longer to heal. Permanent injuries cannot be operated on. +- **Physicians only** — operating requires the Physician profession upgrade, the same permission that unlocks crafting the instruments. +- **Patient consent** — the patient must accept the surgeon's offer, which states the ailment and what a failure would cost. +- **Victorian instruments** — stethoscope, chloroform, carbolic acid, scalpel, catgut suture, artery forceps, splint, silver wire, smelling salts, transfusion syringe, clinical thermometer, willow-bark tincture, and linen dressing. - **Changing patient vitals** — consciousness, pulse, temperature, bleeding, and cleanliness affect the course of treatment. -- **Diagnosis-specific challenges** — different ailments require different interventions, including bone repairs and temperature management. -- **Consequences for each move** — failed actions and worsening complications create pressure to finish treatment before the operation fails. +- **Procedures and complications** — each ailment has its own procedure, with bones to set and complications such as haemorrhage, shock, and sepsis. Originally created by [Justinas Launikonis](https://github.com/JustinasLa). diff --git a/pom.xml b/pom.xml index a2ad49d..2884d0a 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,7 @@ 21 UTF-8 + 2.4.0 2.0.0 @@ -39,6 +40,26 @@ + + + net.tfminecraft + rpcharacters + ${rpcharacters.version} + provided + + + * + * + + + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + @@ -71,6 +92,11 @@ maven-compiler-plugin 3.14.1 + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + diff --git a/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java b/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java index 56c7abe..86a8615 100644 --- a/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java +++ b/src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java @@ -32,7 +32,9 @@ public void onEnable() { } getServer().getPluginManager().registerEvents(new PlayerListener(surgeryMenuManager), this); - getCommand("surgery").setExecutor(new SurgeryCommand(surgeryMenuManager, this)); + SurgeryCommand surgeryCommand = new SurgeryCommand(surgeryMenuManager, this); + getCommand("surgery").setExecutor(surgeryCommand); + getCommand("surgery").setTabCompleter(surgeryCommand); getLogger().info("surgery has been enabled!"); } diff --git a/src/main/java/net/tfminecraft/surgery/commands/SurgeryCommand.java b/src/main/java/net/tfminecraft/surgery/commands/SurgeryCommand.java index f1dfada..21cbe7d 100644 --- a/src/main/java/net/tfminecraft/surgery/commands/SurgeryCommand.java +++ b/src/main/java/net/tfminecraft/surgery/commands/SurgeryCommand.java @@ -1,60 +1,179 @@ package net.tfminecraft.surgery.commands; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; +import net.tfminecraft.surgery.SurgeryPlugin; +import net.tfminecraft.surgery.managers.SurgeryMenuManager; +import net.tfminecraft.surgery.managers.SurgeryRequestManager; +import net.tfminecraft.surgery.managers.SurgeryUIUpdater; +import net.tfminecraft.surgery.procedures.Ailments; +import net.tfminecraft.surgery.procedures.Durations; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; import org.bukkit.entity.Player; -import net.tfminecraft.surgery.managers.SurgeryMenuManager; -import net.tfminecraft.surgery.managers.SurgeryUIUpdater; -import net.tfminecraft.surgery.SurgeryPlugin; -public class SurgeryCommand implements CommandExecutor { +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class SurgeryCommand implements TabExecutor { private final SurgeryMenuManager menuManager; private final SurgeryPlugin plugin; private final SurgeryUIUpdater uiUpdater; + private final SurgeryRequestManager requests; public SurgeryCommand(SurgeryMenuManager menuManager, SurgeryPlugin plugin) { this.menuManager = menuManager; this.plugin = plugin; this.uiUpdater = menuManager.getUiUpdater(); + this.requests = menuManager.getRequestManager(); } @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (!(sender instanceof Player)) { + if (!(sender instanceof Player player)) { sender.sendMessage(uiUpdater.getMessage("command-console", "&cThis command can only be used by players.")); return true; } - Player surgeon = (Player) sender; - - // 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())) { - surgeon.sendMessage(uiUpdater.getMessage("command-already-in-surgery", - "&cYou are already performing surgery! Close the current menu first.")); + if (args.length == 0) { + player.sendMessage(uiUpdater.getMessage("command-usage", "&cUsage: /surgery | accept | deny")); return true; } - // Check if player name is provided in command - if (args.length == 0) { - surgeon.sendMessage(uiUpdater.getMessage("command-usage", "&cUsage: /surgery ")); - return true; + String sub = args[0].toLowerCase(Locale.ROOT); + if (sub.equals("accept")) { + accept(player); + } else if (sub.equals("deny")) { + deny(player); + } else { + offer(player, args[0]); + } + return true; + } + + // ============================================== + // Surgeon offers to operate on a patient's worst healing ailment + // ============================================== + private void offer(Player surgeon, String patientName) { + if (!canOperate(surgeon)) { + surgeon.sendMessage(uiUpdater.getMessage("command-no-permission")); + return; } - // Get the target player (patient) - Player patient = Bukkit.getPlayer(args[0]); + Player patient = Bukkit.getPlayerExact(patientName); if (patient == null || !patient.isOnline()) { surgeon.sendMessage(uiUpdater.getMessage("command-player-not-found", "&cPlayer not found or not online!")); - return true; + return; + } + if (!checkCanStart(surgeon, patient)) { + return; + } + + HealingInjury ailment = Ailments.mostSevere(patient); + if (ailment == null) { + surgeon.sendMessage(uiUpdater.getMessage("command-no-ailment").replace("%patient%", patient.getName())); + return; + } + + long now = System.currentTimeMillis(); + if (requests.pending(patient.getUniqueId(), now) != null) { + surgeon.sendMessage(uiUpdater.getMessage("request-already-pending").replace("%patient%", patient.getName())); + return; + } + + int timeoutSeconds = Math.max(10, plugin.getConfig().getInt("request-timeout-seconds", 60)); + requests.offer(patient.getUniqueId(), surgeon.getUniqueId(), ailment.traitId(), + now + timeoutSeconds * 1000L); + + String ailmentName = ChatColor.stripColor(ailment.displayName()); + String penalty = plugin.getConfig().getString("failure-healing-penalty", "12h"); + long penaltyMs = Durations.parseMs(penalty); + surgeon.sendMessage(uiUpdater.getMessage("request-sent") + .replace("%patient%", patient.getName()).replace("%ailment%", ailmentName)); + patient.sendMessage(uiUpdater.getMessage("request-received") + .replace("%surgeon%", surgeon.getName()) + .replace("%ailment%", ailmentName) + .replace("%remaining%", Durations.formatHours(ailment.remainingMs())) + .replace("%extra%", penaltyMs > 0 ? Durations.formatHours(penaltyMs) : penalty) + .replace("%seconds%", String.valueOf(timeoutSeconds))); + patient.sendMessage(LegacyComponentSerializer.legacySection() + .deserialize(uiUpdater.getMessage("request-accept-button")) + .clickEvent(ClickEvent.runCommand("/surgery accept")) + .append(LegacyComponentSerializer.legacySection().deserialize(" ")) + .append(LegacyComponentSerializer.legacySection() + .deserialize(uiUpdater.getMessage("request-deny-button")) + .clickEvent(ClickEvent.runCommand("/surgery deny")))); + } + + // ============================================== + // Patient agrees; the surgeon's operating menu opens + // ============================================== + private void accept(Player patient) { + SurgeryRequestManager.Request request = requests.take(patient.getUniqueId(), System.currentTimeMillis()); + if (request == null) { + patient.sendMessage(uiUpdater.getMessage("request-none")); + return; + } + Player surgeon = Bukkit.getPlayer(request.surgeonId()); + if (surgeon == null || !surgeon.isOnline() || !canOperate(surgeon)) { + patient.sendMessage(uiUpdater.getMessage("request-surgeon-gone")); + return; + } + if (!checkCanStart(surgeon, patient)) { + patient.sendMessage(uiUpdater.getMessage("request-cannot-start").replace("%surgeon%", surgeon.getName())); + return; + } + HealingInjury ailment = Ailments.find(patient, request.traitId()); + if (ailment == null) { + patient.sendMessage(uiUpdater.getMessage("request-ailment-gone")); + return; + } + + patient.sendMessage(uiUpdater.getMessage("request-accepted-patient").replace("%surgeon%", surgeon.getName())); + surgeon.sendMessage(uiUpdater.getMessage("request-accepted-surgeon").replace("%patient%", patient.getName())); + menuManager.openSurgeryMenu(surgeon, patient, + new HealingInjury(ailment.traitId(), ChatColor.stripColor(ailment.displayName()), ailment.remainingMs())); + } + + private void deny(Player patient) { + SurgeryRequestManager.Request request = requests.take(patient.getUniqueId(), System.currentTimeMillis()); + if (request == null) { + patient.sendMessage(uiUpdater.getMessage("request-none")); + return; + } + patient.sendMessage(uiUpdater.getMessage("request-denied-patient")); + Player surgeon = Bukkit.getPlayer(request.surgeonId()); + if (surgeon != null) { + surgeon.sendMessage(uiUpdater.getMessage("request-denied-surgeon").replace("%patient%", patient.getName())); + } + } + + private boolean canOperate(Player surgeon) { + String permission = plugin.getConfig().getString("permission", "professions.physician"); + return permission == null || permission.isBlank() || surgeon.hasPermission(permission); + } + + // ============================================== + // Checks shared by offering and accepting; messages the surgeon on failure + // ============================================== + private boolean checkCanStart(Player surgeon, Player patient) { + // 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())) { + surgeon.sendMessage(uiUpdater.getMessage("command-already-in-surgery", + "&cYou are already performing surgery! Close the current menu first.")); + return false; } - // Check if trying to "operate" on self if (patient.getUniqueId().equals(surgeon.getUniqueId())) { surgeon.sendMessage(uiUpdater.getMessage("command-self-surgery", "&cYou cannot perform surgery on yourself!")); - return true; + return false; } // One surgery per patient: a second surgeon opening a menu on the same @@ -62,17 +181,16 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (menuManager.getStateManager().isPatientInSurgery(patient.getUniqueId())) { surgeon.sendMessage(uiUpdater.getMessage("command-patient-in-surgery", "&cThat player is already undergoing surgery!")); - return true; + return false; } // A player mid-operation as a surgeon cannot also be a patient if (menuManager.getStateManager().hasSession(patient.getUniqueId())) { surgeon.sendMessage(uiUpdater.getMessage("command-patient-is-operating", "&cThat player is busy performing surgery themselves!")); - return true; + return false; } - // Check if patient is within config distance // World check first: distance() throws across worlds double maxDistance = plugin.getConfig().getDouble("max-surgery-distance", 5.0); if (!surgeon.getWorld().equals(patient.getWorld()) @@ -82,12 +200,31 @@ public boolean onCommand(CommandSender sender, Command command, String label, St ? String.valueOf((long) maxDistance) : String.valueOf(maxDistance); surgeon.sendMessage(uiUpdater.getMessage("command-too-far", "&cThe patient must be within %distance% blocks of you!") .replace("%distance%", distance)); - return true; + return false; } - - // Open surgery menu for the player, operating on the specified "patient" - menuManager.openSurgeryMenu(surgeon, patient); - return true; } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + List options = new ArrayList<>(); + if (args.length != 1) { + return options; + } + String prefix = args[0].toLowerCase(Locale.ROOT); + for (String sub : List.of("accept", "deny")) { + if (sub.startsWith(prefix)) { + options.add(sub); + } + } + if (sender instanceof Player player && canOperate(player)) { + for (Player online : Bukkit.getOnlinePlayers()) { + if (!online.equals(player) && player.canSee(online) + && online.getName().toLowerCase(Locale.ROOT).startsWith(prefix)) { + options.add(online.getName()); + } + } + } + return options; + } } diff --git a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java index a7702b7..88bc21b 100644 --- a/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java +++ b/src/main/java/net/tfminecraft/surgery/listeners/PlayerListener.java @@ -27,6 +27,9 @@ public void onPlayerQuit(PlayerQuitEvent event) { // 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 diff --git a/src/main/java/net/tfminecraft/surgery/managers/DiagnosisChecker.java b/src/main/java/net/tfminecraft/surgery/managers/DiagnosisChecker.java deleted file mode 100644 index 3aeaf93..0000000 --- a/src/main/java/net/tfminecraft/surgery/managers/DiagnosisChecker.java +++ /dev/null @@ -1,42 +0,0 @@ -package net.tfminecraft.surgery.managers; - -import org.bukkit.plugin.java.JavaPlugin; - -import java.util.List; - -// ============================================== -// Helper class to check diagnosis categories from config -// ============================================== -public class DiagnosisChecker { - - private final JavaPlugin plugin; - private List fluDiagnoses; - - public DiagnosisChecker(JavaPlugin plugin) { - this.plugin = plugin; - loadConfig(); - } - - // ============================================== - // Loads configurable diagnosis categories - // ============================================== - public void loadConfig() { - fluDiagnoses = plugin.getConfig().getStringList("flu-diagnoses"); - } - - // ============================================== - // Checks if a diagnosis should have bones - // A diagnosis has bones if it's defined in the bone-counts section - // ============================================== - public boolean hasBones(String diagnosis) { - return plugin.getConfig().contains("bone-counts." + diagnosis); - } - - // ============================================== - // Checks if a diagnosis is a flu type - // Flu types are defined in the flu-diagnoses config list - // ============================================== - public boolean isFlu(String diagnosis) { - return fluDiagnoses != null && fluDiagnoses.contains(diagnosis); - } -} diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java index d9f3bbf..81a40fb 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryCompletionHandler.java @@ -1,7 +1,8 @@ package net.tfminecraft.surgery.managers; +import net.tfminecraft.surgery.procedures.Ailments; +import net.tfminecraft.surgery.procedures.Durations; import org.bukkit.Bukkit; -import org.bukkit.ChatColor; import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; @@ -10,86 +11,140 @@ // ============================================== // Handles surgery completion (success and failure) +// Success cures the patient's healing ailment in RPCharacters; failure makes +// it take longer to heal // ============================================== public class SurgeryCompletionHandler { - + + private static final long DEFAULT_FAILURE_PENALTY_MS = 12 * 3_600_000L; + private final JavaPlugin plugin; private final SurgeryStateManager stateManager; private final SurgeryUIUpdater uiUpdater; - + public SurgeryCompletionHandler(JavaPlugin plugin, SurgeryStateManager stateManager, SurgeryUIUpdater uiUpdater) { this.plugin = plugin; this.stateManager = stateManager; this.uiUpdater = uiUpdater; } - + // ============================================== // Checks if all success conditions are met // ============================================== public boolean isSurgerySuccessful(UUID playerId) { - // 1. Check if diagnosis is cured + // 1. The ailment has been treated and dressed if (!stateManager.isCured(playerId)) { return false; } - + // 2. Pulse must be "Strong" (LIME) if (!stateManager.getPulse(playerId).equals("Strong")) { return false; } - + // 3. Status must be "Unconscious" (LIME) if (!stateManager.getStatus(playerId).equals("Unconscious")) { return false; } - + // 4. Temperature must be at or below the configured success threshold (LIME) double successThreshold = plugin.getConfig().getDouble("temperature.success-threshold", 100.0); if (stateManager.getTemperature(playerId) > successThreshold) { return false; } - + // 5. Operation site must be "Clean" (LIME) if (!stateManager.getOperationSite(playerId).equals("Clean")) { return false; } - + // 6. Incisions must be 0 (LIME) if (stateManager.getIncisions(playerId) != 0) { return false; } - - // 7. All bones must be fixed + + // 7. All bones must be set if (stateManager.getBrokenBones(playerId) != 0 || stateManager.getShatteredBones(playerId) != 0) { return false; } - + // 8. No bleeding if (stateManager.isBleeding(playerId)) { return false; } - + return true; } - + // ============================================== // Handles successful surgery completion // ============================================== - public void handleSuccess(Player player) { - UUID playerId = player.getUniqueId(); - - executeCompletionCommand(player, true); - - stateManager.cleanup(playerId); + public void handleSuccess(Player surgeon) { + UUID surgeonId = surgeon.getUniqueId(); + String traitId = stateManager.getTraitId(surgeonId); + String ailment = stateManager.getAilmentName(surgeonId); + Player patient = patientOf(surgeonId); - closeMenuNextTick(player); - player.playSound(player.getLocation(), Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f, 1.0f); - player.sendMessage(uiUpdater.getMessage("surgery-successful")); - player.sendMessage(uiUpdater.getMessage("surgery-successful-subtitle")); + // 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)); + 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())); } - + // ============================================== // Fails the surgery with a message // ============================================== - public void failSurgery(Player player, String message) { - UUID playerId = player.getUniqueId(); - + public void failSurgery(Player surgeon, String message) { + UUID surgeonId = surgeon.getUniqueId(); + // Check if player is actually in surgery (prevent duplicate messages) - if (!stateManager.hasSession(playerId)) { return; } - - executeCompletionCommand(player, false); - - stateManager.cleanup(playerId); + 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; - closeMenuNextTick(player); - player.playSound(player.getLocation(), Sound.ENTITY_WITHER_DEATH, 1.0f, 0.8f); - player.sendMessage(uiUpdater.getMessage("surgery-failed")); - player.sendMessage(message); + stateManager.cleanup(surgeonId); + + 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)); + } + } + + // ============================================== + // 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) { + UUID surgeonId = surgeon.getUniqueId(); + Player patient = patientOf(surgeonId); + String traitId = stateManager.getTraitId(surgeonId); + if (patient == null || traitId == null) { + return null; + } + long penalty = Durations.parseMs(plugin.getConfig().getString("failure-healing-penalty", "12h")); + if (penalty < 0L) { + plugin.getLogger().warning("Invalid failure-healing-penalty, using 12h"); + penalty = DEFAULT_FAILURE_PENALTY_MS; + } + if (penalty == 0L) { + return null; + } + long remaining = Ailments.extend(patient, 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))); + return extra; } - + // ============================================== // Closes the menu on the next tick; calling closeInventory directly inside // an InventoryClickEvent handler is undefined behavior per the Bukkit docs @@ -99,49 +154,56 @@ private void closeMenuNextTick(Player player) { } // ============================================== - // Handles player giving up on surgery (only if they didn't complete it successfully) + // 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(); - - // Only fail if player still has active surgery data (not cleaned up from success) - if (stateManager.hasDiagnosis(playerId)) { + if (!stateManager.hasSession(playerId)) { + return; + } + if (stateManager.hasOperated(playerId)) { failSurgery(player, uiUpdater.getMessage("failure-gave-up")); } else { - // No diagnosis yet (menu closed before ultrasound) - nothing to fail, - // but state initialized on menu open must still be released stateManager.cleanup(playerId); + player.sendMessage(uiUpdater.getMessage("surgery-cancelled")); } } - + // ============================================== // Handles the surgeon disconnecting mid-surgery - // Runs the failure command (no free escape by logging out) but skips the - // messages, sounds, and menu close, which are pointless for a quitting - // player. If the close event already failed the surgery, the session is - // gone and this only cleans up. + // 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.hasDiagnosis(playerId)) { + if (stateManager.hasOperated(playerId)) { executeCompletionCommand(player, false); + lengthenHealing(player); } stateManager.cleanup(playerId); } + private Player patientOf(UUID surgeonId) { + UUID patientId = stateManager.getPatientUuid(surgeonId); + return patientId == null ? null : Bukkit.getPlayer(patientId); + } + // ============================================== // Executes the configured command for surgery completion (success or failure) // ============================================== private void executeCompletionCommand(Player surgeon, boolean success) { UUID surgeonId = surgeon.getUniqueId(); String patientName = stateManager.getPatientName(surgeonId); - + String configKey = success ? "commands.surgery-success" : "commands.surgery-failure"; String command = plugin.getConfig().getString(configKey, ""); - + if (command != null && !command.isEmpty()) { command = command.replace("%surgeon%", surgeon.getName()); command = command.replace("%player%", patientName); + command = command.replace("%ailment%", stateManager.getAilmentName(surgeonId)); Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryConstants.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryConstants.java index 1994aa0..58fdafa 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryConstants.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryConstants.java @@ -22,27 +22,13 @@ public class SurgeryConstants { public static final int INFO_SLOT_FIRST = SLOT_DIAGNOSIS; public static final int INFO_SLOT_LAST = SLOT_SKILL_FAIL; - // Tool slots - public static final int SLOT_SPONGE = 28; - public static final int SLOT_SCALPEL = 29; - public static final int SLOT_STITCHES = 30; - public static final int SLOT_ANTIBIOTICS = 31; - public static final int SLOT_ANTISEPTIC = 32; - public static final int SLOT_SURGICAL_GLOVE = 33; - public static final int SLOT_ULTRASOUND = 34; - public static final int SLOT_LAB_KIT = 37; - public static final int SLOT_ANESTHETIC = 38; - public static final int SLOT_DEFIBRILLATOR = 39; - public static final int SLOT_PINS = 40; - public static final int SLOT_SPLINT = 41; - public static final int SLOT_CLAMP = 42; - public static final int SLOT_TRANSFUSION = 43; + // Tool slots live on SurgeryTool // List of possible patient statuses (with corresponding colors) public static final String[] PATIENT_STATUSES = { "Awake", // YELLOW_CONCRETE "Unconscious", // LIME_CONCRETE - "Heart Stopped", // RED_CONCRETE + "Collapsed", // RED_CONCRETE "Coming to" // ORANGE_CONCRETE }; @@ -66,7 +52,7 @@ public static Material getStatusColor(String status) { return switch (status) { case "Awake" -> Material.YELLOW_CONCRETE; case "Unconscious" -> Material.LIME_CONCRETE; - case "Heart Stopped" -> Material.RED_CONCRETE; + case "Collapsed" -> Material.RED_CONCRETE; case "Coming to" -> Material.ORANGE_CONCRETE; default -> Material.GRAY_CONCRETE; }; diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java index b6641ea..602ea90 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemHandler.java @@ -1,5 +1,7 @@ package net.tfminecraft.surgery.managers; +import net.tfminecraft.surgery.procedures.Complication; +import net.tfminecraft.surgery.procedures.Procedure; import net.tfminecraft.tlibs.objects.api.ItemAPI; import org.bukkit.ChatColor; import org.bukkit.Material; @@ -9,6 +11,9 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.java.JavaPlugin; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; @@ -16,46 +21,27 @@ // Handles all surgery item clicks and tool logic // ============================================== public class SurgeryItemHandler { - + private final JavaPlugin plugin; private final ItemAPI api; private final SurgeryStateManager stateManager; private final SurgeryUIUpdater uiUpdater; private final SurgeryMechanicsManager mechanicsManager; private final SurgeryCompletionHandler completionHandler; - private final DiagnosisChecker diagnosisChecker; private final SurgeryItemsConfig itemsConfig; - // Skill fail message lists - private java.util.List skillFailLabKit; - private java.util.List skillFailUltrasound; - private java.util.List skillFailScalpel; - private java.util.List skillFailStitches; - private java.util.List skillFailAntibiotics; - private java.util.List skillFailTransfusion; - private java.util.List skillFailAntiseptic; - private java.util.List skillFailSponge; - private java.util.List skillFailAnesthetic; - private java.util.List skillFailDefibrillator; - private java.util.List skillFailSplint; - private java.util.List skillFailPins; - private java.util.List skillFailClamp; - private java.util.List skillFailSurgicalGlove; - - // Diagnoses list - private java.util.List diagnosesList; - - public SurgeryItemHandler(JavaPlugin plugin, ItemAPI api, SurgeryStateManager stateManager, + // Skill fail message lists, one per tool + private final Map> skillFailMessages = new EnumMap<>(SurgeryTool.class); + + public SurgeryItemHandler(JavaPlugin plugin, ItemAPI api, SurgeryStateManager stateManager, SurgeryUIUpdater uiUpdater, SurgeryMechanicsManager mechanicsManager, - SurgeryCompletionHandler completionHandler, DiagnosisChecker diagnosisChecker, - SurgeryItemsConfig itemsConfig) { + SurgeryCompletionHandler completionHandler, SurgeryItemsConfig itemsConfig) { this.plugin = plugin; this.api = api; this.stateManager = stateManager; this.uiUpdater = uiUpdater; this.mechanicsManager = mechanicsManager; this.completionHandler = completionHandler; - this.diagnosisChecker = diagnosisChecker; this.itemsConfig = itemsConfig; } @@ -63,24 +49,11 @@ public SurgeryItemHandler(JavaPlugin plugin, ItemAPI api, SurgeryStateManager st // Initializes skill fail messages from messages.yml // ============================================== public void initialize() { - skillFailLabKit = uiUpdater.getMessageList("skill-fail-lab-kit"); - skillFailUltrasound = uiUpdater.getMessageList("skill-fail-ultrasound"); - skillFailScalpel = uiUpdater.getMessageList("skill-fail-scalpel"); - skillFailStitches = uiUpdater.getMessageList("skill-fail-stitches"); - skillFailAntibiotics = uiUpdater.getMessageList("skill-fail-antibiotics"); - skillFailTransfusion = uiUpdater.getMessageList("skill-fail-transfusion"); - skillFailAntiseptic = uiUpdater.getMessageList("skill-fail-antiseptic"); - skillFailSponge = uiUpdater.getMessageList("skill-fail-sponge"); - skillFailAnesthetic = uiUpdater.getMessageList("skill-fail-anesthetic"); - skillFailDefibrillator = uiUpdater.getMessageList("skill-fail-defibrillator"); - skillFailSplint = uiUpdater.getMessageList("skill-fail-splint"); - skillFailPins = uiUpdater.getMessageList("skill-fail-pins"); - skillFailClamp = uiUpdater.getMessageList("skill-fail-clamp"); - skillFailSurgicalGlove = uiUpdater.getMessageList("skill-fail-surgical-glove"); - - diagnosesList = plugin.getConfig().getStringList("diagnoses"); + for (SurgeryTool tool : SurgeryTool.values()) { + skillFailMessages.put(tool, uiUpdater.getMessageList("skill-fail-" + tool.getConfigKey())); + } } - + // ============================================== // Handles clicking on a surgery menu item // ============================================== @@ -90,27 +63,26 @@ public void handleItemClick(Player player, ItemStack clickedItem, int slot) { if (clickedItem == null || clickedItem.getType().isAir()) { return; } - - UUID playerId = player.getUniqueId(); - - // Ignore clicks on info blocks - if (slot >= SurgeryConstants.INFO_SLOT_FIRST && slot <= SurgeryConstants.INFO_SLOT_LAST) { + + // Ignore clicks on info blocks and anything that is not a tool + SurgeryTool tool = SurgeryTool.fromSlot(slot); + if (tool == null) { return; } + UUID playerId = player.getUniqueId(); + // Special check for scalpel - cannot be used when patient is awake - if (slot == SurgeryConstants.SLOT_SCALPEL) { - String patientStatus = stateManager.getStatus(playerId); - if (patientStatus.equals("Awake")) { - completionHandler.failSurgery(player, uiUpdater.getMessage("failure-stabbed-awake")); - return; - } + if (tool == SurgeryTool.SCALPEL && stateManager.getStatus(playerId).equals("Awake")) { + stateManager.setOperated(playerId, true); + completionHandler.failSurgery(player, uiUpdater.getMessage("failure-stabbed-awake")); + return; } - + // Check if the player has this item in their inventory and remove it - if (removeItemFromPlayer(player, clickedItem)) { + if (removeItemFromPlayer(player, tool)) { // Process per-move effects before updating menu - mechanicsManager.processMoveEffects(player, slot); + mechanicsManager.processMoveEffects(player, tool); // 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 @@ -119,8 +91,8 @@ public void handleItemClick(Player player, ItemStack clickedItem, int slot) { } // Update the menu based on what was clicked - updateMenu(player, slot); - + updateMenu(player, tool); + // Play correct sound only if no skill fail occurred String skillFailMsg = stateManager.getSkillFail(playerId); if (skillFailMsg.isEmpty()) { @@ -128,49 +100,47 @@ public void handleItemClick(Player player, ItemStack clickedItem, int slot) { } } else { // Player doesn't have the item - show error message - player.sendMessage(ChatColor.translateAlternateColorCodes('&', - uiUpdater.getMessage("item-not-in-inventory"))); + player.sendMessage(uiUpdater.getMessage("item-not-in-inventory")); player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f); } } - + // ============================================== // Updates the menu when specific items are clicked // ============================================== // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - private void updateMenu(Player player, int clickedSlot) { + private void updateMenu(Player player, SurgeryTool tool) { Inventory menu = player.getOpenInventory().getTopInventory(); UUID playerId = player.getUniqueId(); boolean skillFail = isSkillFail(playerId); - - String skillFailMsg = ""; - - // Show bleeding warning if bleeding + + String skillFailMsg = switch (tool) { + case SPONGE -> handleSponge(player, menu, playerId, skillFail); + case SCALPEL -> handleScalpel(player, menu, playerId, skillFail); + case SUTURE -> handleSuture(player, menu, playerId, skillFail); + case TINCTURE -> handleTincture(player, menu, playerId, skillFail); + case CARBOLIC_ACID -> handleCarbolicAcid(player, menu, playerId, skillFail); + case DRESSING -> handleDressing(player, menu, playerId, skillFail); + case STETHOSCOPE -> handleStethoscope(player, menu, playerId, skillFail); + case THERMOMETER -> handleThermometer(menu, skillFail); + case CHLOROFORM -> handleChloroform(player, menu, playerId, skillFail); + case SMELLING_SALTS -> handleSmellingSalts(menu, playerId, skillFail); + case SILVER_WIRE -> handleSilverWire(player, menu, playerId, skillFail); + case SPLINT -> handleSplint(player, menu, playerId, skillFail); + case ARTERY_FORCEPS -> handleArteryForceps(player, menu, playerId, skillFail); + case TRANSFUSION -> handleTransfusion(menu, playerId, skillFail); + }; + + // Failing to use chloroform correctly can end the surgery outright + if (!stateManager.hasSession(playerId)) { + return; + } + if (stateManager.isBleeding(playerId)) { uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("bleeding-warning")); } - - // ============================================== - // Skill fail checks for each item - // ============================================== - switch (clickedSlot) { - case SurgeryConstants.SLOT_SPONGE: skillFailMsg = handleSponge(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_SCALPEL: skillFailMsg = handleScalpel(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_STITCHES: skillFailMsg = handleStitches(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_ANTIBIOTICS: skillFailMsg = handleAntibiotics(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_ANTISEPTIC: skillFailMsg = handleAntiseptic(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_SURGICAL_GLOVE: skillFailMsg = handleSurgicalGlove(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_ULTRASOUND: skillFailMsg = handleUltrasound(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_LAB_KIT: skillFailMsg = handleLabKit(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_ANESTHETIC: skillFailMsg = handleAnesthetic(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_DEFIBRILLATOR: skillFailMsg = handleDefibrillator(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_PINS: skillFailMsg = handlePins(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_SPLINT: skillFailMsg = handleSplint(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_CLAMP: skillFailMsg = handleClamp(player, menu, playerId, skillFail); break; - case SurgeryConstants.SLOT_TRANSFUSION: skillFailMsg = handleTransfusion(player, menu, playerId, skillFail); break; - } - + // ============================================== // Update skill fail block // ============================================== @@ -179,396 +149,360 @@ private void updateMenu(Player player, int clickedSlot) { String skillFailDisplay = skillFailMsg.isEmpty() ? ChatColor.GRAY + "Nothing to show here" : ChatColor.GRAY + skillFailMsg; ItemStack skillFailBlock = uiUpdater.createInfoBlock(skillFailColor, ChatColor.GOLD + "Skill Fail", skillFailDisplay); menu.setItem(SurgeryConstants.SLOT_SKILL_FAIL, skillFailBlock); - + // Clear sponge effect (it only lasts for one move) stateManager.setSpongeEffect(playerId, false); - + // Play "broken item" sound if skill fail occurred if (!skillFailMsg.isEmpty()) { player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0f, 1.0f); } - - // Check if surgery is complete after this move (if diagnosis was already cured) + + // Check if surgery is complete after this move (if the ailment was already treated) if (stateManager.isCured(playerId) && completionHandler.isSurgerySuccessful(playerId)) { completionHandler.handleSuccess(player); } } - + // ============================================== - // Labkit functionality: reveals antibiotics + // Clinical thermometer: reveals the fever tincture // ============================================== - private String handleLabKit(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleThermometer(Inventory menu, boolean skillFail) { if (skillFail) { - return getRandomSkillFail(skillFailLabKit); - } else { - menu.setItem(SurgeryConstants.SLOT_LAB_KIT, null); - ItemStack antibiotics = api.getCreator().getItemFromPath(itemsConfig.getItemPath(3)); - if (antibiotics != null) { - menu.setItem(SurgeryConstants.SLOT_ANTIBIOTICS, antibiotics); - } - return ""; + return getRandomSkillFail(SurgeryTool.THERMOMETER); + } + menu.setItem(SurgeryTool.THERMOMETER.getSlot(), null); + ItemStack tincture = api.getCreator().getItemFromPath(itemsConfig.getItemPath(SurgeryTool.TINCTURE)); + if (tincture != null) { + menu.setItem(SurgeryTool.TINCTURE.getSlot(), tincture); } + return ""; } - + // ============================================== - // Ultrasound functionality: reveals diagnosis and sets temperature for flu diagnoses + // Stethoscope: examines the patient, revealing the procedure and its bones // ============================================== // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") - private String handleUltrasound(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleStethoscope(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { - return getRandomSkillFail(skillFailUltrasound); - } else { - menu.setItem(SurgeryConstants.SLOT_ULTRASOUND, null); - String diagnosis = diagnosesList.get(ThreadLocalRandom.current().nextInt(diagnosesList.size())); - stateManager.setDiagnosis(playerId, diagnosis); - - // If diagnosis is a flu, set initial high temperature from config range - if (diagnosisChecker.isFlu(diagnosis)) { - double fluMin = plugin.getConfig().getDouble("temperature.flu-temp-min", 99.0); - double fluMax = plugin.getConfig().getDouble("temperature.flu-temp-max", 104.0); - double fluTemp = fluMin + (ThreadLocalRandom.current().nextDouble() * (fluMax - fluMin)); - stateManager.setTemperature(playerId, fluTemp); - uiUpdater.updateTemperatureBlock(menu, playerId, fluTemp); - } - - // Assign bones only for bone-related diagnoses - if (diagnosisChecker.hasBones(diagnosis)) { - // Get bone counts from config, or use random if not specified - int brokenBones = plugin.getConfig().getInt("bone-counts." + diagnosis + ".broken", ThreadLocalRandom.current().nextInt(3)); - int shatteredBones = plugin.getConfig().getInt("bone-counts." + diagnosis + ".shattered", ThreadLocalRandom.current().nextInt(2)); - stateManager.setBrokenBones(playerId, brokenBones); - stateManager.setShatteredBones(playerId, shatteredBones); - } - - // Update diagnosis block - ItemStack diagnosisBlock = uiUpdater.createInfoBlock(Material.YELLOW_CONCRETE, - ChatColor.GOLD + "Diagnosis", ChatColor.GRAY + "The patient suffers from " + diagnosis); - menu.setItem(SurgeryConstants.SLOT_DIAGNOSIS, diagnosisBlock); - - // Check if surgical glove should appear - mechanicsManager.checkForFixItButton(player, menu, playerId, 0); - return ""; + return getRandomSkillFail(SurgeryTool.STETHOSCOPE); + } + menu.setItem(SurgeryTool.STETHOSCOPE.getSlot(), null); + stateManager.setExamined(playerId, true); + Procedure procedure = stateManager.getProcedure(playerId); + + // Sepsis: the patient is already feverish and the fever keeps climbing + if (procedure.has(Complication.SEPSIS)) { + double feverMin = plugin.getConfig().getDouble("complications.sepsis.fever-min", 100.0); + double feverMax = plugin.getConfig().getDouble("complications.sepsis.fever-max", 104.0); + double fever = Math.max(stateManager.getTemperature(playerId), + feverMin + ThreadLocalRandom.current().nextDouble() * (feverMax - feverMin)); + stateManager.setHasRisingTemp(playerId, true); + stateManager.setTemperature(playerId, fever); + uiUpdater.updateTemperatureBlock(menu, playerId, fever); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("sepsis-found")); } + + stateManager.setBrokenBones(playerId, procedure.brokenBones()); + stateManager.setShatteredBones(playerId, procedure.shatteredBones()); + uiUpdater.updateDiagnosisBlock(menu, playerId); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("examined") + .replace("%procedure%", procedure.name()) + .replace("%incisions%", String.valueOf(procedure.requiredIncisions()))); + + // Incisions made before the examination still count + int incisions = stateManager.getIncisions(playerId); + mechanicsManager.handleBoneReveal(player, menu, playerId, incisions); + mechanicsManager.checkForDressing(player, menu, playerId, incisions); + return ""; } - + // ============================================== - // Scalpel functionality: creates incisions and affects pulse + // Scalpel: creates incisions and affects pulse // ============================================== private String handleScalpel(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { // On skill fail: lower pulse instead of creating incision - String currentPulse = stateManager.getPulse(playerId); - String newPulse = SurgeryConstants.worsenPulse(currentPulse); - stateManager.setPulse(playerId, newPulse); - uiUpdater.updatePulseBlock(menu, playerId, newPulse); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("pulse-weakening")); - return getRandomSkillFail(skillFailScalpel); - } else { - int incisions = stateManager.getIncisions(playerId) + 1; - stateManager.setIncisions(playerId, incisions); - uiUpdater.updateIncisionBlock(menu, playerId, incisions); - - // Scalpel makes operation site unclean ("bleeding") - String currentSite = stateManager.getOperationSite(playerId); - if (currentSite.equals("Clean")) { - stateManager.setOperationSite(playerId, "Unclean"); - uiUpdater.updateOperationSiteBlock(menu, playerId, "Unclean"); - } - - // Paper Cuts: Show examined message after configured scalpel uses - String diagnosis = stateManager.getDiagnosis(playerId); - int paperCutsUses = plugin.getConfig().getInt("diagnosis-mechanics.paper-cuts.scalpel-uses-required", 2); - if (diagnosis != null && diagnosis.equals("Paper Cuts") && incisions == paperCutsUses) { - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("wounds-examined")); - } - - // Reveal bones at required incision count for bone-based diagnoses - if (diagnosis != null) { - mechanicsManager.handleBoneReveal(player, menu, playerId, diagnosis, incisions); - } + worsenPulse(player, menu, playerId); + return getRandomSkillFail(SurgeryTool.SCALPEL); + } + // Once the patient has been sedated or cut, walking away counts as a failed surgery + stateManager.setOperated(playerId, true); + int incisions = stateManager.getIncisions(playerId) + 1; + stateManager.setIncisions(playerId, incisions); + uiUpdater.updateIncisionBlock(menu, playerId, incisions); - // Always refresh dynamic tools; the clamp depends on incision count and - // bleeding, which can both be true before a diagnosis exists - mechanicsManager.updateDynamicTools(player, menu, playerId); - - // 50% chance for pulse to decrease when making incision - if (ThreadLocalRandom.current().nextDouble() < plugin.getConfig().getDouble("pulse.scalpel-decrease-chance", 0.50)) { - String currentPulse = stateManager.getPulse(playerId); - String newPulse = SurgeryConstants.worsenPulse(currentPulse); - stateManager.setPulse(playerId, newPulse); - uiUpdater.updatePulseBlock(menu, playerId, newPulse); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("pulse-weakening")); - } - - mechanicsManager.checkForFixItButton(player, menu, playerId, incisions); - return ""; + // Cutting makes a clean operation site unclean + if (stateManager.getOperationSite(playerId).equals("Clean")) { + stateManager.setOperationSite(playerId, "Unclean"); + uiUpdater.updateOperationSiteBlock(menu, playerId, "Unclean"); } + + // Reveal bones at the procedure's incision count + mechanicsManager.handleBoneReveal(player, menu, playerId, incisions); + + // Always refresh dynamic tools; the forceps depend on incision count and + // bleeding, which can both be true before the examination + mechanicsManager.updateDynamicTools(player, menu, playerId); + + // Chance for pulse to decrease when making incision + if (ThreadLocalRandom.current().nextDouble() < plugin.getConfig().getDouble("pulse.scalpel-decrease-chance", 0.50)) { + worsenPulse(player, menu, playerId); + } + + mechanicsManager.checkForDressing(player, menu, playerId, incisions); + return ""; + } + + private void worsenPulse(Player player, Inventory menu, UUID playerId) { + String newPulse = SurgeryConstants.worsenPulse(stateManager.getPulse(playerId)); + stateManager.setPulse(playerId, newPulse); + uiUpdater.updatePulseBlock(menu, playerId, newPulse); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("pulse-weakening")); } - + // ============================================== - // Stitches functionality: closes incisions and can stop bleeding + // Catgut suture: closes incisions and can stop bleeding // ============================================== - private String handleStitches(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleSuture(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { - return getRandomSkillFail(skillFailStitches); - } else { - int incisions = Math.max(0, stateManager.getIncisions(playerId) - 1); - stateManager.setIncisions(playerId, incisions); - uiUpdater.updateIncisionBlock(menu, playerId, incisions); - if (incisions == 0) { - stateManager.setBleeding(playerId, false); - } - mechanicsManager.updateDynamicTools(player, menu, playerId); - return ""; + return getRandomSkillFail(SurgeryTool.SUTURE); + } + int incisions = Math.max(0, stateManager.getIncisions(playerId) - 1); + stateManager.setIncisions(playerId, incisions); + uiUpdater.updateIncisionBlock(menu, playerId, incisions); + if (incisions == 0) { + stateManager.setBleeding(playerId, false); } + mechanicsManager.updateDynamicTools(player, menu, playerId); + return ""; } - + // ============================================== - // Antibiotics functionality: reduces temperature by 5.4°F (3.0°C) + // Willow-bark tincture: brings the fever down // ============================================== - private String handleAntibiotics(Player player, Inventory menu, UUID playerId, boolean skillFail) { - double change = plugin.getConfig().getDouble("temperature.antibiotics-change", 5.4); + private String handleTincture(Player player, Inventory menu, UUID playerId, boolean skillFail) { + double change = plugin.getConfig().getDouble("temperature.tincture-change", 5.4); if (skillFail) { double temp = stateManager.getTemperature(playerId) + change; temp = Math.min(temp, plugin.getConfig().getDouble("temperature.instant-death-threshold", 110.0)); stateManager.setTemperature(playerId, temp); uiUpdater.updateTemperatureBlock(menu, playerId, temp); - return getRandomSkillFail(skillFailAntibiotics); - } else { - double temp = stateManager.getTemperature(playerId) - change; - temp = Math.max(temp, plugin.getConfig().getDouble("temperature.normal", 98.6)); - stateManager.setTemperature(playerId, temp); - uiUpdater.updateTemperatureBlock(menu, playerId, temp); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("temperature-reduced")); - mechanicsManager.checkForFixItButton(player, menu, playerId, 0); - return ""; + return getRandomSkillFail(SurgeryTool.TINCTURE); } + double temp = stateManager.getTemperature(playerId) - change; + temp = Math.max(temp, plugin.getConfig().getDouble("temperature.normal", 98.6)); + stateManager.setTemperature(playerId, temp); + uiUpdater.updateTemperatureBlock(menu, playerId, temp); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("temperature-reduced")); + return ""; } - + // ============================================== - // Transfusion functionality: improves pulse + // Transfusion syringe: improves pulse // ============================================== - private String handleTransfusion(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleTransfusion(Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { stateManager.setOperationSite(playerId, "Unsanitary"); uiUpdater.updateOperationSiteBlock(menu, playerId, "Unsanitary"); - return getRandomSkillFail(skillFailTransfusion); - } else { - String currentPulse = stateManager.getPulse(playerId); - String newPulse = SurgeryConstants.improvePulse(currentPulse); - stateManager.setPulse(playerId, newPulse); - uiUpdater.updatePulseBlock(menu, playerId, newPulse); - return ""; + return getRandomSkillFail(SurgeryTool.TRANSFUSION); } + String newPulse = SurgeryConstants.improvePulse(stateManager.getPulse(playerId)); + stateManager.setPulse(playerId, newPulse); + uiUpdater.updatePulseBlock(menu, playerId, newPulse); + return ""; } - + // ============================================== - // Antiseptic functionality: cleans the operation site and provides temperature protection + // Carbolic acid: cleans the operation site and protects against fever // ============================================== - private String handleAntiseptic(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleCarbolicAcid(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { - return getRandomSkillFail(skillFailAntiseptic); - } else { - stateManager.setOperationSite(playerId, "Clean"); - uiUpdater.updateOperationSiteBlock(menu, playerId, "Clean"); - stateManager.setAntisepticProtection(playerId, true); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("operation-clean")); - return ""; + return getRandomSkillFail(SurgeryTool.CARBOLIC_ACID); } + stateManager.setOperationSite(playerId, "Clean"); + uiUpdater.updateOperationSiteBlock(menu, playerId, "Clean"); + stateManager.setAntisepticProtection(playerId, true); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("operation-clean")); + return ""; } - + // ============================================== - // Sponge functionality: removes bleeding and provides temporary protection against skill fails + // Sponge: removes bleeding and reduces the next move's skill fail chance // ============================================== private String handleSponge(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { - return getRandomSkillFail(skillFailSponge); - } else { - stateManager.setBleeding(playerId, false); - stateManager.setSpongeEffect(playerId, true); - stateManager.setMovesSinceLastSponge(playerId, 0); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("vision-cleared")); - return ""; + return getRandomSkillFail(SurgeryTool.SPONGE); } + stateManager.setBleeding(playerId, false); + stateManager.setSpongeEffect(playerId, true); + stateManager.setMovesSinceLastSponge(playerId, 0); + mechanicsManager.updateDynamicTools(player, menu, playerId); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("vision-cleared")); + return ""; } - + // ============================================== - // Anesthetic functionality: unconsciousness for the patient + // Chloroform: puts the patient under // ============================================== - private String handleAnesthetic(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleChloroform(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { - return getRandomSkillFail(skillFailAnesthetic); - } else { - Integer unconsciousTimer = stateManager.getUnconsciousTimer(playerId); - String currentStatus = stateManager.getStatus(playerId); - - int reuseCooldown = plugin.getConfig().getInt("death-timers.anesthetic-reuse-cooldown", 4); - if ((currentStatus.equals("Unconscious") || currentStatus.equals("Coming to")) && - unconsciousTimer != null && unconsciousTimer < reuseCooldown) { - completionHandler.failSurgery(player, uiUpdater.getMessage("failure-anesthetic-misuse")); - return ""; - } - - stateManager.setStatus(playerId, "Unconscious"); - uiUpdater.updateStatusBlock(menu, playerId, "Unconscious"); - stateManager.setUnconsciousTimer(playerId, 0); + return getRandomSkillFail(SurgeryTool.CHLOROFORM); + } + Integer unconsciousTimer = stateManager.getUnconsciousTimer(playerId); + String currentStatus = stateManager.getStatus(playerId); + + int reuseCooldown = plugin.getConfig().getInt("death-timers.anesthetic-reuse-cooldown", 4); + if ((currentStatus.equals("Unconscious") || currentStatus.equals("Coming to")) && + unconsciousTimer != null && unconsciousTimer < reuseCooldown) { + completionHandler.failSurgery(player, uiUpdater.getMessage("failure-anesthetic-misuse")); return ""; } + + stateManager.setStatus(playerId, "Unconscious"); + uiUpdater.updateStatusBlock(menu, playerId, "Unconscious"); + stateManager.setUnconsciousTimer(playerId, 0); + stateManager.setOperated(playerId, true); + return ""; } - + // ============================================== - // Defibrillator functionality: revives the patient from a stopped heart + // Smelling salts: revive a collapsed patient // ============================================== - private String handleDefibrillator(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleSmellingSalts(Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { - return getRandomSkillFail(skillFailDefibrillator); - } else { - String status = stateManager.getStatus(playerId); - if (status.equals("Heart Stopped")) { - stateManager.setStatus(playerId, "Unconscious"); - uiUpdater.updateStatusBlock(menu, playerId, "Unconscious"); - stateManager.removeDefibrillatorCountdown(playerId); - menu.setItem(SurgeryConstants.SLOT_DEFIBRILLATOR, null); - } - return ""; + return getRandomSkillFail(SurgeryTool.SMELLING_SALTS); } + if (stateManager.getStatus(playerId).equals("Collapsed")) { + stateManager.setStatus(playerId, "Unconscious"); + uiUpdater.updateStatusBlock(menu, playerId, "Unconscious"); + stateManager.removeCollapseCountdown(playerId); + menu.setItem(SurgeryTool.SMELLING_SALTS.getSlot(), null); + } + return ""; } - + // ============================================== - // Splint functionality: fixes broken bones + // Splint: sets broken bones // ============================================== private String handleSplint(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { stateManager.setBleeding(playerId, true); - return getRandomSkillFail(skillFailSplint); - } else { - int actualBroken = stateManager.getBrokenBones(playerId); - if (actualBroken > 0) { - stateManager.setBrokenBones(playerId, actualBroken - 1); - int revealedBroken = stateManager.getRevealedBrokenBones(playerId); - if (revealedBroken > 0) { - stateManager.setRevealedBrokenBones(playerId, revealedBroken - 1); - } - uiUpdater.updateDiagnosisBlock(menu, playerId); - if (actualBroken - 1 == 0) { - menu.setItem(SurgeryConstants.SLOT_SPLINT, null); - } + mechanicsManager.updateDynamicTools(player, menu, playerId); + return getRandomSkillFail(SurgeryTool.SPLINT); + } + int actualBroken = stateManager.getBrokenBones(playerId); + if (actualBroken > 0) { + stateManager.setBrokenBones(playerId, actualBroken - 1); + int revealedBroken = stateManager.getRevealedBrokenBones(playerId); + if (revealedBroken > 0) { + stateManager.setRevealedBrokenBones(playerId, revealedBroken - 1); } - return ""; + uiUpdater.updateDiagnosisBlock(menu, playerId); + mechanicsManager.updateDynamicTools(player, menu, playerId); + mechanicsManager.checkForDressing(player, menu, playerId, stateManager.getIncisions(playerId)); } + return ""; } - + // ============================================== - // Pins functionality: Fixes shattered bones + // Silver wire: binds shattered bones into broken bones that can be splinted // ============================================== - private String handlePins(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleSilverWire(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { - String diagnosis = stateManager.getDiagnosis(playerId); - if (diagnosis != null && diagnosis.equals("Ecto-Bones")) { - int shatteredBones = stateManager.getShatteredBones(playerId) + 1; - stateManager.setShatteredBones(playerId, shatteredBones); - int revealedShattered = stateManager.getRevealedShatteredBones(playerId) + 1; - stateManager.setRevealedShatteredBones(playerId, revealedShattered); - return uiUpdater.getMessage("skill-fail-pins-ecto-bones"); - } else { - stateManager.setBleeding(playerId, true); - return getRandomSkillFail(skillFailPins); - } - } else { - int shatteredBones = stateManager.getShatteredBones(playerId); - if (shatteredBones > 0) { - int brokenBones = stateManager.getBrokenBones(playerId); - stateManager.setBrokenBones(playerId, brokenBones + 1); - stateManager.setShatteredBones(playerId, shatteredBones - 1); - - int revealedShattered = stateManager.getRevealedShatteredBones(playerId); - int revealedBroken = stateManager.getRevealedBrokenBones(playerId); - if (revealedShattered > 0) { - stateManager.setRevealedShatteredBones(playerId, revealedShattered - 1); - } - stateManager.setRevealedBrokenBones(playerId, revealedBroken + 1); - - uiUpdater.updateDiagnosisBlock(menu, playerId); - mechanicsManager.updateDynamicTools(player, menu, playerId); - - if (shatteredBones - 1 == 0) { - menu.setItem(SurgeryConstants.SLOT_PINS, null); - } + stateManager.setBleeding(playerId, true); + mechanicsManager.updateDynamicTools(player, menu, playerId); + return getRandomSkillFail(SurgeryTool.SILVER_WIRE); + } + int shatteredBones = stateManager.getShatteredBones(playerId); + if (shatteredBones > 0) { + stateManager.setBrokenBones(playerId, stateManager.getBrokenBones(playerId) + 1); + stateManager.setShatteredBones(playerId, shatteredBones - 1); + + int revealedShattered = stateManager.getRevealedShatteredBones(playerId); + if (revealedShattered > 0) { + stateManager.setRevealedShatteredBones(playerId, revealedShattered - 1); } - return ""; + stateManager.setRevealedBrokenBones(playerId, stateManager.getRevealedBrokenBones(playerId) + 1); + + uiUpdater.updateDiagnosisBlock(menu, playerId); + mechanicsManager.updateDynamicTools(player, menu, playerId); } + return ""; } - + // ============================================== - // Clamp functionality: stops bleeding + // Artery forceps: stop bleeding from an open wound // ============================================== - private String handleClamp(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleArteryForceps(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { - return getRandomSkillFail(skillFailClamp); - } else { - if (stateManager.getIncisions(playerId) > 0 && stateManager.isBleeding(playerId)) { - stateManager.setBleeding(playerId, false); - mechanicsManager.updateDynamicTools(player, menu, playerId); - } - return ""; + return getRandomSkillFail(SurgeryTool.ARTERY_FORCEPS); } + if (stateManager.getIncisions(playerId) > 0 && stateManager.isBleeding(playerId)) { + stateManager.setBleeding(playerId, false); + mechanicsManager.updateDynamicTools(player, menu, playerId); + } + return ""; } - + // ============================================== - // Surgical Glove functionality: "Fixes" the patient + // Linen dressing: treats the ailment once the procedure is done // ============================================== - private String handleSurgicalGlove(Player player, Inventory menu, UUID playerId, boolean skillFail) { + private String handleDressing(Player player, Inventory menu, UUID playerId, boolean skillFail) { if (skillFail) { - return getRandomSkillFail(skillFailSurgicalGlove); - } else { - stateManager.setCured(playerId, true); - menu.setItem(SurgeryConstants.SLOT_SURGICAL_GLOVE, null); - uiUpdater.updateDiagnosisBlock(menu, playerId); - - // Don't show incomplete message if surgery is already successful - if (!completionHandler.isSurgerySuccessful(playerId)) { - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("condition-treated-incomplete")); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("check-remaining")); - } - return ""; + return getRandomSkillFail(SurgeryTool.DRESSING); } + stateManager.setCured(playerId, true); + menu.setItem(SurgeryTool.DRESSING.getSlot(), null); + uiUpdater.updateDiagnosisBlock(menu, playerId); + + // Don't show incomplete message if surgery is already successful + if (!completionHandler.isSurgerySuccessful(playerId)) { + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("condition-treated-incomplete")); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("check-remaining")); + } + return ""; } - + // ============================================== - // Gets a random skill fail message from a list + // Gets a random skill fail message for a tool // ============================================== - private String getRandomSkillFail(java.util.List messages) { + private String getRandomSkillFail(SurgeryTool tool) { + List messages = skillFailMessages.get(tool); if (messages == null || messages.isEmpty()) { return "Something went wrong!"; } return messages.get(ThreadLocalRandom.current().nextInt(messages.size())); } - + // ============================================== // Checks if skill fail should occur based on bleeding and sponge effect // ============================================== private boolean isSkillFail(UUID playerId) { double baseChance = plugin.getConfig().getDouble("skill-fail.base-chance", 0.25); - + if (stateManager.isBleeding(playerId)) { baseChance = plugin.getConfig().getDouble("skill-fail.bleeding-chance", 0.40); } - + if (stateManager.hasSpongeEffect(playerId)) { baseChance = plugin.getConfig().getDouble("skill-fail.with-sponge-chance", 0.10); } - + return ThreadLocalRandom.current().nextDouble() < baseChance; } - + // ============================================== - // Removes item from player inventory + // Removes one of the tool from the player's inventory + // Matches by item path rather than exact meta, so tools crafted before a + // rename or lore change still count // ============================================== - private boolean removeItemFromPlayer(Player player, ItemStack targetItem) { + private boolean removeItemFromPlayer(Player player, SurgeryTool tool) { + String path = itemsConfig.getItemPath(tool); for (int i = 0; i < player.getInventory().getSize(); i++) { ItemStack item = player.getInventory().getItem(i); - if (item != null && item.isSimilar(targetItem)) { + if (item != null && api.getChecker().checkItemWithPath(item, path)) { if (item.getAmount() > 1) { item.setAmount(item.getAmount() - 1); } else { diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemsConfig.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemsConfig.java index 28198b7..e34bbc0 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemsConfig.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryItemsConfig.java @@ -8,29 +8,28 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; +import java.util.EnumMap; +import java.util.Map; // ============================================== // Configuration for surgery item paths // ============================================== public class SurgeryItemsConfig { - + private final JavaPlugin plugin; - private FileConfiguration config; - - // Item paths array (index matches original SURGERY_ITEMS) - private final String[] itemPaths = new String[14]; - + private final Map itemPaths = new EnumMap<>(SurgeryTool.class); + public SurgeryItemsConfig(JavaPlugin plugin) { this.plugin = plugin; loadConfig(); } - + // ============================================== // Load or create the surgeryItemsConfig.yml // ============================================== private void loadConfig() { File configFile = new File(plugin.getDataFolder(), "surgeryItemsConfig.yml"); - + // Create config file if it doesn't exist if (!configFile.exists()) { plugin.getDataFolder().mkdirs(); @@ -42,41 +41,17 @@ private void loadConfig() { plugin.getLogger().severe("Could not create surgeryItemsConfig.yml: " + e.getMessage()); } } - - // Load the config - config = YamlConfiguration.loadConfiguration(configFile); - - // Load item paths into array - itemPaths[0] = config.getString("items.sponge", "m.surgery.sponge"); - itemPaths[1] = config.getString("items.scalpel", "m.surgery.scalpel"); - itemPaths[2] = config.getString("items.stitches", "m.surgery.stitches"); - itemPaths[3] = config.getString("items.antibiotics", "m.surgery.antibiotics"); - itemPaths[4] = config.getString("items.antiseptic", "m.surgery.antiseptic"); - itemPaths[5] = config.getString("items.surgical-glove", "m.surgery.surgical_glove"); - itemPaths[6] = config.getString("items.ultrasound", "m.surgery.ultrasound"); - itemPaths[7] = config.getString("items.lab-kit", "m.surgery.lab_kit"); - itemPaths[8] = config.getString("items.anesthetic", "m.surgery.anesthetic"); - itemPaths[9] = config.getString("items.defibrillator", "m.surgery.defibrillator"); - itemPaths[10] = config.getString("items.pins", "m.surgery.pins"); - itemPaths[11] = config.getString("items.splint", "m.surgery.splint"); - itemPaths[12] = config.getString("items.clamp", "m.surgery.clamp"); - itemPaths[13] = config.getString("items.transfusion", "m.surgery.transfusion"); - } - - // ============================================== - // Get the item paths array - // ============================================== - public String[] getItemPaths() { - return itemPaths; + + FileConfiguration config = YamlConfiguration.loadConfiguration(configFile); + for (SurgeryTool tool : SurgeryTool.values()) { + itemPaths.put(tool, config.getString("items." + tool.getConfigKey(), tool.getDefaultPath())); + } } - + // ============================================== - // Get a specific item path by index + // Get the TLibs item path for a tool // ============================================== - public String getItemPath(int index) { - if (index >= 0 && index < itemPaths.length) { - return itemPaths[index]; - } - return null; + public String getItemPath(SurgeryTool tool) { + return itemPaths.get(tool); } } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java index ea8fe9f..5f1d0e2 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMechanicsManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.surgery.managers; +import net.tfminecraft.surgery.procedures.Complication; +import net.tfminecraft.surgery.procedures.Procedure; import net.tfminecraft.tlibs.objects.api.ItemAPI; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -7,58 +9,38 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.java.JavaPlugin; -import java.util.HashMap; -import java.util.Map; import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; // ============================================== -// Handles diagnosis-specific mechanics and per-move effects +// Handles complications and per-move effects // ============================================== public class SurgeryMechanicsManager { - + private final JavaPlugin plugin; private final ItemAPI api; private final SurgeryStateManager stateManager; private final SurgeryUIUpdater uiUpdater; private final SurgeryCompletionHandler completionHandler; - private final DiagnosisChecker diagnosisChecker; private final SurgeryItemsConfig itemsConfig; - // Map diagnoses to required incision counts - private final Map requiredIncisions = new HashMap<>(); - public SurgeryMechanicsManager(JavaPlugin plugin, ItemAPI api, SurgeryStateManager stateManager, SurgeryUIUpdater uiUpdater, SurgeryCompletionHandler completionHandler, - DiagnosisChecker diagnosisChecker, SurgeryItemsConfig itemsConfig) { + SurgeryItemsConfig itemsConfig) { this.plugin = plugin; this.api = api; this.stateManager = stateManager; this.uiUpdater = uiUpdater; this.completionHandler = completionHandler; - this.diagnosisChecker = diagnosisChecker; this.itemsConfig = itemsConfig; } - - // ============================================== - // Initializes required incisions from config - // ============================================== - public void initialize() { - var config = plugin.getConfig().getConfigurationSection("required-incisions"); - if (config != null) { - for (String key : config.getKeys(false)) { - requiredIncisions.put(key, config.getInt(key)); - } - } - } - + // ============================================== - // Processes per-move effects (antibiotics countdown, temperature changes, etc.) + // Processes per-move effects (anesthetic wear-off, fever, blood loss, etc.) // ============================================== - public void processMoveEffects(Player player, int clickedSlot) { + public void processMoveEffects(Player player, SurgeryTool clickedTool) { UUID playerId = player.getUniqueId(); Inventory menu = player.getOpenInventory().getTopInventory(); - String diagnosis = stateManager.getDiagnosis(playerId); // Fail if the patient logged off or moved out of surgery range if (!isPatientPresent(player)) { @@ -75,7 +57,7 @@ public void processMoveEffects(Player player, int clickedSlot) { int movesSinceSponge = stateManager.getMovesSinceLastSponge(playerId) + 1; stateManager.setMovesSinceLastSponge(playerId, movesSinceSponge); - // Advance the anesthetic wear-off cycle: Unconscious -> Coming to -> Awake + // Advance the chloroform wear-off cycle: Unconscious -> Coming to -> Awake String currentStatus = stateManager.getStatus(playerId); if (currentStatus.equals("Unconscious") || currentStatus.equals("Coming to")) { Integer unconsciousTimer = stateManager.getUnconsciousTimer(playerId); @@ -90,32 +72,32 @@ public void processMoveEffects(Player player, int clickedSlot) { stateManager.setStatus(playerId, "Coming to"); uiUpdater.updateStatusBlock(menu, playerId, "Coming to"); uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("patient-coming-to", - "&eThe anesthetic is wearing off - the patient is coming to!")); + "&eThe chloroform is wearing off - the patient is coming to!")); } else if (currentStatus.equals("Coming to") && timer >= unconsciousMoves + comingToMoves) { stateManager.setStatus(playerId, "Awake"); uiUpdater.updateStatusBlock(menu, playerId, "Awake"); uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("patient-woke-up", - "&cThe patient is fully awake! Re-administer anesthetic before cutting!")); + "&cThe patient is fully awake! Give more chloroform before cutting!")); } } } - - // Check defibrillator countdown (failure after configured moves) - // Skip the decrement when the defibrillator itself was clicked, otherwise - // the countdown expires before the shock is applied and the full window + + // Check the collapse countdown (failure after configured moves) + // Skip the decrement when the smelling salts were used, otherwise the + // countdown expires before the patient is revived and the full window // promised by the config is never usable - if (currentStatus.equals("Heart Stopped") && clickedSlot != SurgeryConstants.SLOT_DEFIBRILLATOR) { - Integer countdown = stateManager.getDefibrillatorCountdown(playerId); + if (currentStatus.equals("Collapsed") && clickedTool != SurgeryTool.SMELLING_SALTS) { + Integer countdown = stateManager.getCollapseCountdown(playerId); if (countdown != null) { countdown--; if (countdown <= 0) { completionHandler.failSurgery(player, uiUpdater.getMessage("failure-not-resuscitated")); return; } - stateManager.setDefibrillatorCountdown(playerId, countdown); + stateManager.setCollapseCountdown(playerId, countdown); } } - + // Check if temperature reached instant death threshold // >= because every temperature increase clamps to the threshold, so a // strict > could never fire and the mechanic was dead @@ -125,7 +107,7 @@ public void processMoveEffects(Player player, int clickedSlot) { completionHandler.failSurgery(player, uiUpdater.getMessage("failure-infection")); return; } - + // Check for consecutive red temperature. Fail after configured turns double redTempThreshold = plugin.getConfig().getDouble("temperature.red-temp-threshold", 106.0); int maxRedTempTurns = plugin.getConfig().getInt("death-timers.red-temp-turns", 2); @@ -139,7 +121,7 @@ public void processMoveEffects(Player player, int clickedSlot) { } else { stateManager.setRedTempCounter(playerId, 0); } - + // Degrade pulse if bleeding double pulseDegradationChance = plugin.getConfig().getDouble("pulse.degradation-chance-bleeding", 0.30); if (stateManager.isBleeding(playerId) && ThreadLocalRandom.current().nextDouble() < pulseDegradationChance) { @@ -153,7 +135,7 @@ public void processMoveEffects(Player player, int clickedSlot) { uiUpdater.updatePulseBlock(menu, playerId, newPulse); uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("pulse-weakening")); } - + // Check for consecutive Extremely Weak pulse. Fail after configured turns String currentPulse = stateManager.getPulse(playerId); int maxWeakPulseTurns = plugin.getConfig().getInt("death-timers.weak-pulse-turns", 2); @@ -167,16 +149,16 @@ public void processMoveEffects(Player player, int clickedSlot) { } else { stateManager.setExtremelyWeakCounter(playerId, 0); } - + // Handle temperature rise String opSite = stateManager.getOperationSite(playerId); boolean hasProtection = stateManager.hasAntisepticProtection(playerId); boolean isBleeding = stateManager.isBleeding(playerId); int incisions = stateManager.getIncisions(playerId); boolean hasRisingTemp = stateManager.hasRisingTemp(playerId); - + boolean shouldRiseTemp = (!opSite.equals("Clean") && (incisions > 0 || isBleeding)) || hasRisingTemp; - + if (shouldRiseTemp && !hasProtection) { double riseRate = plugin.getConfig().getDouble("temperature.rise-rate", 1.8); double maxTemp = plugin.getConfig().getDouble("temperature.instant-death-threshold", 110.0); @@ -185,18 +167,20 @@ public void processMoveEffects(Player player, int clickedSlot) { stateManager.setTemperature(playerId, temp); uiUpdater.updateTemperatureBlock(menu, playerId, temp); } - - // Disable antiseptic protection if operation site becomes unclean + + // Disable carbolic acid protection if operation site becomes unclean if (!opSite.equals("Clean") && hasProtection) { stateManager.setAntisepticProtection(playerId, false); uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("protection-lost")); } - - if (diagnosis != null) { - runDiagnosisSpecificMechanics(player, menu, playerId, diagnosis); + + // Complications only show once the surgeon knows what they are dealing with + Procedure procedure = stateManager.getProcedure(playerId); + if (procedure != null && stateManager.isExamined(playerId)) { + runComplications(player, menu, playerId, procedure); } } - + // ============================================== // Checks the patient is still online, in the same world, and within range // Range gets a configurable multiplier so normal fidgeting doesn't end the surgery @@ -219,269 +203,124 @@ private boolean isPatientPresent(Player surgeon) { } // ============================================== - // Runs diagnosis-specific mechanics + // Runs the procedure's complications // ============================================== - private void runDiagnosisSpecificMechanics(Player player, Inventory menu, UUID playerId, String diagnosis) { - switch (diagnosis) { - case "Moldy Guts": - // Forces bleeding somewhere between min and max moves since the last - // sponge: 50/50 each move once min is reached, guaranteed at max - int moldyGutsMin = plugin.getConfig().getInt("diagnosis-mechanics.moldy-guts.bleeding-interval-min", 3); - int moldyGutsMax = plugin.getConfig().getInt("diagnosis-mechanics.moldy-guts.bleeding-interval-max", 4); - int movesSinceSpongeNow = stateManager.getMovesSinceLastSponge(playerId); - if (movesSinceSpongeNow >= moldyGutsMax - || (movesSinceSpongeNow >= moldyGutsMin && ThreadLocalRandom.current().nextBoolean())) { - stateManager.setBleeding(playerId, true); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("moldy-guts")); - updateDynamicTools(player, menu, playerId); - } - break; - - case "Fatty Liver": - double fattyLiverChance = plugin.getConfig().getDouble("diagnosis-mechanics.fatty-liver.heart-stop-chance", 0.20); - if (stateManager.getStatus(playerId).equals("Unconscious") && ThreadLocalRandom.current().nextDouble() < fattyLiverChance) { - stopHeart(player, menu, playerId, "fatty-liver-heart-stop"); - } - break; + private void runComplications(Player player, Inventory menu, UUID playerId, Procedure procedure) { + if (procedure.has(Complication.HAEMORRHAGE)) { + // Forces bleeding somewhere between min and max moves since the last + // sponge: 50/50 each move once min is reached, guaranteed at max + int min = plugin.getConfig().getInt("complications.haemorrhage.bleeding-interval-min", 3); + int max = plugin.getConfig().getInt("complications.haemorrhage.bleeding-interval-max", 4); + int movesSinceSponge = stateManager.getMovesSinceLastSponge(playerId); + if (movesSinceSponge >= max || (movesSinceSponge >= min && ThreadLocalRandom.current().nextBoolean())) { + stateManager.setBleeding(playerId, true); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("haemorrhage")); + updateDynamicTools(player, menu, playerId); + } + } - case "Broken Heart": - double brokenHeartChance = plugin.getConfig().getDouble("diagnosis-mechanics.broken-heart.heart-stop-chance", 0.35); - if (stateManager.getStatus(playerId).equals("Unconscious") && ThreadLocalRandom.current().nextDouble() < brokenHeartChance) { - stopHeart(player, menu, playerId, "broken-heart-stop"); - } - break; - - case "Arcane Infection": - double arcaneChance = plugin.getConfig().getDouble("diagnosis-mechanics.arcane-infection.chaos-chance", 0.25); - if (ThreadLocalRandom.current().nextDouble() < arcaneChance) { - handleArcaneInfectionChaos(player, menu, playerId); - } - break; - - case "Lupus": - double lupusChance = plugin.getConfig().getDouble("diagnosis-mechanics.lupus.howl-chance", 0.15); - if (ThreadLocalRandom.current().nextDouble() < lupusChance) { - int currentIncisions = stateManager.getIncisions(playerId); - stateManager.setIncisions(playerId, currentIncisions + 1); - uiUpdater.updateIncisionBlock(menu, playerId, currentIncisions + 1); - stateManager.setBleeding(playerId, true); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("lupus-howl")); - updateDynamicTools(player, menu, playerId); - } - break; + if (procedure.has(Complication.SHOCK)) { + double chance = plugin.getConfig().getDouble("complications.shock.collapse-chance", 0.20); + if (stateManager.getStatus(playerId).equals("Unconscious") && ThreadLocalRandom.current().nextDouble() < chance) { + collapse(player, menu, playerId); + } } } - + // ============================================== - // Stops the patient's heart: status, UI, message, death timer, tools + // The patient collapses: status, UI, message, death timer, tools // ============================================== - private void stopHeart(Player player, Inventory menu, UUID playerId, String messageKey) { - stateManager.setStatus(playerId, "Heart Stopped"); - uiUpdater.updateStatusBlock(menu, playerId, "Heart Stopped"); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage(messageKey)); - int defibCountdown = plugin.getConfig().getInt("death-timers.defibrillator-countdown", 2); - stateManager.setDefibrillatorCountdown(playerId, defibCountdown); + private void collapse(Player player, Inventory menu, UUID playerId) { + stateManager.setStatus(playerId, "Collapsed"); + uiUpdater.updateStatusBlock(menu, playerId, "Collapsed"); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("shock-collapse")); + int countdown = plugin.getConfig().getInt("death-timers.collapse-countdown", 2); + stateManager.setCollapseCountdown(playerId, countdown); updateDynamicTools(player, menu, playerId); } // ============================================== - // Handles Arcane Infection chaos effects + // Shows the linen dressing once the procedure's incisions are made and + // every bone has been set // ============================================== - private void handleArcaneInfectionChaos(Player player, Inventory menu, UUID playerId) { - int chaosEffect = ThreadLocalRandom.current().nextInt(4); - double temp; - - switch (chaosEffect) { - case 0: // Temperature spike - double tempSpikeMax = plugin.getConfig().getDouble("diagnosis-mechanics.arcane-infection.temp-spike-max", 4.0); - double maxTemp = plugin.getConfig().getDouble("temperature.instant-death-threshold", 110.0); - temp = Math.min(stateManager.getTemperature(playerId) + ThreadLocalRandom.current().nextDouble() * tempSpikeMax, maxTemp); - stateManager.setTemperature(playerId, temp); - uiUpdater.updateTemperatureBlock(menu, playerId, temp); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("chaos-temp-spike")); - break; - - case 1: // Temperature drop - double tempDropMax = plugin.getConfig().getDouble("diagnosis-mechanics.arcane-infection.temp-drop-max", 2.0); - double normalTemp = plugin.getConfig().getDouble("temperature.normal", 98.6); - temp = Math.max(stateManager.getTemperature(playerId) - ThreadLocalRandom.current().nextDouble() * tempDropMax, normalTemp); - stateManager.setTemperature(playerId, temp); - uiUpdater.updateTemperatureBlock(menu, playerId, temp); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("chaos-temp-drop")); - break; - - case 2: // Heart stop - stopHeart(player, menu, playerId, "chaos-heart-stop"); - break; - - case 3: // Random status change - String newStatus = SurgeryConstants.PATIENT_STATUSES[ThreadLocalRandom.current().nextInt(SurgeryConstants.PATIENT_STATUSES.length)]; - stateManager.setStatus(playerId, newStatus); - uiUpdater.updateStatusBlock(menu, playerId, newStatus); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("chaos-status-change")); - // A random "Heart Stopped" needs the death timer like every other - // heart-stop path; without it the countdown stays null and the - // patient can never die from the stopped heart - if (newStatus.equals("Heart Stopped")) { - int countdown = plugin.getConfig().getInt("death-timers.defibrillator-countdown", 2); - stateManager.setDefibrillatorCountdown(playerId, countdown); - } else { - // Leaving "Heart Stopped" by chaos skips the defibrillator, so the - // old countdown must not survive into a later heart stop - stateManager.removeDefibrillatorCountdown(playerId); - } - // Align the anesthetic wear-off timer with the forced status, otherwise - // a stale timer makes the patient wake instantly or far too late - if (newStatus.equals("Unconscious")) { - stateManager.setUnconsciousTimer(playerId, 0); - } else if (newStatus.equals("Coming to")) { - stateManager.setUnconsciousTimer(playerId, plugin.getConfig().getInt("anesthetic.unconscious-moves", 6)); - } - updateDynamicTools(player, menu, playerId); - break; + public void checkForDressing(Player player, Inventory menu, UUID playerId, int currentIncisions) { + Procedure procedure = stateManager.getProcedure(playerId); + if (procedure == null || !stateManager.isExamined(playerId) || stateManager.isCured(playerId)) { + return; } - } - - // ============================================== - // Check if Fix it button should appear based on diagnosis and conditions - // ============================================== - public void checkForFixItButton(Player player, Inventory menu, UUID playerId, int currentIncisions) { - String diagnosis = stateManager.getDiagnosis(playerId); - if (diagnosis == null || stateManager.isCured(playerId)) { + if (currentIncisions < procedure.requiredIncisions()) { return; } - - // Check for flu diagnoses. Show surgical glove at normal temperature - if (diagnosisChecker.isFlu(diagnosis)) { - double currentTemp = stateManager.getTemperature(playerId); - double normalTemp = plugin.getConfig().getDouble("temperature.normal", 98.6); - if (Math.abs(currentTemp - normalTemp) < 0.1) { - ItemStack surgicalGlove = api.getCreator().getItemFromPath(itemsConfig.getItemPath(5)); - if (surgicalGlove != null) { - menu.setItem(SurgeryConstants.SLOT_SURGICAL_GLOVE, surgicalGlove); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("surgical-glove-ready")); - } - } + if (stateManager.getBrokenBones(playerId) > 0 || stateManager.getShatteredBones(playerId) > 0) { return; } - - // For non-flu diagnoses, check incision count - if (requiredIncisions.containsKey(diagnosis)) { - int requiredInc = requiredIncisions.get(diagnosis); - if (currentIncisions >= requiredInc) { - // If diagnosis has bones, check that all bones are fixed first - if (diagnosisChecker.hasBones(diagnosis)) { - int brokenBones = stateManager.getBrokenBones(playerId); - int shatteredBones = stateManager.getShatteredBones(playerId); - if (brokenBones > 0 || shatteredBones > 0) { - return; - } - } - - ItemStack surgicalGlove = api.getCreator().getItemFromPath(itemsConfig.getItemPath(5)); - if (surgicalGlove != null) { - menu.setItem(SurgeryConstants.SLOT_SURGICAL_GLOVE, surgicalGlove); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("surgical-glove-ready")); - } - } + if (menu.getItem(SurgeryTool.DRESSING.getSlot()) != null) { + return; } - } - - // ============================================== - // Handles bone reveal logic for scalpel - // ============================================== - public void handleBoneReveal(Player player, Inventory menu, UUID playerId, String diagnosis, int incisions) { - if (diagnosisChecker.hasBones(diagnosis)) { - // >= so overshooting the required count still reveals; re-running is - // idempotent because revealed counts track the actual bone counts - int required = requiredIncisions.getOrDefault(diagnosis, 0); - if (incisions >= required) { - int totalBrokenBones = stateManager.getBrokenBones(playerId); - int totalShatteredBones = stateManager.getShatteredBones(playerId); - stateManager.setRevealedBrokenBones(playerId, totalBrokenBones); - stateManager.setRevealedShatteredBones(playerId, totalShatteredBones); - updateDynamicTools(player, menu, playerId); - } - } else { - revealBonesRandomly(player, menu, playerId); + ItemStack dressing = api.getCreator().getItemFromPath(itemsConfig.getItemPath(SurgeryTool.DRESSING)); + if (dressing != null) { + menu.setItem(SurgeryTool.DRESSING.getSlot(), dressing); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("dressing-ready")); } } - + // ============================================== - // Randomly reveal bones when scalpel is used (25% chance per incision) + // Reveals the procedure's bones once enough incisions expose them // ============================================== - private void revealBonesRandomly(Player player, Inventory menu, UUID playerId) { - int totalBroken = stateManager.getBrokenBones(playerId); - int totalShattered = stateManager.getShatteredBones(playerId); - int revealedBroken = stateManager.getRevealedBrokenBones(playerId); - int revealedShattered = stateManager.getRevealedShatteredBones(playerId); - - double revealChance = plugin.getConfig().getDouble("bones.random-reveal-chance", 0.25); - - if (revealedBroken < totalBroken && ThreadLocalRandom.current().nextDouble() < revealChance) { - stateManager.setRevealedBrokenBones(playerId, revealedBroken + 1); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("discovered-broken-bone")); + public void handleBoneReveal(Player player, Inventory menu, UUID playerId, int incisions) { + Procedure procedure = stateManager.getProcedure(playerId); + if (procedure == null || !procedure.hasBones() || !stateManager.isExamined(playerId)) { + return; } - - if (revealedShattered < totalShattered && ThreadLocalRandom.current().nextDouble() < revealChance) { - stateManager.setRevealedShatteredBones(playerId, revealedShattered + 1); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("discovered-shattered-bone")); + // >= so overshooting the required count still reveals; re-running is + // idempotent because revealed counts track the actual bone counts + if (incisions >= procedure.requiredIncisions()) { + int brokenBefore = stateManager.getRevealedBrokenBones(playerId); + int shatteredBefore = stateManager.getRevealedShatteredBones(playerId); + stateManager.setRevealedBrokenBones(playerId, stateManager.getBrokenBones(playerId)); + stateManager.setRevealedShatteredBones(playerId, stateManager.getShatteredBones(playerId)); + if (stateManager.getRevealedBrokenBones(playerId) > brokenBefore) { + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("discovered-broken-bone")); + } + if (stateManager.getRevealedShatteredBones(playerId) > shatteredBefore) { + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("discovered-shattered-bone")); + } + uiUpdater.updateDiagnosisBlock(menu, playerId); + updateDynamicTools(player, menu, playerId); } - - updateDynamicTools(player, menu, playerId); } - + // ============================================== // Updates dynamic tools that appear based on conditions // ============================================== public void updateDynamicTools(Player player, Inventory menu, UUID playerId) { - // Defibrillator: appears when heart stopped - String status = stateManager.getStatus(playerId); - if (status.equals("Heart Stopped") && menu.getItem(SurgeryConstants.SLOT_DEFIBRILLATOR) == null) { - ItemStack defibrillator = api.getCreator().getItemFromPath(itemsConfig.getItemPath(9)); - if (defibrillator != null) { - menu.setItem(SurgeryConstants.SLOT_DEFIBRILLATOR, defibrillator); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("defibrillator-available")); - } - } else if (!status.equals("Heart Stopped") && menu.getItem(SurgeryConstants.SLOT_DEFIBRILLATOR) != null) { - menu.setItem(SurgeryConstants.SLOT_DEFIBRILLATOR, null); - } + // Smelling salts: appear when the patient has collapsed + boolean collapsed = stateManager.getStatus(playerId).equals("Collapsed"); + showTool(player, menu, SurgeryTool.SMELLING_SALTS, collapsed, "smelling-salts-available"); - // Pins: appears when shattered bones revealed - int revealedShattered = stateManager.getRevealedShatteredBones(playerId); - if (revealedShattered > 0 && menu.getItem(SurgeryConstants.SLOT_PINS) == null) { - ItemStack pins = api.getCreator().getItemFromPath(itemsConfig.getItemPath(10)); - if (pins != null) { - menu.setItem(SurgeryConstants.SLOT_PINS, pins); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("pins-available")); - } - } else if (revealedShattered == 0 && menu.getItem(SurgeryConstants.SLOT_PINS) != null) { - menu.setItem(SurgeryConstants.SLOT_PINS, null); - } + // Silver wire: appears when shattered bones are revealed + showTool(player, menu, SurgeryTool.SILVER_WIRE, stateManager.getRevealedShatteredBones(playerId) > 0, + "silver-wire-available"); - // Splint: appears when broken bones revealed - int revealedBroken = stateManager.getRevealedBrokenBones(playerId); - if (revealedBroken > 0 && menu.getItem(SurgeryConstants.SLOT_SPLINT) == null) { - ItemStack splint = api.getCreator().getItemFromPath(itemsConfig.getItemPath(11)); - if (splint != null) { - menu.setItem(SurgeryConstants.SLOT_SPLINT, splint); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("splint-available")); - } - } else if (revealedBroken == 0 && menu.getItem(SurgeryConstants.SLOT_SPLINT) != null) { - menu.setItem(SurgeryConstants.SLOT_SPLINT, null); - } + // Splint: appears when broken bones are revealed + showTool(player, menu, SurgeryTool.SPLINT, stateManager.getRevealedBrokenBones(playerId) > 0, + "splint-available"); - // Clamp: appears when incisions > 1 AND bleeding - int incisions = stateManager.getIncisions(playerId); - boolean bleeding = stateManager.isBleeding(playerId); - if (incisions > 1 && bleeding && menu.getItem(SurgeryConstants.SLOT_CLAMP) == null) { - ItemStack clamp = api.getCreator().getItemFromPath(itemsConfig.getItemPath(12)); - if (clamp != null) { - menu.setItem(SurgeryConstants.SLOT_CLAMP, clamp); - uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage("clamp-available")); + // Artery forceps: appear when incisions > 1 AND bleeding + boolean forceps = stateManager.getIncisions(playerId) > 1 && stateManager.isBleeding(playerId); + showTool(player, menu, SurgeryTool.ARTERY_FORCEPS, forceps, "artery-forceps-available"); + } + + private void showTool(Player player, Inventory menu, SurgeryTool tool, boolean visible, String messageKey) { + boolean shown = menu.getItem(tool.getSlot()) != null; + if (visible && !shown) { + ItemStack item = api.getCreator().getItemFromPath(itemsConfig.getItemPath(tool)); + if (item != null) { + menu.setItem(tool.getSlot(), item); + uiUpdater.sendNumberedMessage(player, uiUpdater.getMessage(messageKey)); } - } else if ((incisions <= 1 || !bleeding) && menu.getItem(SurgeryConstants.SLOT_CLAMP) != null) { - menu.setItem(SurgeryConstants.SLOT_CLAMP, null); + } else if (!visible && shown) { + menu.setItem(tool.getSlot(), null); } } } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuBuilder.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuBuilder.java index 4756708..2f4f4e0 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuBuilder.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuBuilder.java @@ -36,38 +36,23 @@ public SurgeryMenuBuilder(JavaPlugin plugin, ItemAPI api, SurgeryStateManager st public void buildAndOpenMenu(Player player) { Inventory menu = new SurgeryMenuHolder().getInventory(); - // Map specific slots to item config indexes - int[][] slotMapping = { - {SurgeryConstants.SLOT_SPONGE, 0}, - {SurgeryConstants.SLOT_SCALPEL, 1}, - {SurgeryConstants.SLOT_STITCHES, 2}, - {SurgeryConstants.SLOT_ANTISEPTIC, 4}, - {SurgeryConstants.SLOT_ULTRASOUND, 6}, - {SurgeryConstants.SLOT_LAB_KIT, 7}, - {SurgeryConstants.SLOT_ANESTHETIC, 8}, - {SurgeryConstants.SLOT_TRANSFUSION, 13} - - // Antibiotics appear after using the lab kit - // Surgical Glove, Defibrillator, Pins, Splint, and Clamp - // appear dynamically + // Tools on the table from the start; the tincture appears after the + // thermometer, and the dressing, smelling salts, silver wire, splint, + // and artery forceps appear when needed + SurgeryTool[] startingTools = { + SurgeryTool.SPONGE, SurgeryTool.SCALPEL, SurgeryTool.SUTURE, SurgeryTool.CARBOLIC_ACID, + SurgeryTool.STETHOSCOPE, SurgeryTool.THERMOMETER, SurgeryTool.CHLOROFORM, SurgeryTool.TRANSFUSION }; - - // Place items in the specified slots - for (int[] mapping : slotMapping) { - int slot = mapping[0]; - int itemIndex = mapping[1]; - - String itemPath = itemsConfig.getItemPath(itemIndex); - if (itemPath != null) { - ItemStack item = api.getCreator().getItemFromPath(itemPath); - if (item != null) { - menu.setItem(slot, item); - } else { - plugin.getLogger().warning("[Surgery] Could not load item: " + itemPath); - } + for (SurgeryTool tool : startingTools) { + String itemPath = itemsConfig.getItemPath(tool); + ItemStack item = itemPath == null ? null : api.getCreator().getItemFromPath(itemPath); + if (item != null) { + menu.setItem(tool.getSlot(), item); + } else { + plugin.getLogger().warning("[Surgery] Could not load item: " + itemPath); } } - + // Add placeholder info blocks for (int i = SurgeryConstants.INFO_SLOT_FIRST; i <= SurgeryConstants.INFO_SLOT_LAST; i++) { ItemStack infoBlock = uiUpdater.createInfoBlock(Material.RED_CONCRETE, " ", ""); @@ -89,8 +74,7 @@ private void initializePlayerState(Player player, Inventory menu) { // ============================================== // Set the first info block (slot 10) as "diagnosis" // ============================================== - ItemStack diagnosisBlock = uiUpdater.createInfoBlock(Material.RED_CONCRETE, ChatColor.GOLD + "Diagnosis", ChatColor.GRAY + "The patient has not been diagnosed."); - menu.setItem(SurgeryConstants.SLOT_DIAGNOSIS, diagnosisBlock); + uiUpdater.updateDiagnosisBlock(menu, playerId); // ============================================== // Set the second info block (slot 11) as "pulse". Always starts at Strong diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java index fd727e8..ae8a618 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryMenuManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.surgery.managers; +import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; +import net.tfminecraft.surgery.procedures.ProcedureRegistry; import net.tfminecraft.tlibs.objects.api.ItemAPI; import net.tfminecraft.tlibs.TLibs; import org.bukkit.entity.Player; @@ -17,7 +19,8 @@ public class SurgeryMenuManager { private ItemAPI api; // Specialized managers - private DiagnosisChecker diagnosisChecker; + private final ProcedureRegistry procedures = new ProcedureRegistry(); + private final SurgeryRequestManager requestManager = new SurgeryRequestManager(); private SurgeryStateManager stateManager; private SurgeryUIUpdater uiUpdater; private SurgeryMenuBuilder menuBuilder; @@ -43,27 +46,28 @@ public void initialize() { } // Initialize all managers in dependency order - diagnosisChecker = new DiagnosisChecker(plugin); + procedures.load(plugin.getConfig(), plugin.getLogger()::warning); stateManager = new SurgeryStateManager(); - uiUpdater = new SurgeryUIUpdater(plugin, stateManager, diagnosisChecker); + uiUpdater = new SurgeryUIUpdater(plugin, stateManager); completionHandler = new SurgeryCompletionHandler(plugin, stateManager, uiUpdater); - mechanicsManager = new SurgeryMechanicsManager(plugin, api, stateManager, uiUpdater, completionHandler, diagnosisChecker, itemsConfig); + 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, diagnosisChecker, itemsConfig); + itemHandler = new SurgeryItemHandler(plugin, api, stateManager, uiUpdater, mechanicsManager, completionHandler, itemsConfig); // Initialize any managers that need config - mechanicsManager.initialize(); itemHandler.initialize(); plugin.getLogger().info("[Surgery] Surgery menu manager initialized!"); } // ============================================== - // Opens the surgery menu for the surgeon, operating on the specified patient + // Opens the surgery menu for the surgeon, operating on the patient's ailment // ============================================== - public void openSurgeryMenu(Player surgeon, Player patient) { + public void openSurgeryMenu(Player surgeon, Player patient, HealingInjury ailment) { stateManager.setPatientName(surgeon.getUniqueId(), patient.getName()); stateManager.setPatientUuid(surgeon.getUniqueId(), patient.getUniqueId()); + stateManager.setAilment(surgeon.getUniqueId(), ailment.traitId(), ailment.displayName(), + procedures.get(ailment.traitId())); menuBuilder.buildAndOpenMenu(surgeon); } @@ -121,6 +125,7 @@ public void handlePatientQuit(Player patient) { // Getters for accessing individual managers // ============================================== public SurgeryStateManager getStateManager() { return stateManager; } + public SurgeryRequestManager getRequestManager() { return requestManager; } public SurgeryUIUpdater getUiUpdater() { return uiUpdater; } public SurgeryMenuBuilder getMenuBuilder() { return menuBuilder; } public SurgeryCompletionHandler getCompletionHandler() { return completionHandler; } diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryRequestManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryRequestManager.java new file mode 100644 index 0000000..4b2aa07 --- /dev/null +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryRequestManager.java @@ -0,0 +1,52 @@ +package net.tfminecraft.surgery.managers; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +// ============================================== +// 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) { + } + + 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)); + } + + // ============================================== + // The patient's current offer, or null when there is none or it has expired + // ============================================== + public Request pending(UUID patientId, long now) { + Request request = requests.get(patientId); + if (request == null) { + return null; + } + if (request.expiresAt() < now) { + requests.remove(patientId); + return null; + } + return request; + } + + // ============================================== + // Removes and returns the patient's request if it has not expired + // ============================================== + public Request take(UUID patientId, long now) { + Request request = requests.remove(patientId); + return request == null || request.expiresAt() < now ? 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)); + } +} diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java index 6054f48..11d1130 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryStateManager.java @@ -1,5 +1,7 @@ package net.tfminecraft.surgery.managers; +import net.tfminecraft.surgery.procedures.Procedure; + import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -17,7 +19,12 @@ public class SurgeryStateManager { private static class SurgerySession { String patientName; UUID patientUuid; - String diagnosis; + String traitId; + String ailmentName; + Procedure procedure; + Boolean examined; + // Treatment has begun: the patient was sedated or cut + Boolean operated; String pulse; String status; Double temperature; @@ -29,7 +36,7 @@ private static class SurgerySession { Integer shatteredBones; Integer revealedBrokenBones; Integer revealedShatteredBones; - Integer defibrillatorCountdown; + Integer collapseCountdown; Boolean cured; Boolean antisepticProtection; Boolean spongeEffect; @@ -54,7 +61,11 @@ private SurgerySession get(UUID playerId) { // ============================================== // Getters with default values // ============================================== - public String getDiagnosis(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.diagnosis; } + public String getTraitId(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.traitId; } + public String getAilmentName(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.ailmentName == null ? "Unknown ailment" : s.ailmentName; } + public Procedure getProcedure(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.procedure; } + public boolean isExamined(UUID playerId) { SurgerySession s = get(playerId); return s != null && Boolean.TRUE.equals(s.examined); } + public boolean hasOperated(UUID playerId) { SurgerySession s = get(playerId); return s != null && Boolean.TRUE.equals(s.operated); } public String getPulse(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.pulse == null ? "Strong" : s.pulse; } public String getStatus(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.status == null ? "Awake" : s.status; } public double getTemperature(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.temperature == null ? 98.6 : s.temperature; } @@ -66,7 +77,7 @@ private SurgerySession get(UUID playerId) { public int getShatteredBones(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.shatteredBones == null ? 0 : s.shatteredBones; } public int getRevealedBrokenBones(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.revealedBrokenBones == null ? 0 : s.revealedBrokenBones; } public int getRevealedShatteredBones(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.revealedShatteredBones == null ? 0 : s.revealedShatteredBones; } - public Integer getDefibrillatorCountdown(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.defibrillatorCountdown; } + public Integer getCollapseCountdown(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.collapseCountdown; } public boolean isCured(UUID playerId) { SurgerySession s = get(playerId); return s != null && Boolean.TRUE.equals(s.cured); } public boolean hasAntisepticProtection(UUID playerId) { SurgerySession s = get(playerId); return s != null && Boolean.TRUE.equals(s.antisepticProtection); } public boolean hasSpongeEffect(UUID playerId) { SurgerySession s = get(playerId); return s != null && Boolean.TRUE.equals(s.spongeEffect); } @@ -79,7 +90,6 @@ private SurgerySession get(UUID playerId) { public String getPatientName(UUID playerId) { SurgerySession s = get(playerId); return s == null || s.patientName == null ? "Unknown" : s.patientName; } public UUID getPatientUuid(UUID playerId) { SurgerySession s = get(playerId); return s == null ? null : s.patientUuid; } public boolean hasSession(UUID playerId) { return sessions.containsKey(playerId); } - public boolean hasDiagnosis(UUID playerId) { SurgerySession s = get(playerId); return s != null && s.diagnosis != null; } // ============================================== // Patient-side lookups (sessions are keyed by surgeon) @@ -100,7 +110,14 @@ public UUID findSurgeonForPatient(UUID patientUuid) { // ============================================== // Setters // ============================================== - public void setDiagnosis(UUID playerId, String diagnosis) { session(playerId).diagnosis = diagnosis; } + public void setAilment(UUID playerId, String traitId, String ailmentName, Procedure procedure) { + SurgerySession s = session(playerId); + s.traitId = traitId; + s.ailmentName = ailmentName; + s.procedure = procedure; + } + public void setExamined(UUID playerId, boolean examined) { session(playerId).examined = examined; } + public void setOperated(UUID playerId, boolean operated) { session(playerId).operated = operated; } public void setPulse(UUID playerId, String pulse) { session(playerId).pulse = pulse; } public void setStatus(UUID playerId, String status) { session(playerId).status = status; } public void setTemperature(UUID playerId, double temp) { session(playerId).temperature = temp; } @@ -112,7 +129,7 @@ public UUID findSurgeonForPatient(UUID patientUuid) { public void setShatteredBones(UUID playerId, int count) { session(playerId).shatteredBones = count; } public void setRevealedBrokenBones(UUID playerId, int count) { session(playerId).revealedBrokenBones = count; } public void setRevealedShatteredBones(UUID playerId, int count) { session(playerId).revealedShatteredBones = count; } - public void setDefibrillatorCountdown(UUID playerId, int countdown) { session(playerId).defibrillatorCountdown = countdown; } + public void setCollapseCountdown(UUID playerId, int countdown) { session(playerId).collapseCountdown = countdown; } public void setCured(UUID playerId, boolean cured) { session(playerId).cured = cured; } public void setAntisepticProtection(UUID playerId, boolean protected_) { session(playerId).antisepticProtection = protected_; } public void setSpongeEffect(UUID playerId, boolean effect) { session(playerId).spongeEffect = effect; } @@ -124,7 +141,7 @@ public UUID findSurgeonForPatient(UUID patientUuid) { 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 removeDefibrillatorCountdown(UUID playerId) { SurgerySession s = get(playerId); if (s != null) { s.defibrillatorCountdown = null; } } + public void removeCollapseCountdown(UUID playerId) { SurgerySession s = get(playerId); if (s != null) { s.collapseCountdown = null; } } // ============================================== // Player Data Cleanup diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryTool.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryTool.java new file mode 100644 index 0000000..aed5a59 --- /dev/null +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryTool.java @@ -0,0 +1,49 @@ +package net.tfminecraft.surgery.managers; + +// ============================================== +// Victorian surgical instruments: config key, default TLibs item path, and menu slot +// Default paths keep the original MMOItems IDs so existing crafted tools keep working +// ============================================== +public enum SurgeryTool { + + SPONGE("sponge", "m.surgery.sponge", 28), + SCALPEL("scalpel", "m.surgery.scalpel", 29), + SUTURE("suture", "m.surgery.stitches", 30), + TINCTURE("tincture", "m.surgery.antibiotics", 31), + CARBOLIC_ACID("carbolic-acid", "m.surgery.antiseptic", 32), + DRESSING("dressing", "m.surgery.surgical_glove", 33), + STETHOSCOPE("stethoscope", "m.surgery.ultrasound", 34), + THERMOMETER("thermometer", "m.surgery.lab_kit", 37), + CHLOROFORM("chloroform", "m.surgery.anesthetic", 38), + SMELLING_SALTS("smelling-salts", "m.surgery.defibrillator", 39), + SILVER_WIRE("silver-wire", "m.surgery.pins", 40), + SPLINT("splint", "m.surgery.splint", 41), + ARTERY_FORCEPS("artery-forceps", "m.surgery.clamp", 42), + TRANSFUSION("transfusion", "m.surgery.transfusion", 43); + + private final String configKey; + private final String defaultPath; + private final int slot; + + SurgeryTool(String configKey, String defaultPath, int slot) { + this.configKey = configKey; + this.defaultPath = defaultPath; + this.slot = slot; + } + + public String getConfigKey() { return configKey; } + public String getDefaultPath() { return defaultPath; } + public int getSlot() { return slot; } + + // ============================================== + // Finds the tool shown in a menu slot, or null for info blocks and empty slots + // ============================================== + public static SurgeryTool fromSlot(int slot) { + for (SurgeryTool tool : values()) { + if (tool.slot == slot) { + return tool; + } + } + return null; + } +} diff --git a/src/main/java/net/tfminecraft/surgery/managers/SurgeryUIUpdater.java b/src/main/java/net/tfminecraft/surgery/managers/SurgeryUIUpdater.java index b6c25c9..1556aeb 100644 --- a/src/main/java/net/tfminecraft/surgery/managers/SurgeryUIUpdater.java +++ b/src/main/java/net/tfminecraft/surgery/managers/SurgeryUIUpdater.java @@ -1,5 +1,6 @@ package net.tfminecraft.surgery.managers; +import net.tfminecraft.surgery.procedures.Procedure; import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.configuration.file.FileConfiguration; @@ -20,13 +21,11 @@ public class SurgeryUIUpdater { private final JavaPlugin plugin; private final SurgeryStateManager stateManager; - private final DiagnosisChecker diagnosisChecker; private FileConfiguration messages; - public SurgeryUIUpdater(JavaPlugin plugin, SurgeryStateManager stateManager, DiagnosisChecker diagnosisChecker) { + public SurgeryUIUpdater(JavaPlugin plugin, SurgeryStateManager stateManager) { this.plugin = plugin; this.stateManager = stateManager; - this.diagnosisChecker = diagnosisChecker; loadMessages(); } @@ -131,37 +130,37 @@ public void updatePulseBlock(Inventory menu, UUID playerId, String pulse) { // ============================================== // Updates the diagnosis block + // Before the stethoscope examination only the patient's complaint is known // ============================================== // Keep the existing legacy text representation, formatting, and exact-string comparisons. @SuppressWarnings("deprecation") public void updateDiagnosisBlock(Inventory menu, UUID playerId) { - String diagnosis = stateManager.getDiagnosis(playerId); - boolean cured = stateManager.isCured(playerId); - - Material diagnosisColor = cured ? Material.LIME_CONCRETE : Material.RED_CONCRETE; - String diagnosisText = diagnosis != null ? ChatColor.GRAY + diagnosis : ChatColor.GRAY + "The patient has not been diagnosed."; - - ItemStack diagnosisBlock; - if (diagnosis != null && diagnosisChecker.hasBones(diagnosis)) { - // ============================================== - // Show bone information in description - // ============================================== - int brokenBones = stateManager.getRevealedBrokenBones(playerId); - int shatteredBones = stateManager.getRevealedShatteredBones(playerId); - - java.util.List lore = new java.util.ArrayList<>(); - lore.add(diagnosisText); - lore.add(ChatColor.YELLOW + "Broken Bones: " + ChatColor.GRAY + brokenBones); - lore.add(ChatColor.RED + "Shattered Bones: " + ChatColor.GRAY + shatteredBones); - - diagnosisBlock = createInfoBlock(diagnosisColor, ChatColor.GOLD + "Diagnosis", lore); + String ailment = stateManager.getAilmentName(playerId); + java.util.List lore = new java.util.ArrayList<>(); + Material color; + + Procedure procedure = stateManager.getProcedure(playerId); + if (!stateManager.isExamined(playerId) || procedure == null) { + color = Material.RED_CONCRETE; + lore.add(ChatColor.GRAY + "Complaint: " + ChatColor.WHITE + ailment); + lore.add(ChatColor.GRAY + "Examine the patient with the stethoscope."); } else { - diagnosisBlock = createInfoBlock(diagnosisColor, ChatColor.GOLD + "Diagnosis", diagnosisText); + color = stateManager.isCured(playerId) ? Material.LIME_CONCRETE : Material.YELLOW_CONCRETE; + lore.add(ChatColor.GRAY + "The patient suffers from " + ChatColor.WHITE + procedure.name()); + lore.add(ChatColor.GRAY + "Ailment: " + ChatColor.WHITE + ailment); + lore.add(ChatColor.GRAY + "Incisions needed: " + ChatColor.WHITE + procedure.requiredIncisions()); + if (procedure.hasBones()) { + lore.add(ChatColor.YELLOW + "Broken Bones: " + ChatColor.GRAY + stateManager.getRevealedBrokenBones(playerId)); + lore.add(ChatColor.RED + "Shattered Bones: " + ChatColor.GRAY + stateManager.getRevealedShatteredBones(playerId)); + } + if (stateManager.isCured(playerId)) { + lore.add(ChatColor.GREEN + "Treated and dressed"); + } } - - menu.setItem(SurgeryConstants.SLOT_DIAGNOSIS, diagnosisBlock); + + menu.setItem(SurgeryConstants.SLOT_DIAGNOSIS, createInfoBlock(color, ChatColor.GOLD + "Diagnosis", lore)); } - + // ============================================== // Sends a numbered message to the player // ============================================== diff --git a/src/main/java/net/tfminecraft/surgery/procedures/Ailments.java b/src/main/java/net/tfminecraft/surgery/procedures/Ailments.java new file mode 100644 index 0000000..d626ef5 --- /dev/null +++ b/src/main/java/net/tfminecraft/surgery/procedures/Ailments.java @@ -0,0 +1,44 @@ +package net.tfminecraft.surgery.procedures; + +import net.tfminecraft.rpcharacters.api.HealingInjuries; +import net.tfminecraft.rpcharacters.api.HealingInjuries.HealingInjury; +import org.bukkit.entity.Player; + +import java.util.Comparator; +import java.util.List; + +// ============================================== +// Reads and changes a patient's healing ailments through RPCharacters +// Permanent injuries are never returned, so surgery cannot touch them +// ============================================== +public final class Ailments { + + private Ailments() { + } + + // ============================================== + // The ailment surgery treats next: the one with the most healing time left + // ============================================== + public static HealingInjury mostSevere(Player patient) { + List injuries = HealingInjuries.list(patient); + return injuries.stream().max(Comparator.comparingLong(HealingInjury::remainingMs)).orElse(null); + } + + public static HealingInjury find(Player patient, String traitId) { + for (HealingInjury injury : HealingInjuries.list(patient)) { + if (injury.traitId().equalsIgnoreCase(traitId)) { + return injury; + } + } + return null; + } + + public static boolean cure(Player patient, String traitId) { + return HealingInjuries.cure(patient, traitId); + } + + // Returns the new remaining time, or -1 if the ailment is gone + public static long extend(Player patient, String traitId, long extraMs) { + return HealingInjuries.extend(patient, traitId, extraMs); + } +} diff --git a/src/main/java/net/tfminecraft/surgery/procedures/Complication.java b/src/main/java/net/tfminecraft/surgery/procedures/Complication.java new file mode 100644 index 0000000..14ea93a --- /dev/null +++ b/src/main/java/net/tfminecraft/surgery/procedures/Complication.java @@ -0,0 +1,27 @@ +package net.tfminecraft.surgery.procedures; + +import java.util.Locale; + +// ============================================== +// Complications a procedure can carry, each adding pressure during the operation +// ============================================== +public enum Complication { + + // Wound reopens and bleeds every few moves unless sponged + HAEMORRHAGE, + // Patient may collapse while under chloroform and need smelling salts + SHOCK, + // Patient arrives feverish and the fever keeps climbing + SEPSIS; + + public static Complication fromConfig(String value) { + if (value == null) { + return null; + } + try { + return valueOf(value.trim().toUpperCase(Locale.ROOT).replace('-', '_')); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/surgery/procedures/Durations.java b/src/main/java/net/tfminecraft/surgery/procedures/Durations.java new file mode 100644 index 0000000..ada9ad7 --- /dev/null +++ b/src/main/java/net/tfminecraft/surgery/procedures/Durations.java @@ -0,0 +1,51 @@ +package net.tfminecraft.surgery.procedures; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +// ============================================== +// Parses config durations such as "12h", "90m" or "1d 6h" into milliseconds +// ============================================== +public final class Durations { + + private static final Pattern PART = Pattern.compile("(\\d+)\\s*([dhms])"); + + private Durations() { + } + + // Returns -1 when the value is blank or not a valid duration + public static long parseMs(String value) { + if (value == null || value.isBlank()) { + return -1L; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + Matcher matcher = PART.matcher(normalized); + long total = 0L; + int end = 0; + while (matcher.find()) { + if (!normalized.substring(end, matcher.start()).isBlank()) { + return -1L; + } + long amount = Long.parseLong(matcher.group(1)); + total += switch (matcher.group(2)) { + case "d" -> amount * 86_400_000L; + case "h" -> amount * 3_600_000L; + case "m" -> amount * 60_000L; + default -> amount * 1_000L; + }; + end = matcher.end(); + } + return end > 0 && normalized.substring(end).isBlank() ? total : -1L; + } + + // ============================================== + // Short hours form for player messages: "12h", or "45m" under an hour + // ============================================== + public static String formatHours(long ms) { + if (ms < 3_600_000L) { + return Math.max(0L, (ms + 59_999L) / 60_000L) + "m"; + } + return ((ms + 3_599_999L) / 3_600_000L) + "h"; + } +} diff --git a/src/main/java/net/tfminecraft/surgery/procedures/Procedure.java b/src/main/java/net/tfminecraft/surgery/procedures/Procedure.java new file mode 100644 index 0000000..f851dae --- /dev/null +++ b/src/main/java/net/tfminecraft/surgery/procedures/Procedure.java @@ -0,0 +1,22 @@ +package net.tfminecraft.surgery.procedures; + +import java.util.Set; + +// ============================================== +// How a healing ailment is operated on +// ============================================== +public record Procedure(String name, int requiredIncisions, int brokenBones, int shatteredBones, + Set complications) { + + public Procedure { + complications = Set.copyOf(complications); + } + + public boolean hasBones() { + return brokenBones > 0 || shatteredBones > 0; + } + + public boolean has(Complication complication) { + return complications.contains(complication); + } +} diff --git a/src/main/java/net/tfminecraft/surgery/procedures/ProcedureRegistry.java b/src/main/java/net/tfminecraft/surgery/procedures/ProcedureRegistry.java new file mode 100644 index 0000000..3b10304 --- /dev/null +++ b/src/main/java/net/tfminecraft/surgery/procedures/ProcedureRegistry.java @@ -0,0 +1,71 @@ +package net.tfminecraft.surgery.procedures; + +import org.bukkit.configuration.ConfigurationSection; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +// ============================================== +// Procedures keyed by RPCharacters healing trait ID, with a fallback for +// healing ailments that have no procedure of their own +// ============================================== +public class ProcedureRegistry { + + private static final Procedure BUILT_IN_DEFAULT = new Procedure("Injury", 2, 0, 0, Set.of()); + + private final Map procedures = new HashMap<>(); + private Procedure defaultProcedure = BUILT_IN_DEFAULT; + + // ============================================== + // Loads "procedures" and "default-procedure" from the plugin config + // Unknown complication names are reported through the warning callback + // ============================================== + public void load(ConfigurationSection root, Consumer warning) { + procedures.clear(); + ConfigurationSection defaults = root.getConfigurationSection("default-procedure"); + defaultProcedure = defaults == null ? BUILT_IN_DEFAULT : parse(defaults, BUILT_IN_DEFAULT, warning); + + ConfigurationSection section = root.getConfigurationSection("procedures"); + if (section == null) { + return; + } + for (String traitId : section.getKeys(false)) { + ConfigurationSection procedure = section.getConfigurationSection(traitId); + if (procedure != null) { + procedures.put(traitId.toLowerCase(Locale.ROOT), parse(procedure, defaultProcedure, warning)); + } + } + } + + // ============================================== + // The procedure for a healing trait ID, falling back to the default procedure + // ============================================== + public Procedure get(String traitId) { + if (traitId == null) { + return defaultProcedure; + } + return procedures.getOrDefault(traitId.toLowerCase(Locale.ROOT), defaultProcedure); + } + + private static Procedure parse(ConfigurationSection section, Procedure fallback, Consumer warning) { + Set complications = EnumSet.noneOf(Complication.class); + for (String value : section.getStringList("complications")) { + Complication complication = Complication.fromConfig(value); + if (complication == null) { + warning.accept("Unknown complication '" + value + "' in " + section.getCurrentPath()); + } else { + complications.add(complication); + } + } + return new Procedure( + section.getString("name", fallback.name()), + Math.max(0, section.getInt("required-incisions", fallback.requiredIncisions())), + Math.max(0, section.getInt("broken-bones", fallback.brokenBones())), + Math.max(0, section.getInt("shattered-bones", fallback.shatteredBones())), + complications); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0dba8b1..f15f76a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,5 +1,19 @@ # Surgery Plugin Configuration -# This file contains all gameplay settings and mechanics +# Physicians operate on the healing ailments of RPCharacters characters +# (broken arm, broken leg, half blind, ...). Permanent injuries cannot be +# treated. A successful surgery cures the ailment; a failed one makes it take +# longer to heal. + +# Permission needed to operate. The Physician profession upgrade grants it, +# the same permission that unlocks crafting the surgical tools +permission: "professions.physician" + +# Healing time added to the ailment when a surgery fails after the patient +# was sedated or cut (d/h/m/s, e.g. "12h" or "1d 6h"; "0h" disables it) +failure-healing-penalty: "12h" + +# How long a patient has to accept a surgery offer +request-timeout-seconds: 60 # Maximum distance in blocks between surgeon and patient to perform surgery max-surgery-distance: 5.0 @@ -9,95 +23,44 @@ max-surgery-distance: 5.0 patient-leave-distance-multiplier: 2.0 # ============================================ -# DIAGNOSES -# ============================================ - -# List of possible diagnoses/injuries -# These are randomly assigned when the ultrasound is used -diagnoses: - - "Torn Tongue Muscle" - - "Paper Cuts" - - "Grumbleteeth" - - "Chicken Feet" - - "Broken Heart" - - "Brain Eating Amoeba" - - "Ecto-Bones" - - "Moldy Guts" - - "Fatty Liver" - - "Arcane Infection" - - "Lupus" - - "Broken Arm" - - "Broken Leg" - - "Bird Flu" - - "Turtle Flu" - - "Monkey Flu" - - "Lung Tumor" - - "Brain Tumor" - - "Liver Infection" - - "Kidney Failure" - - "Appendicitis" - - "Nose Job" - - "Herniated Disc" - - "Heart Attack" - - "Swallowed Ingot" - - "Broken Everything" - - "Serious Head Injury" - - "Serious Trauma" - - "Massive Trauma" - -# Diagnoses that are flu types (require temperature at 98.6°F to use surgical glove) -flu-diagnoses: - - "Bird Flu" - - "Turtle Flu" - - "Monkey Flu" - -# Required incisions for each diagnosis to unlock the surgical glove and "Fix" the patient -required-incisions: - "Nose Job": 1 - "Lung Tumor": 1 - "Heart Attack": 2 - "Brain Tumor": 5 - "Liver Infection": 2 - "Kidney Failure": 2 - "Appendicitis": 3 - "Swallowed Ingot": 2 - "Herniated Disc": 3 - "Serious Head Injury": 4 - "Serious Trauma": 2 - "Massive Trauma": 3 - "Torn Tongue Muscle": 1 - "Paper Cuts": 2 - "Grumbleteeth": 1 - "Chicken Feet": 1 - "Broken Heart": 1 - "Brain Eating Amoeba": 5 - "Ecto-Bones": 1 - "Moldy Guts": 2 - "Fatty Liver": 3 - "Arcane Infection": 3 - "Lupus": 3 - "Broken Arm": 2 - "Broken Leg": 2 - "Broken Everything": 4 - -# ============================================ -# BONE COUNTS -# ============================================ - -# Number of broken and shattered bones for bone-related diagnoses -bone-counts: - "Broken Arm": - broken: 2 - shattered: 0 - "Broken Leg": - broken: 2 - shattered: 1 - "Ecto-Bones": - broken: 1 - shattered: 2 - "Broken Everything": - broken: 3 - shattered: 2 +# PROCEDURES +# ============================================ + +# How each healing ailment is operated on, keyed by RPCharacters trait ID. +# The stethoscope examination reveals the procedure. Once required-incisions +# are made, the bones are exposed; after every bone is set, the linen dressing +# treats the ailment. +# Complications: +# haemorrhage - the wound keeps reopening and bleeding +# shock - the patient may collapse under chloroform +# sepsis - the patient is feverish and the fever keeps climbing +procedures: + broken_arm: + name: "Fractured Arm" + required-incisions: 2 + broken-bones: 2 + shattered-bones: 0 + complications: [] + broken_leg: + name: "Compound Fracture of the Leg" + required-incisions: 2 + broken-bones: 2 + shattered-bones: 1 + complications: [haemorrhage] + half_blind: + name: "Injured Eye" + required-incisions: 1 + broken-bones: 0 + shattered-bones: 0 + complications: [shock, sepsis] + +# Used for healing ailments with no procedure above +default-procedure: + name: "Injury" + required-incisions: 2 + broken-bones: 0 + shattered-bones: 0 + complications: [] # ============================================ # SKILL FAIL CHANCES @@ -120,9 +83,7 @@ temperature: red-temp-threshold: 106.0 # Red temp danger zone success-threshold: 100.0 # Must be <= this to succeed rise-rate: 1.8 # Temp increase per move - antibiotics-change: 5.4 # ±F from antibiotics - flu-temp-min: 99.0 # Min initial temp for flu diagnoses - flu-temp-max: 104.0 # Max initial temp for flu diagnoses + tincture-change: 5.4 # ±F from the willow-bark tincture # ============================================ # PULSE SETTINGS @@ -137,13 +98,13 @@ pulse: # ============================================ death-timers: - defibrillator-countdown: 2 # Moves until death after heart stops + collapse-countdown: 2 # Moves until death after the patient collapses weak-pulse-turns: 2 # Consecutive turns with Extremely Weak pulse red-temp-turns: 2 # Consecutive turns with red temperature - anesthetic-reuse-cooldown: 4 # Moves before anesthetic can be reused + anesthetic-reuse-cooldown: 4 # Moves before chloroform can be given again # ============================================ -# ANESTHETIC WEAR-OFF +# CHLOROFORM WEAR-OFF # ============================================ anesthetic: @@ -151,46 +112,27 @@ anesthetic: coming-to-moves: 2 # Moves of "Coming to" before patient is fully awake # ============================================ -# BONE MECHANICS -# ============================================ - -bones: - # Chance to randomly reveal a bone with each scalpel use for non-bone diagnoses - random-reveal-chance: 0.25 - -# ============================================ -# DIAGNOSIS-SPECIFIC MECHANICS +# COMPLICATIONS # ============================================ -diagnosis-mechanics: - moldy-guts: - bleeding-interval-min: 3 # Forces bleeding every 3-4 moves +complications: + haemorrhage: + bleeding-interval-min: 3 # Forces bleeding every 3-4 moves since the last sponge bleeding-interval-max: 4 - - fatty-liver: - heart-stop-chance: 0.20 # 20% chance heart stops when unconscious - - broken-heart: - heart-stop-chance: 0.35 # 35% chance heart stops when unconscious - - arcane-infection: - chaos-chance: 0.25 # 25% chance for chaos each move - temp-spike-max: 4.0 # Random temp spike up to +4°F - temp-drop-max: 2.0 # Random temp drop up to -2°F - - lupus: - howl-chance: 0.15 # 15% chance for howl effect - - paper-cuts: - scalpel-uses-required: 2 # Scalpel uses to examine wounds + shock: + collapse-chance: 0.20 # Chance per move to collapse while unconscious + sepsis: + fever-min: 100.0 # Fever found by the examination + fever-max: 104.0 # ============================================ # COMMANDS # ============================================ # Commands to execute on surgery completion -# Use %surgeon% for the surgeon's name and %player% for the patient's name +# Use %surgeon% for the surgeon's name, %player% for the patient's name, +# and %ailment% for the ailment # These are executed by the console commands: - surgery-success: "sudo %surgeon% me Successfully treated %player%!" - surgery-failure: "sudo %surgeon% me Failed surgery on %player%!" + surgery-success: "sudo %surgeon% me Successfully operated on %player%'s %ailment%!" + surgery-failure: "sudo %surgeon% me Failed to mend %player%'s %ailment%!" diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index e0de532..5c63e8b 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -2,7 +2,7 @@ # This file contains all text displayed to players # Command Messages -command-usage: "&cUsage: /surgery " +command-usage: "&cUsage: /surgery | accept | deny" command-player-not-found: "&cPlayer not found or not online!" command-too-far: "&cThe patient must be within %distance% blocks of you!" command-console: "&cThis command can only be used by players." @@ -10,62 +10,78 @@ command-self-surgery: "&cYou cannot perform surgery on yourself!" command-already-in-surgery: "&cYou are already performing surgery! Close the current menu first." command-patient-in-surgery: "&cThat player is already undergoing surgery!" command-patient-is-operating: "&cThat player is busy performing surgery themselves!" +command-no-permission: "&cOnly a trained &6Physician &ccan perform surgery." +command-no-ailment: "&e%patient% has no ailment that surgery can treat." + +# Consent +request-sent: "&7You offered to operate on &f%patient%&7's &f%ailment%&7. Waiting for them to accept..." +request-already-pending: "&c%patient% already has a surgery offer waiting. They need to accept or deny it first." +request-received: "&6Dr. %surgeon% &eoffers to operate on your &f%ailment% &e(&f%remaining%&e left to heal). &7If the surgery fails, it will take &c%extra% &7longer to heal. This offer expires in %seconds%s." +request-accept-button: "&a&l[Accept]" +request-deny-button: "&c&l[Deny]" +request-none: "&cYou have no surgery offer to answer." +request-surgeon-gone: "&cThe physician is no longer available." +request-cannot-start: "&cDr. %surgeon% cannot begin the surgery right now." +request-ailment-gone: "&eThat ailment has already healed." +request-accepted-patient: "&aYou lie down on the table. Dr. %surgeon% begins the operation." +request-accepted-surgeon: "&a%patient% has agreed to the operation." +request-denied-patient: "&7You declined the operation." +request-denied-surgeon: "&c%patient% declined the operation." # Item Errors -item-not-in-inventory: "&cYou don't have this item in your inventory!" +item-not-in-inventory: "&cYou don't have this instrument in your inventory!" # Success/Failure Messages surgery-successful: "&6Surgery Successful!" -surgery-successful-subtitle: "&aYou have successfully treated the patient!" +surgery-successful-subtitle: "&aYou have mended %patient%'s %ailment%!" +patient-surgery-successful: "&aDr. %surgeon% has mended your %ailment%." surgery-failed: "&cSurgery Failed!" +surgery-failed-penalty: "&7The patient's recovery will take &c%extra% &7longer." +patient-surgery-failed: "&cThe surgery failed. Your %ailment% will take %extra% longer to heal (%remaining% left)." +surgery-cancelled: "&7You set down your instruments before operating. No harm done." +ailment-already-healed: "&eThe patient's %ailment% had already healed by the time you finished." # Failure Reasons -failure-not-resuscitated: "&cThe patient was not resuscitated in time." -failure-stabbed-awake: "&cYou just stabbed someone who was fully awake!" +failure-not-resuscitated: "&cThe patient could not be revived in time." +failure-stabbed-awake: "&cYou cut into a patient who was wide awake!" failure-bled-out: "&cThe patient has bled out!" failure-infection: "&cThe patient has succumbed to infection." -failure-gave-up: "&cYou couldn't save them!" -failure-anesthetic-misuse: "&cYou put the patient to sleep. Permanently." -failure-weak-pulse: "&cThe patient's heart has stopped beating." -failure-no-diagnosis: "&cYou cut into the patient without knowing what was wrong!" -failure-high-fever: "&cThe patient died from severe hyperthermia!" +failure-gave-up: "&cYou abandoned the patient mid-operation!" +failure-anesthetic-misuse: "&cToo much chloroform. The patient will not wake." +failure-weak-pulse: "&cThe patient's heart has given out." +failure-high-fever: "&cThe patient burned up with fever!" failure-patient-left: "&cThe patient is no longer on the operating table!" # Progress Messages bleeding-warning: "&cIt's becoming hard to see..." -wounds-examined: "&aYou have examined the wounds" -temperature-reduced: "&aTemperature reduced by 3°C!" -operation-clean: "&aOperation site is clean. Temperature will not rise." +examined: "&aExamination complete: &f%procedure%&a. Make %incisions% incision(s) to reach it." +temperature-reduced: "&aThe tincture brings the fever down." +operation-clean: "&aCarbolic acid has cleaned the site. The fever will not rise." vision-cleared: "&aVision cleared! Skill fail chance reduced for next move." -condition-treated-incomplete: "&eCondition treated, but surgery not yet complete." +condition-treated-incomplete: "&eThe ailment is dressed, but the surgery is not yet complete." check-remaining: "&eCheck: bones, incisions, bleeding, temperature, status" -surgical-glove-ready: "&aSurgical Glove is ready - you can fix the problem now!" +dressing-ready: "&aThe injury is set - apply the linen dressing!" # Discovery Messages -discovered-broken-bone: "&eYou discovered a broken bone!" -discovered-shattered-bone: "&eYou discovered a shattered bone!" +discovered-broken-bone: "&eThe incision reveals a broken bone!" +discovered-shattered-bone: "&eThe incision reveals a shattered bone!" # Tool Availability Messages -defibrillator-available: "&cDefibrillator is now available!" -pins-available: "&eSurgical Pins are now available!" -splint-available: "&eSurgical Splint is now available!" -clamp-available: "&cSurgical Clamp is now available!" +smelling-salts-available: "&cThe patient has collapsed - reach for the smelling salts!" +silver-wire-available: "&eSilver wire is ready to bind the shattered bone!" +splint-available: "&eA splint is ready to set the broken bone!" +artery-forceps-available: "&cArtery forceps are ready to stop the bleeding!" # Status Change Messages pulse-weakening: "&cBlood loss is weakening the patient's pulse!" -protection-lost: "&cOperation site contaminated! Temperature protection lost." -patient-coming-to: "&eThe anesthetic is wearing off - the patient is coming to!" -patient-woke-up: "&cThe patient is fully awake! Re-administer anesthetic before cutting!" +protection-lost: "&cOperation site contaminated! The carbolic acid no longer protects it." +patient-coming-to: "&eThe chloroform is wearing off - the patient is coming to!" +patient-woke-up: "&cThe patient is fully awake! Give more chloroform before cutting!" -# Diagnosis-Specific Messages -moldy-guts: "&4The patient's guts burst making it impossible to see anything!" -fatty-liver-heart-stop: "&4The fat build up has caused the patient's heart to stop!" -broken-heart-stop: "&4The patient's heart breaks!" -chaos-temp-spike: "&cArcane energy causes temperature spike!" -chaos-temp-drop: "&bArcane energy causes temperature drop!" -chaos-heart-stop: "&4Arcane energy stops the heart!" -chaos-status-change: "&eArcane energy changes patient status!" -lupus-howl: "&4The patient howls, causing an incision and bleeding!" +# Complication Messages +haemorrhage: "&4The wound haemorrhages, blood pours over everything!" +shock-collapse: "&4The patient goes grey and collapses from shock!" +sepsis-found: "&cThe wound is septic - the patient is burning with fever!" # Skill Fail Messages (Sponge - slot 28) skill-fail-sponge: @@ -101,158 +117,113 @@ skill-fail-scalpel: - "The lighting made you misjudge." - "You sneezed while cutting." -# Skill Fail Messages (Stitches - slot 30) -skill-fail-stitches: - - "You somehow tied yourself up in stitches." - - "The suture snapped!" +# Skill Fail Messages (Catgut Suture - slot 30) +skill-fail-suture: + - "You somehow tied yourself up in catgut." + - "The catgut snapped!" - "You tied the knot wrong." - "The stitches are too loose." - - "You poked yourself with the needle." + - "You pricked your finger on the needle." - "The thread tangled." - "You made uneven stitches." - - "The needle bent." + - "The curved needle bent." - "You lost count of the stitches." - - "The suture pulled through." - - "You stitched skin to glove." + - "The catgut pulled through the skin." + - "You stitched the wound to your cuff." - "The knot came undone." - - "You used the wrong stitch pattern." - - "The thread broke mid-stitch." + - "The catgut had dried out and cracked." -# Skill Fail Messages (Antibiotics - slot 31) -skill-fail-antibiotics: - - "This is the wrong medication! The bacteria like it." - - "You gave the wrong dosage!" - - "The antibiotics are expired!" - - "You mixed incompatible drugs!" - - "You gave it via the wrong route!" - - "The patient is allergic!" +# Skill Fail Messages (Willow-Bark Tincture - slot 31) +skill-fail-tincture: + - "You poured the tincture down the wrong pipe." + - "You gave a whole bottle instead of a spoonful!" + - "The tincture had turned sour." + - "You grabbed the laudanum by mistake!" + - "The patient spat it back out." - "You dropped the vial!" - - "The antibiotics crystallized!" - - "You confused the labels, gave the patient Fentanyl instead!" - - "The medication was contaminated!" - - "You gave it too quickly!" - - "The IV line infiltrated!" - - "You used the wrong antibiotic!" - - "The patient vomited it up!" + - "The tincture was mostly gin." + - "You confused the labels and gave cod liver oil." + - "The dose was far too weak." + - "The patient choked on it." -# Skill Fail Messages (Antiseptic - slot 32) -skill-fail-antiseptic: - - "You spilled antiseptic on your shoes. They are very clean now." - - "The antiseptic evaporated!" - - "You used expired antiseptic!" - - "You sprayed it in your eyes!" - - "The bottle was empty!" - - "You grabbed the wrong solution!" - - "It spilled on the floor!" - - "The antiseptic is contaminated!" - - "You forgot to let it dry!" - - "You wiped it off too soon!" - - "You mixed it with the wrong solution!" - - "The spray nozzle broke!" - - "You used too much!" - - "You missed the target area!" +# Skill Fail Messages (Carbolic Acid - slot 32) +skill-fail-carbolic-acid: + - "You splashed carbolic acid on your boots. They are very clean now." + - "The carbolic spray sputtered and died." + - "The bottle was nearly empty!" + - "The fumes made your eyes stream." + - "You grabbed the vinegar instead!" + - "It spilled across the floor!" + - "You diluted it far too much." + - "You wiped it off before it could work." + - "The spray pump jammed." + - "You burned your fingers on the neat acid." -# Skill Fail Messages (Surgical Glove/Fix It - slot 33) -skill-fail-surgical-glove: - - "You screwed it up! Try again." - - "Your glove tore!" - - "You contaminated the glove!" - - "The glove is inside-out!" - - "You grabbed the wrong size!" - - "The glove has a hole!" - - "You double-gloved wrong!" - - "The powder caused a reaction!" - - "You can't feel anything!" - - "The glove doesn't fit you must acquit!" - - "You dropped a tool!" - - "Your hand is sweating profusely!" - - "The glove got stuck!" - - "You used a latex glove on an allergic patient!" +# Skill Fail Messages (Linen Dressing - slot 33) +skill-fail-dressing: + - "You wrapped the dressing far too tight." + - "The linen unravelled in your hands." + - "You dropped the dressing on the floor." + - "The lint was filthy." + - "You bandaged the wrong side." + - "The dressing slipped straight off." + - "You ran out of linen halfway." + - "You tied the bandage to the table." -# Skill Fail Messages (Ultrasound - slot 34) -skill-fail-ultrasound: - - "You scanned the nurse with your ultrasound!" - - "The screen is showing static." - - "You dropped the ultrasound probe." - - "The gel bottle exploded everywhere." - - "You scanned yourself by mistake." - - "The machine is showing error codes." - - "You forgot to turn it on." - - "The image is too blurry to read." - - "You scanned the wrong area." - - "The battery died mid-scan." - - "You interpreted the results backwards." - - "The probe slipped out of your hands." - - "You spilled ultrasound gel everywhere." - - "The machine made a terrible noise." +# Skill Fail Messages (Stethoscope - slot 34) +skill-fail-stethoscope: + - "You listened through the wrong end of the stethoscope." + - "All you can hear is your own heartbeat." + - "The nurse coughed at the worst moment." + - "You pressed it to the patient's elbow." + - "The wooden tube has a crack in it." + - "You dropped the stethoscope under the table." + - "The patient's snoring drowns everything out." + - "You forgot what a healthy chest sounds like." -# Skill Fail Messages (Lab Kit - slot 37) -skill-fail-lab-kit: - - "You contaminated the sample." - - "You spilled the lab kit everywhere." - - "The test results are inconclusive." - - "You mixed up the samples." - - "The equipment malfunctioned." - - "You dropped the sample on the floor." - - "The reagents expired." - - "You forgot to calibrate the machine." - - "The sample was too small." - - "You contaminated the work area." - - "The centrifuge broke." - - "You used the wrong test." - - "The sample coagulated." - - "You spilled blood on the paperwork." +# Skill Fail Messages (Clinical Thermometer - slot 37) +skill-fail-thermometer: + - "The thermometer slipped and shattered." + - "You read it upside down." + - "The mercury refuses to move." + - "You took your own temperature by mistake." + - "The patient bit through the glass!" + - "You forgot to shake it down first." + - "The markings have worn away." + - "You left it in the tea." -# Skill Fail Messages (Anesthetic - slot 38) -skill-fail-anesthetic: - - "You end up inhaling all the anesthetic yourself. You feel woozy." - - "You gave too much!" - - "The patient is under-anesthetized!" - - "You forgot to check allergies!" - - "The gas mask leaked!" - - "You mixed the wrong gases!" - - "The ventilator failed!" - - "Anesthesia awareness!" - - "The patient isn't breathing!" - - "You gave it in the wrong vein!" - - "The oxygen tank is empty!" - - "You dropped the mask!" - - "The dosage calculator was wrong!" - - "The patient woke up mid-surgery!" +# Skill Fail Messages (Chloroform - slot 38) +skill-fail-chloroform: + - "You breathed in the chloroform yourself. You feel woozy." + - "The cloth dried out before it reached the patient." + - "You spilled the chloroform on the floor." + - "Not nearly enough drops!" + - "The patient held their breath." + - "The bottle's stopper is stuck fast." + - "The patient struggled free of the cloth." + - "The nurse fainted from the fumes." -# Skill Fail Messages (Defibrillator - slot 39) -skill-fail-defibrillator: - - "You electrocuted yourself!" - - "The pads weren't attached!" - - "You shocked at the wrong time!" - - "The battery is dead!" - - "You forgot to yell 'Clear'!" - - "The machine malfunctioned!" - - "You used too much power!" - - "The gel dried out!" - - "You placed the pads backwards!" - - "The defib didn't charge!" - - "You shocked yourself instead!" - - "The cables came loose!" - - "You delivered it during a pulse!" - - "The pads slipped off!" +# Skill Fail Messages (Smelling Salts - slot 39) +skill-fail-smelling-salts: + - "You took a whiff yourself and reeled back!" + - "The salts have lost their pungency." + - "You couldn't unscrew the cap." + - "You held it under the patient's chin, not their nose." + - "The vial slipped from your fingers." + - "The patient barely stirs." + - "You waved it far too far away." + - "The crystals spilled everywhere." -# Skill Fail Messages (Pins - slot 40) -skill-fail-pins: - - "You jabbed the pin through the artery!" - - "The pin bent!" - - "You hammered too hard!" - - "The pin went in crooked!" - - "You missed the bone!" - - "The pin is too short!" - - "You used the wrong size!" - - "The pin broke!" - - "You hit your thumb!" - - "The alignment is wrong!" - - "You drove it too deep!" - - "The pin won't grip!" - - "You fractured the bone more!" - - "The pin fell out!" +# Skill Fail Messages (Silver Wire - slot 40) +skill-fail-silver-wire: + - "The wire snapped as you twisted it!" + - "You drilled straight through the bone." + - "The wire slipped off the fragment." + - "You wired the wrong pieces together." + - "The wire is far too short." + - "You pricked an artery with the wire!" + - "The wire kinked and won't bind." + - "You twisted it so tight the bone cracked." # Skill Fail Messages (Splint - slot 41) skill-fail-splint: @@ -271,39 +242,24 @@ skill-fail-splint: - "It's not immobilizing properly!" - "You dropped the splint!" -# Skill Fail Messages (Clamp - slot 42) -skill-fail-clamp: - - "The clamp fell out of your hand, oh well." +# Skill Fail Messages (Artery Forceps - slot 42) +skill-fail-artery-forceps: + - "The forceps fell out of your hand, oh well." - "You clamped the wrong vessel!" - - "The clamp slipped off!" + - "The forceps slipped off!" - "You crushed the tissue!" - - "The clamp is too tight!" + - "The ratchet won't catch!" - "You clamped a nerve!" - - "The clamp broke!" - - "You dropped it in the wound!" - - "It won't stay closed!" - - "You applied it backwards!" - - "The spring is too weak!" - - "You clamped healthy tissue!" - - "The clamp is contaminated!" + - "You dropped them in the wound!" - "You lost your grip!" -# Skill Fail Messages (Transfusion - slot 43) +# Skill Fail Messages (Transfusion Syringe - slot 43) skill-fail-transfusion: - "You spilled blood everywhere!" - - "Wrong blood type!" - - "The blood bag leaked!" - - "You mixed up the blood units!" - - "The blood was expired!" - - "You dropped the blood bag!" - - "The IV line kinked!" - - "You started the transfusion too fast!" - - "The blood wasn't warmed!" - - "Transfusion reaction!" - - "You punctured the bag!" - - "The blood separated!" - - "You connected the wrong tubing!" - - "The filter clogged!" - -# Skill Fail Messages (Pins - Ecto-Bones special) -skill-fail-pins-ecto-bones: "The pin phased through the bone and jabbed an artery, shattering another bone in the process!" \ No newline at end of file + - "The blood clotted in the syringe!" + - "Air got into the syringe!" + - "The donor fainted mid-transfusion!" + - "You pushed the plunger far too fast!" + - "The brass syringe jammed." + - "The needle came out of the vein." + - "You drew blood out instead of putting it in!" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 43dddb1..3024637 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,15 +3,9 @@ version: ${project.version} name: surgery author: Justin api-version: '1.21.10' -depend: [TLibs] +depend: [TLibs, RPCharacters] commands: surgery: - description: Opens the surgery menu - usage: /surgery - permission: surgery.use - -permissions: - surgery.use: - description: Allows performing surgery on another player - default: true \ No newline at end of file + description: Offer surgery to a patient, or accept or deny an offer + usage: /surgery | accept | deny diff --git a/src/main/resources/surgeryItemsConfig.yml b/src/main/resources/surgeryItemsConfig.yml index f87ab30..b08d16f 100644 --- a/src/main/resources/surgeryItemsConfig.yml +++ b/src/main/resources/surgeryItemsConfig.yml @@ -1,18 +1,20 @@ # Surgery Items Configuration -# Configure the TLibs item paths for all surgery tools +# TLibs item paths for the Victorian surgical instruments. +# The MMOItems IDs predate the Victorian redesign; keeping them means tools +# players already crafted keep working. items: sponge: "m.surgery.sponge" scalpel: "m.surgery.scalpel" - stitches: "m.surgery.stitches" - antibiotics: "m.surgery.antibiotics" - antiseptic: "m.surgery.antiseptic" - surgical-glove: "m.surgery.surgical_glove" - ultrasound: "m.surgery.ultrasound" - lab-kit: "m.surgery.lab_kit" - anesthetic: "m.surgery.anesthetic" - defibrillator: "m.surgery.defibrillator" - pins: "m.surgery.pins" + suture: "m.surgery.stitches" + tincture: "m.surgery.antibiotics" + carbolic-acid: "m.surgery.antiseptic" + dressing: "m.surgery.surgical_glove" + stethoscope: "m.surgery.ultrasound" + thermometer: "m.surgery.lab_kit" + chloroform: "m.surgery.anesthetic" + smelling-salts: "m.surgery.defibrillator" + silver-wire: "m.surgery.pins" splint: "m.surgery.splint" - clamp: "m.surgery.clamp" + artery-forceps: "m.surgery.clamp" transfusion: "m.surgery.transfusion" diff --git a/src/test/java/net/tfminecraft/surgery/procedures/DurationsTest.java b/src/test/java/net/tfminecraft/surgery/procedures/DurationsTest.java new file mode 100644 index 0000000..d4a4c53 --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/procedures/DurationsTest.java @@ -0,0 +1,35 @@ +package net.tfminecraft.surgery.procedures; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class DurationsTest { + + private static final long HOUR = 3_600_000L; + + @Test + void parsesSingleAndCombinedUnits() { + assertEquals(12 * HOUR, Durations.parseMs("12h")); + assertEquals(90 * 60_000L, Durations.parseMs("90m")); + assertEquals(30 * HOUR, Durations.parseMs("1d 6h")); + assertEquals(30 * HOUR, Durations.parseMs("1D6H")); + assertEquals(0L, Durations.parseMs("0h")); + } + + @Test + void rejectsInvalidValues() { + assertEquals(-1L, Durations.parseMs(null)); + assertEquals(-1L, Durations.parseMs("")); + assertEquals(-1L, Durations.parseMs("12")); + assertEquals(-1L, Durations.parseMs("twelve hours")); + assertEquals(-1L, Durations.parseMs("12h soon")); + } + + @Test + void formatsHoursRoundedUp() { + assertEquals("12h", Durations.formatHours(12 * HOUR)); + assertEquals("49h", Durations.formatHours(48 * HOUR + 1)); + assertEquals("45m", Durations.formatHours(45 * 60_000L)); + } +} diff --git a/src/test/java/net/tfminecraft/surgery/procedures/ProcedureRegistryTest.java b/src/test/java/net/tfminecraft/surgery/procedures/ProcedureRegistryTest.java new file mode 100644 index 0000000..4aa1bcb --- /dev/null +++ b/src/test/java/net/tfminecraft/surgery/procedures/ProcedureRegistryTest.java @@ -0,0 +1,85 @@ +package net.tfminecraft.surgery.procedures; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; + +class ProcedureRegistryTest { + + private static ProcedureRegistry load(String yaml, List warnings) throws InvalidConfigurationException { + YamlConfiguration config = new YamlConfiguration(); + config.loadFromString(yaml); + ProcedureRegistry registry = new ProcedureRegistry(); + registry.load(config, warnings::add); + return registry; + } + + @Test + void loadsProceduresByTraitIdIgnoringCase() throws InvalidConfigurationException { + ProcedureRegistry registry = load(""" + procedures: + Broken_Leg: + name: "Compound Fracture" + required-incisions: 2 + broken-bones: 2 + shattered-bones: 1 + complications: [haemorrhage, SEPSIS] + """, new ArrayList<>()); + + Procedure leg = registry.get("broken_leg"); + assertEquals("Compound Fracture", leg.name()); + assertEquals(2, leg.requiredIncisions()); + assertEquals(1, leg.shatteredBones()); + assertTrue(leg.hasBones()); + assertTrue(leg.has(Complication.HAEMORRHAGE)); + assertTrue(leg.has(Complication.SEPSIS)); + assertFalse(leg.has(Complication.SHOCK)); + } + + @Test + void unknownAilmentsUseTheDefaultProcedure() throws InvalidConfigurationException { + ProcedureRegistry registry = load(""" + default-procedure: + name: "Wound" + required-incisions: 3 + """, new ArrayList<>()); + + Procedure unknown = registry.get("sprained_wrist"); + assertEquals("Wound", unknown.name()); + assertEquals(3, unknown.requiredIncisions()); + assertFalse(unknown.hasBones()); + } + + @Test + void procedureFieldsFallBackToTheDefault() throws InvalidConfigurationException { + ProcedureRegistry registry = load(""" + default-procedure: + required-incisions: 4 + procedures: + half_blind: + name: "Injured Eye" + """, new ArrayList<>()); + + assertEquals(4, registry.get("half_blind").requiredIncisions()); + } + + @Test + void warnsAboutUnknownComplications() throws InvalidConfigurationException { + List warnings = new ArrayList<>(); + ProcedureRegistry registry = load(""" + procedures: + broken_arm: + complications: [lupus, shock] + """, warnings); + + assertEquals(1, warnings.size()); + assertTrue(registry.get("broken_arm").has(Complication.SHOCK)); + } +}