Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
26 changes: 26 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<rpcharacters.version>2.4.0</rpcharacters.version>
<tlibs.version>2.0.0</tlibs.version>
</properties>

Expand All @@ -39,6 +40,26 @@
</exclusion>
</exclusions>
</dependency>
<!-- RPCharacters: healing ailments that surgery treats -->
<dependency>
<groupId>net.tfminecraft</groupId>
<artifactId>rpcharacters</artifactId>
<version>${rpcharacters.version}</version>
<scope>provided</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>

<repositories>
Expand Down Expand Up @@ -71,6 +92,11 @@
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
4 changes: 3 additions & 1 deletion src/main/java/net/tfminecraft/surgery/SurgeryPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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!");
}
Expand Down
197 changes: 167 additions & 30 deletions src/main/java/net/tfminecraft/surgery/commands/SurgeryCommand.java
Original file line number Diff line number Diff line change
@@ -1,78 +1,196 @@
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 <player> | accept | deny"));
return true;
}

// Check if player name is provided in command
if (args.length == 0) {
surgeon.sendMessage(uiUpdater.getMessage("command-usage", "&cUsage: /surgery <player_name>"));
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
// patient would run two independent surgeries on one body
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())
Expand All @@ -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<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
List<String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

This file was deleted.

Loading