From c810d5967501252a9e3d4255b5aa3115fcdd124f Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:04:02 +0000 Subject: [PATCH 1/3] feat: server-owned vehicles with /vf server Staff can convert a vehicle to the owner value `server` with /vf server and a right-click. A server vehicle has no player owner, whitelist, group access or tickets. Players with vf.server.use or vf.admin may use it, and the new ServerVehicleUseEvent lets other plugins admit more players, such as a battle side. /vf server list lists them; /vf takeover or the staff release button reverts the conversion. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++++++ .../vehicleframework/api/OwnershipReason.java | 2 + .../vehicleframework/api/VehicleOwners.java | 14 ++++- .../events/ServerVehicleUseEvent.java | 40 ++++++++++++++ .../managers/CommandManager.java | 16 ++++++ .../managers/InventoryManager.java | 5 +- .../managers/OwnershipManager.java | 51 +++++++++++++++++ .../managers/VehicleManager.java | 15 ++++- .../ownership/GroupAccess.java | 2 +- .../ownership/OwnerNames.java | 3 + .../ownership/OwnershipAccess.java | 3 +- .../ownership/OwnershipActions.java | 4 ++ .../ownership/PendingActions.java | 3 +- .../ownership/ServerVehicles.java | 41 ++++++++++++++ .../vehicleframework/util/TabCompletion.java | 6 +- .../vehicles/handlers/VehicleTicketRules.java | 10 +++- src/main/resources/plugin.yml | 10 +++- .../api/VehicleOwnersTest.java | 11 ++++ .../CommandAndOwnershipCoverageTest.java | 26 ++++++++- .../InventoryManagerCoverageTest.java | 11 ++++ .../managers/OwnershipManagerTest.java | 52 ++++++++++++++++++ .../managers/VehicleManagerCoverageTest.java | 38 +++++++++++++ .../ownership/OwnerNamesTest.java | 6 ++ .../ownership/ServerVehiclesTest.java | 55 +++++++++++++++++++ .../handlers/VehicleTicketRulesTest.java | 15 +++++ 25 files changed, 441 insertions(+), 15 deletions(-) create mode 100644 src/main/java/net/tfminecraft/vehicleframework/events/ServerVehicleUseEvent.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/ownership/ServerVehicles.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/ownership/ServerVehiclesTest.java diff --git a/README.md b/README.md index dacff1d..6776f2d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,23 @@ are reported separately. HTML/XML reports are written to `target/site/jacoco/`. The Build workflow runs verification on pull requests and pushes to `main` and uploads both test and coverage reports. +## Server-owned vehicles + +Staff can run `/vf server` and right-click a vehicle within 60 seconds to convert it. +`/vf server list` lists live and stored server vehicles (also available from console). +Conversion clears the whitelist, group switches and tickets. These vehicles cannot be +claimed. Use requires `vf.server.use` (default op) or `vf.admin`. Whitelists, group +switches and tickets cannot be enabled on them. Revert with `/vf takeover [character]` +or the staff ownership menu's release button. + +API consumers can recognize the case-insensitive owner value `server` with +`VehicleOwners.isServer`; its display name is `Server`. It is neither unowned nor a +personal/character owner. Conversion fires the usual ownership events with staff reason +`SERVER`. The synchronous `ServerVehicleUseEvent` defaults to denied; listeners may +call `setAllowed(true)` to admit a player without permission, for example for a side +assigned by staff during a battle. Coupled cars also check their locomotive's access. +`cleanup.keep-owners: [server]` protects these vehicles from configured cleanup. + ## License Copyright (c) 2026 TF-Minecraft contributors. diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipReason.java b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipReason.java index 800dcd5..209f74c 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipReason.java +++ b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipReason.java @@ -18,6 +18,8 @@ public enum OwnershipReason { GUILD_ACCESS_TOGGLE, ALLY_ACCESS_TOGGLE, VASSAL_ACCESS_TOGGLE, + /** Staff converted the vehicle to server ownership. */ + SERVER, MIGRATE, RESTAMP, RELEASE, diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwners.java b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwners.java index a3f93df..2190d49 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwners.java +++ b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwners.java @@ -13,6 +13,7 @@ * * * + * * * @@ -23,6 +24,7 @@ * is the one captured when the entry was added. */ public final class VehicleOwners { + public static final String SERVER = "server"; public static final String NONE = "none"; public static final String NO_CHARACTER = "char_none"; public static final String CHARACTER_PREFIX = "char_"; @@ -52,6 +54,11 @@ public static boolean isUnowned(String owner) { return owner == null || owner.isBlank() || owner.equalsIgnoreCase(NONE); } + /** Whether the vehicle belongs to the server. */ + public static boolean isServer(String owner) { + return SERVER.equalsIgnoreCase(owner); + } + /** Whether the value names a character. */ public static boolean isCharacterOwner(String owner) { return characterId(owner).isPresent(); @@ -71,13 +78,16 @@ public static boolean isPersonalOwner(String owner) { } /** - * The canonical form of a value that characters mode may write ({@code none}, {@code char_none} - * or {@code char_}), or empty for anything else. + * The canonical form of a value that characters mode may write ({@code none}, {@code char_none}, + * {@code server} or {@code char_}), or empty for anything else. */ public static Optional canonical(String owner) { if (owner == null) { return Optional.empty(); } + if (isServer(owner)) { + return Optional.of(SERVER); + } if (owner.equalsIgnoreCase(NONE)) { return Optional.of(NONE); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/events/ServerVehicleUseEvent.java b/src/main/java/net/tfminecraft/vehicleframework/events/ServerVehicleUseEvent.java new file mode 100644 index 0000000..739a0ea --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/events/ServerVehicleUseEvent.java @@ -0,0 +1,40 @@ +package net.tfminecraft.vehicleframework.events; + +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; + +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +/** Synchronous request to admit a player without server-use permission. Denied by default. */ +public final class ServerVehicleUseEvent extends PlayerEvent { + private static final HandlerList HANDLERS = new HandlerList(); + private final ActiveVehicle vehicle; + private boolean allowed; + + public ServerVehicleUseEvent(Player player, ActiveVehicle vehicle) { + super(player); + this.vehicle = vehicle; + } + + public ActiveVehicle getVehicle() { + return vehicle; + } + + public boolean isAllowed() { + return allowed; + } + + public void setAllowed(boolean allowed) { + this.allowed = allowed; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java index 96ca606..46324c9 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java @@ -138,6 +138,22 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String sender.sendMessage("§cYou do not have access to this command!"); return true; } + if(args[0].equalsIgnoreCase("server")) { + if (args.length == 2 && args[1].equalsIgnoreCase("list")) { + sender.sendMessage("§bServer vehicles:"); + for (OwnedVehicleSummary vehicle : VehicleFramework.getVehicleManager().listOwnedVehicles( + net.tfminecraft.vehicleframework.api.VehicleOwners.SERVER)) { + sender.sendMessage("§e" + vehicle.getName() + " §7- " + formatVehicleLocation(vehicle.getLocation())); + } + } else if (args.length != 1) { + sender.sendMessage("§cUsage: /vf server [list]"); + } else if (sender instanceof Player p) { + VehicleFramework.getVehicleManager().getOwnershipManager().startServer(p); + } else { + sender.sendMessage("§cOnly players can use this command."); + } + return true; + } if(args[0].equalsIgnoreCase("takeover")) { if(!(sender instanceof Player)) { sender.sendMessage("§cOnly players can use this command."); diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java index 4766191..3c5d9c8 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java @@ -22,6 +22,7 @@ import net.tfminecraft.vehicleframework.ownership.AccessKey; import net.tfminecraft.vehicleframework.ownership.GroupAccess; import net.tfminecraft.vehicleframework.ownership.OwnershipAccess; +import net.tfminecraft.vehicleframework.ownership.ServerVehicles; import net.tfminecraft.vehicleframework.ownership.OwnershipActions; import net.tfminecraft.vehicleframework.VehicleFramework; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; @@ -49,11 +50,13 @@ public class InventoryManager { * The vehicle's outside hold, if it has one and the player may open it: the owner, a group * the vehicle always admits (such as its faction), and while the owner has the whitelist * switched on, those on it and its groups. A ticket is not enough. Staff get in as for - * seats: by takeover. + * seats: by takeover. Server vehicles instead use their permission/event gate. */ public static Container outsideHold(Player p, ActiveVehicle v) { Container hold = v.hasContainers() ? v.getContainerHandler().outside() : null; if(hold == null) return null; + if (!ServerVehicles.mayAccess(p, v)) return null; + if (ServerVehicles.isServer(v)) return hold; AccessKey key = OwnershipAccess.key(p); OwnerData data = v.getOwnerData(); boolean owner = key != null && data != null && key.owns(data.getOwner()); diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java index f377fa8..2af2c3e 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java @@ -242,6 +242,53 @@ public boolean takeover(Player p, ActiveVehicle v) { return true; } + /** Arms the staff conversion under the current character session. */ + public void startServer(Player p) { + Outcome actor = OwnershipActions.actor(p); + if (actor.refusal() != null) { + p.sendMessage(actor.refusal()); + return; + } + pending.start(p, Kind.SERVER, null, actor.value().session(), null); + p.sendMessage("§aRight-click a vehicle within 60 seconds to make it a server vehicle."); + } + + /** Applies a pending server conversion through the ownership event and persistence pipeline. */ + public boolean makeServer(Player p, ActiveVehicle v) { + Optional taken = pending.take(p, Kind.SERVER); + if (taken.isEmpty()) return false; + if (!Permissions.isAdmin(p)) { + p.sendMessage(OwnershipActions.NOT_ALLOWED); + return true; + } + if (!PendingActions.stillCurrent(taken.get())) { + p.sendMessage(SESSION_CHANGED); + return true; + } + Outcome actor = OwnershipActions.actor(p); + if (actor.refusal() != null) { + p.sendMessage(actor.refusal()); + return true; + } + String previousOwner = v.getOwnerData().getOwner(); + if (VehicleOwners.isServer(previousOwner)) { + p.sendMessage("§cThat is already a server vehicle."); + return true; + } + if (write(actor.value(), v, OwnershipReason.SERVER, null, null, data -> { + data.setOwner(VehicleOwners.SERVER); + data.setWhiteList(new ArrayList<>()); + data.setWhiteListed(false); + data.setAccessGroups(null); + data.setTicketsEnabled(false); + }) == Result.OK) { + p.sendMessage("§e" + v.getName() + "§a is now a server vehicle."); + net.tfminecraft.vehicleframework.VFLogger.log("server-owned " + v.getUUID() + " (" + v.getId() + + ") by " + p.getName() + ": " + previousOwner + " -> server"); + } + return true; + } + /** Starts renaming with a name tag. */ @SuppressWarnings("deprecation") public void startRename(Player p, ActiveVehicle v) { @@ -270,6 +317,10 @@ public void ownershipClick(InventoryClickEvent e) { ActiveVehicle v = menuVehicle(e, VFGUI.OWNERSHIP); if (v == null) return; Player p = (Player) e.getWhoClicked(); + if (VehicleOwners.isServer(v.getOwnerData().getOwner()) && e.getSlot() != 6) { + p.sendMessage(OwnershipActions.SERVER_ACCESS); + return; + } switch (e.getSlot()) { case 0 -> { if (change(p, v, OwnershipReason.WHITELIST_TOGGLE, null, null, diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java index 11fb432..8f286af 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java @@ -78,6 +78,7 @@ import net.tfminecraft.vehicleframework.managers.spawner.VehicleSpawner; import net.tfminecraft.vehicleframework.ownership.OwnerNames; import net.tfminecraft.vehicleframework.ownership.OwnershipAccess; +import net.tfminecraft.vehicleframework.ownership.ServerVehicles; import net.tfminecraft.vehicleframework.protocol.PacketConverter; import net.tfminecraft.vehicleframework.tracks.RecorderLog; import net.tfminecraft.vehicleframework.tracks.TrainGhosts; @@ -481,10 +482,11 @@ public void seatInteract(Player p, ActiveVehicle v) { if(v.isPassenger(p, true)) { return; } + if (!ServerVehicles.check(p, v)) return; ActiveVehicle ticketSource = v.ticketSource(); boolean hasTicket = VehicleTicketItems.inventoryHas( p, ticketSource.getOwnerData().getTicketId()); - boolean admitted = OwnershipAccess.admits(v, OwnershipAccess.key(p)); + boolean admitted = ServerVehicles.isServer(v) || OwnershipAccess.admits(v, OwnershipAccess.key(p)); if (!VehicleTicketRules.mayOpenSeatMenu(v.getOwnerData(), ticketSource.getOwnerData(), admitted, hasTicket)) { p.sendMessage(ticketSource.getOwnerData().isTicketsEnabled() ? "§cYou need a ticket to board this vehicle." @@ -518,6 +520,7 @@ private void handlePendingEntityMount(Player p, Entity entity) { p.sendMessage("§cThe vehicle has been destroyed"); return; } + if (!ServerVehicles.check(p, v)) return; if(!isEntityAllowed(entity, v.getEntitySeatWhitelist())) { p.sendMessage("§cThat creature cannot ride in this seat"); return; @@ -635,7 +638,7 @@ public void vehicleInteract(PlayerInteractEntityEvent e){ if(!vehicles.containsKey(entity)) return; ActiveVehicle v = vehicles.get(entity); // Admin takeover goes through the ownership events like any other change - if(ownership.takeover(p, v)) { + if(ownership.takeover(p, v) || ownership.makeServer(p, v)) { e.setCancelled(true); return; } @@ -645,6 +648,10 @@ public void vehicleInteract(PlayerInteractEntityEvent e){ e.setCancelled(true); return; } + if (!ServerVehicles.check(p, v)) { + e.setCancelled(true); + return; + } if(cooldown.containsKey(p)) { if(cooldown.get(p) > System.currentTimeMillis()) { return; @@ -969,9 +976,10 @@ private boolean allowTicketSeat(Player p, ActiveVehicle v, Seat seat) { if (p == null || v == null || seat == null) { return true; } + if (!ServerVehicles.check(p, v)) return false; ActiveVehicle source = v.ticketSource(); boolean has = VehicleTicketItems.inventoryHas(p, source.getOwnerData().getTicketId()); - boolean admitted = OwnershipAccess.admits(v, OwnershipAccess.key(p)); + boolean admitted = ServerVehicles.isServer(v) || OwnershipAccess.admits(v, OwnershipAccess.key(p)); if (VehicleTicketRules.mayEnter(v.getOwnerData(), source.getOwnerData(), admitted, seat.getType(), has)) { return true; } @@ -1158,6 +1166,7 @@ public void seatSelect(InventoryClickEvent e) { if(id == null) return; Seat seat = v.getSeat(id); + if (!ServerVehicles.check(p, v)) return; // Occupied entity seat: click to dismount the entity and teleport it to the player if(i.getType().equals(Material.GRAY_CONCRETE) && seat != null && seat.getType().equals(SeatType.ENTITY)) { if(seat.isOccupied()) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/GroupAccess.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/GroupAccess.java index 36e2b45..1e99afe 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/ownership/GroupAccess.java +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/GroupAccess.java @@ -116,7 +116,7 @@ private static Optional owned(ActiveVehicle vehicle) { return Optional.empty(); } String owner = vehicle.getOwnerData().getOwner(); - if (VehicleOwners.isUnowned(owner)) { + if (VehicleOwners.isUnowned(owner) || VehicleOwners.isServer(owner)) { return Optional.empty(); } return OwnershipStore.parseUuid(vehicle.getUUID()).map(id -> new Owned(id, owner)); diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnerNames.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnerNames.java index f13f573..15e5638 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnerNames.java +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnerNames.java @@ -22,6 +22,9 @@ public static String player(Player player) { /** An owner value as text, such as "Kira" or, for staff, "Kira (Steve)". */ public static String owner(String owner, boolean staff) { + if (VehicleOwners.isServer(owner)) { + return "Server"; + } if (VehicleOwners.isUnowned(owner)) { return "nobody"; } diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccess.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccess.java index 6f64b4b..278bae4 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccess.java +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccess.java @@ -51,6 +51,7 @@ public static boolean isOwner(Player player, OwnerData data) { /** Whether the key owns the vehicle, is on its whitelist, or is in a group it admits. */ public static boolean admits(ActiveVehicle vehicle, AccessKey key) { - return VehicleTicketRules.ownerOrWhitelisted(vehicle.getOwnerData(), key) || GroupAccess.admits(vehicle, key); + return !ServerVehicles.isServer(vehicle) + && (VehicleTicketRules.ownerOrWhitelisted(vehicle.getOwnerData(), key) || GroupAccess.admits(vehicle, key)); } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipActions.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipActions.java index a6dc8d2..d6688f2 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipActions.java +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipActions.java @@ -31,6 +31,7 @@ public final class OwnershipActions { public static final String UNAVAILABLE = "§cVehicle ownership changes are unavailable right now."; public static final String NOT_OWNER = "§cYou do not own this vehicle."; public static final String NOT_ALLOWED = "§cYou cannot change that on this vehicle."; + public static final String SERVER_ACCESS = "§cServer vehicles have no whitelist, tickets or group access."; public static final String SAVE_FAILED = "§cCould not save the change. Nothing was changed."; private OwnershipActions() { @@ -91,6 +92,9 @@ public static boolean controls(Actor actor, ActiveVehicle vehicle) { * source), or empty if they may. Fires {@link VehicleOwnershipEditCheckEvent}. */ public static Optional refusal(Player player, ActiveVehicle vehicle, OwnershipReason reason) { + if (ServerVehicles.isServer(vehicle) && reason != OwnershipReason.CLEAR) { + return Optional.of(SERVER_ACCESS); + } Outcome actor = actor(player); if (actor.refusal() != null) { return Optional.of(actor.refusal()); diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/PendingActions.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/PendingActions.java index f86483f..7b9932d 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/ownership/PendingActions.java +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/PendingActions.java @@ -23,7 +23,8 @@ public final class PendingActions { public enum Kind { WHITELIST_ADD("§cWhitelist entry timed out."), RENAME("§cNaming timed out."), - TAKEOVER("§cTakeover expired."); + TAKEOVER("§cTakeover expired."), + SERVER("§cServer vehicle action expired."); private final String timeoutMessage; diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/ServerVehicles.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/ServerVehicles.java new file mode 100644 index 0000000..50beed4 --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/ServerVehicles.java @@ -0,0 +1,41 @@ +package net.tfminecraft.vehicleframework.ownership; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.vehicleframework.api.VehicleOwners; +import net.tfminecraft.vehicleframework.events.ServerVehicleUseEvent; +import net.tfminecraft.vehicleframework.permissions.Permissions; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +/** Server ownership is independent of player identities, whitelists, groups and tickets. */ +public final class ServerVehicles { + private ServerVehicles() { + } + + public static boolean isServer(ActiveVehicle vehicle) { + return vehicle != null && vehicle.getOwnerData() != null + && VehicleOwners.isServer(vehicle.getOwnerData().getOwner()); + } + + public static boolean mayUse(Player player, ActiveVehicle vehicle) { + if (player.hasPermission("vf.server.use") || Permissions.isAdmin(player)) return true; + ServerVehicleUseEvent event = new ServerVehicleUseEvent(player, vehicle); + Bukkit.getPluginManager().callEvent(event); + return event.isAllowed(); + } + + /** Check the car and locomotive separately; deck clicks already resolve to their car. */ + public static boolean mayAccess(Player player, ActiveVehicle vehicle) { + if (isServer(vehicle) && !mayUse(player, vehicle)) return false; + ActiveVehicle source = vehicle.ticketSource(); + return source == vehicle || !isServer(source) || mayUse(player, source); + } + + /** Shared interaction refusal, also used for stale menus and pending entity mounts. */ + public static boolean check(Player player, ActiveVehicle vehicle) { + if (mayAccess(player, vehicle)) return true; + player.sendMessage("§cThis is a server vehicle."); + return false; + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java b/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java index ec64605..8b1e045 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java +++ b/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java @@ -25,7 +25,8 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe private List candidates(CommandSender sender, Command cmd, String[] args) { if (!(sender instanceof Player)) { if (!cmd.getName().equalsIgnoreCase("vf") || !Permissions.isAdmin(sender)) return List.of(); - if (args.length == 1) return List.of("reload", "admin", "track"); + if (args.length == 1) return List.of("reload", "admin", "track", "server"); + if (args.length == 2 && args[0].equalsIgnoreCase("server")) return List.of("list"); if (args.length == 2 && args[0].equalsIgnoreCase("admin")) return List.of("quarantine"); if (args.length == 2 && args[0].equalsIgnoreCase("track")) return List.of("resync"); return List.of(); @@ -43,6 +44,7 @@ private List candidates(CommandSender sender, Command cmd, String[] args } if (Permissions.isAdmin(sender)) { completions.add("takeover"); + completions.add("server"); completions.add("reload"); completions.add("track"); completions.add("admin"); @@ -50,6 +52,8 @@ private List candidates(CommandSender sender, Command cmd, String[] args return completions; } + if (args[0].equalsIgnoreCase("server") && args.length == 2 && Permissions.isAdmin(sender)) return List.of("list"); + if (args[0].equalsIgnoreCase("admin") && args.length == 2 && Permissions.isAdmin(sender)) { completions.add("quarantine"); return completions; diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRules.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRules.java index 6a382a0..e2f145f 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRules.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRules.java @@ -1,6 +1,7 @@ package net.tfminecraft.vehicleframework.vehicles.handlers; import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.api.VehicleOwners; import net.tfminecraft.vehicleframework.enums.SeatType; import net.tfminecraft.vehicleframework.ownership.AccessKey; @@ -15,7 +16,7 @@ public static boolean ownerOrWhitelisted(OwnerData data, String playerName) { /** A {@code null} key (no identity) matches nobody. */ public static boolean ownerOrWhitelisted(OwnerData data, AccessKey key) { - if (data == null || key == null) { + if (data == null || key == null || VehicleOwners.isServer(data.getOwner())) { return false; } if (key.owns(data.getOwner())) { @@ -45,7 +46,8 @@ public static boolean mayEnter(OwnerData access, OwnerData tickets, AccessKey ke * that would refuse them, a ticket only covers passenger seats. */ public static boolean mayEnter(OwnerData access, OwnerData tickets, boolean admitted, SeatType seat, boolean hasMatchingTicket) { - boolean ticketsEnabled = tickets != null && tickets.isTicketsEnabled(); + if (access != null && VehicleOwners.isServer(access.getOwner())) return admitted; + boolean ticketsEnabled = tickets != null && !VehicleOwners.isServer(tickets.getOwner()) && tickets.isTicketsEnabled(); if (!admitted && gated(access)) { return ticketsEnabled && seat == SeatType.PASSENGER && hasMatchingTicket; } @@ -66,15 +68,17 @@ public static boolean mayOpenSeatMenu(OwnerData access, OwnerData tickets, Acces } public static boolean mayOpenSeatMenu(OwnerData access, OwnerData tickets, boolean admitted, boolean hasMatchingTicket) { + if (access != null && VehicleOwners.isServer(access.getOwner())) return admitted; if (!gated(access) || admitted) { return true; } - return tickets != null && tickets.isTicketsEnabled() && hasMatchingTicket; + return tickets != null && !VehicleOwners.isServer(tickets.getOwner()) && tickets.isTicketsEnabled() && hasMatchingTicket; } /** Whether the vehicle's whitelist is enforced: switched on, on an owned vehicle. */ public static boolean gated(OwnerData access) { return access != null + && !VehicleOwners.isServer(access.getOwner()) && access.isWhiteListed() && access.getOwner() != null && !access.getOwner().equalsIgnoreCase("none"); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 1eb67d2..cf763e4 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -10,4 +10,12 @@ softdepend: [MythicMobs, CoreProtect] commands: vf: useage: / - description: Vehicle Framework Command \ No newline at end of file + description: Vehicle Framework Command + +permissions: + vf.server.use: + description: Use server-owned vehicles. + default: op + vf.admin: + description: Administer vehicles and ownership. + default: op diff --git a/src/test/java/net/tfminecraft/vehicleframework/api/VehicleOwnersTest.java b/src/test/java/net/tfminecraft/vehicleframework/api/VehicleOwnersTest.java index de1b26e..c0024fe 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/api/VehicleOwnersTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/api/VehicleOwnersTest.java @@ -90,4 +90,15 @@ void reasonsKnowWhichAreSystemWrites() { assertTrue(reason.reason().isSystem()); } } + + @Test void serverOwnerIsCanonicalAndNeverPersonalOrClaimable() { + assertEquals("server", VehicleOwners.SERVER); + assertTrue(VehicleOwners.isServer("SeRvEr")); + assertFalse(VehicleOwners.isServer(null)); + assertFalse(VehicleOwners.isUnowned("server")); + assertFalse(VehicleOwners.isCharacterOwner("server")); + assertFalse(VehicleOwners.isPersonalOwner("server")); + assertEquals(Optional.of("server"), VehicleOwners.canonical("SERVER")); + } + } diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java index 79aabe2..26ec084 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java @@ -230,7 +230,7 @@ void ownershipMenuReflectsWhitelistTicketsAndLinkedTrainOwnership(boolean enable } var console = mock(CommandSender.class); when(console.hasPermission("vf.admin")).thenReturn(true); - assertEquals(List.of("reload", "admin", "track"), tabs.onTabComplete(console, command, "vf", new String[]{""})); + assertEquals(List.of("reload", "admin", "track", "server"), tabs.onTabComplete(console, command, "vf", new String[]{""})); assertEquals(List.of("reload"), tabs.onTabComplete(console, command, "vf", new String[]{"RE"})); assertEquals(List.of("quarantine"), tabs.onTabComplete(console, command, "vf", new String[]{"admin", "Q"})); assertEquals(List.of("resync"), tabs.onTabComplete(console, command, "vf", new String[]{"track", "R"})); @@ -445,4 +445,28 @@ private void configure(ItemStack item, Material type) { doAnswer(call -> { lore.set(0, List.copyOf(call.getArgument(0))); return null; }).when(meta).setLore(anyList()); when(meta.getLore()).thenAnswer(call -> lore.get(0)); } private T keep(T scope) { scopes.add(scope); return scope; } + + @Test void serverCommandsAndCompletionAreStaffOnlyAndListStoredAndLiveVehicles() { + assertTrue(run(player, "server")); + verify(vehicles, never()).getOwnershipManager(); + when(player.hasPermission("vf.admin")).thenReturn(true); + OwnershipManager manager = mock(OwnershipManager.class); when(vehicles.getOwnershipManager()).thenReturn(manager); + assertTrue(run(player, "server")); verify(manager).startServer(player); + assertTrue(run(player, "server", "bad")); + verify(player).sendMessage("§cUsage: /vf server [list]"); + CommandSender console = mock(CommandSender.class); when(console.hasPermission("vf.admin")).thenReturn(true); + assertTrue(run(console, "server")); verify(console).sendMessage("§cOnly players can use this command."); + OwnedVehicleSummary stored = mock(OwnedVehicleSummary.class), live = mock(OwnedVehicleSummary.class); + when(stored.getName()).thenReturn("Stored"); when(stored.getLocation()).thenReturn(Optional.empty()); + when(live.getName()).thenReturn("Live"); when(live.getLocation()).thenReturn(Optional.of(new Location(world, 1, 64, 2))); + when(vehicles.listOwnedVehicles("server")).thenReturn(List.of(stored, live)); + assertTrue(run(console, "server", "list")); + verify(console).sendMessage("§eStored §7- §7location unknown (stored)"); + verify(console).sendMessage("§eLive §7- §fworld 1, 64, 2"); + TabCompletion tabs = new TabCompletion(); + assertEquals(List.of("server"), tabs.onTabComplete(player, command, "vf", new String[]{"ser"})); + assertEquals(List.of("list"), tabs.onTabComplete(player, command, "vf", new String[]{"server", "l"})); + assertEquals(List.of("list"), tabs.onTabComplete(console, command, "vf", new String[]{"server", ""})); + } + } diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java index bbcd686..8766993 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java @@ -342,4 +342,15 @@ private String tag(ItemStack item, String name) { } private static final class ItemState { Material type; String name; List lore = List.of(); } private T keep(T scope) { scopes.add(scope); return scope; } + + @Test void serverHoldRequiresPermissionEvenWithStaleWhitelist() { + when(vehicle.hasContainers()).thenReturn(true); + ContainerHandler containers = mock(ContainerHandler.class); when(vehicle.getContainerHandler()).thenReturn(containers); + Container hold = mock(Container.class); when(containers.outside()).thenReturn(hold); + when(owner.getOwner()).thenReturn("server"); + assertNull(InventoryManager.outsideHold(player, vehicle)); + when(player.hasPermission("vf.server.use")).thenReturn(true); + assertSame(hold, InventoryManager.outsideHold(player, vehicle)); + } + } diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java index 2dab694..7c0e294 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java @@ -792,4 +792,56 @@ void namesModeTakeoverUsesTheAccountNameAndRefusesTypedTargets() { tasks.getLast().run(); verify(staff).sendMessage("§cTakeover expired."); } + + @Test void conversionClearsAccessAndCanBeTakenOverOrReleased() { + ActiveVehicle v = vehicle(owner(kira)); + v.getOwnerData().setWhiteList(new ArrayList<>(List.of("crew"))); + v.getOwnerData().setWhiteListed(true); v.getOwnerData().setTicketsEnabled(true); + v.getOwnerData().setAccessGroups(Set.of(AccessGroup.ALLY)); + assertFalse(manager.makeServer(staff, v)); + manager.startServer(staff); + assertTrue(manager.makeServer(staff, v)); + assertEquals("server", v.getOwnerData().getOwner()); + assertTrue(v.getOwnerData().getWhiteList().isEmpty()); + assertTrue(v.getOwnerData().getAccessGroups().isEmpty()); + assertFalse(v.getOwnerData().isWhiteListed()); assertFalse(v.getOwnerData().isTicketsEnabled()); + VehicleOwnershipChangeEvent pre = fired(VehicleOwnershipChangeEvent.class).getFirst(); + assertEquals(OwnershipReason.SERVER, pre.getReason()); assertTrue(pre.isStaff()); + assertFalse(OwnershipReason.SERVER.isSystem()); assertTrue(pre.getTarget().isEmpty()); + assertTrue(fired(VehicleOwnershipChangedEvent.class).getFirst().isSuccess()); + assertFalse(manager.claim(alex, v)); + manager.startServer(staff); manager.makeServer(staff, v); + verify(staff).sendMessage("§cThat is already a server vehicle."); + manager.startTakeover(staff, null); manager.takeover(staff, v); + assertEquals(owner(warden), v.getOwnerData().getOwner()); + assertEquals(OwnershipReason.TAKEOVER, fired(VehicleOwnershipChangeEvent.class).getLast().getReason()); + v.getOwnerData().setOwner("server"); + for (int slot : new int[]{0, 2, 4, 8, OwnershipGUIManager.FIRST_GROUP_SLOT}) manager.ownershipClick(menu(staff, v, slot)); + verify(staff, times(5)).sendMessage(OwnershipActions.SERVER_ACCESS); + for (OwnershipReason reason : List.of(OwnershipReason.WHITELIST_ADD, OwnershipReason.WHITELIST_REMOVE, + OwnershipReason.WHITELIST_TOGGLE, OwnershipReason.TICKETS_TOGGLE, OwnershipReason.ALLY_ACCESS_TOGGLE)) { + assertEquals(Optional.of(OwnershipActions.SERVER_ACCESS), OwnershipActions.refusal(staff, v, reason)); + } + manager.ownershipClick(menu(staff, v, 6)); + assertEquals("none", v.getOwnerData().getOwner()); + } + + @Test void conversionRechecksActorSessionAndPermissionAndHonorsVeto() { + ActiveVehicle v = vehicle("none"); + playing(staff, null); manager.startServer(staff); + verify(staff).sendMessage(OwnershipActions.NEED_CHARACTER); + playing(staff, warden); manager.startServer(staff); + when(staff.hasPermission("vf.admin")).thenReturn(false); + manager.makeServer(staff, v); verify(staff).sendMessage(OwnershipActions.NOT_ALLOWED); + when(staff.hasPermission("vf.admin")).thenReturn(true); + manager.startServer(staff); characters.when(() -> Characters.isCurrent(warden)).thenReturn(false); + manager.makeServer(staff, v); verify(staff).sendMessage(OwnershipManager.SESSION_CHANGED); + characters.when(() -> Characters.isCurrent(warden)).thenReturn(true); + manager.startServer(staff); playing(staff, null); manager.makeServer(staff, v); + verify(staff, times(2)).sendMessage(OwnershipActions.NEED_CHARACTER); + playing(staff, warden); manager.startServer(staff); + doAnswer(call -> { if (call.getArgument(0) instanceof VehicleOwnershipChangeEvent pre) pre.setCancelled(true); return null; }).when(plugins).callEvent(any(Event.class)); + manager.makeServer(staff, v); assertEquals("none", v.getOwnerData().getOwner()); + } + } diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java index ff2b28a..5f43354 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java @@ -1080,4 +1080,42 @@ void ownershipAndInputDefensiveHelpersRejectMissingParticipants() throws Excepti cooldown.setAccessible(true); cooldown.invoke(manager, null, vehicle("v")); assertTrue(map("ejectCooldown").isEmpty()); } + + @Test void serverAccessGatesClickSeatsMountAndEntityMenus() throws Exception { + ActiveVehicle v = vehicle("server-v"); Player player = player("Ryan"); register(v); + v.getOwnerData().setOwner("server"); + PlayerInteractEntityEvent interact = interact(player, v.getEntity()); + manager.vehicleInteract(interact); assertTrue(interact.isCancelled()); + manager.seatInteract(player, v); + Seat seat = mock(Seat.class); when(seat.getType()).thenReturn(SeatType.PASSENGER); + when(v.getSeat("seat")).thenReturn(seat); when(v.getSeatHandler().getSeat("seat")).thenReturn(seat); + manager.mount(player, "seat", v); verify(v, never()).addPassenger(any(), any()); + map("tempVehicle").put(player, v); + manager.seatSelect(click(player, v, VFGUI.SEAT_SELECTION, 0, item(Material.CYAN_CONCRETE, "seat"))); + assertTrue(map("pendingEntityVehicle").isEmpty()); + Entity animal = mock(Entity.class); + map("pendingEntityVehicle").put(player, v); map("pendingEntitySeat").put(player, "seat"); + manager.vehicleInteract(interact(player, animal)); verify(v, never()).addPassenger(any(), any()); + when(player.hasPermission("vf.server.use")).thenReturn(true); + manager.seatInteract(player, v); + verify(inventories.constructed().getFirst()).seatSelection(null, player, v, true); + when(v.addPassenger(player, seat)).thenReturn(MountResult.MOUNTED); + manager.mount(player, "seat", v); verify(v).addPassenger(player, seat); + } + + @Test void serverConversionClickRunsBeforePreInteractAndDeckChecksLocomotive() throws Exception { + ActiveVehicle v = vehicle("server-v"); Player player = player("Ryan"); register(v); + when(player.hasPermission("vf.admin")).thenReturn(true); + when(persistence().saveLive(v)).thenReturn(true); + manager.getOwnershipManager().startServer(player); + PlayerInteractEntityEvent event = interact(player, v.getEntity()); manager.vehicleInteract(event); + assertTrue(event.isCancelled()); assertEquals("server", v.getOwnerData().getOwner()); + verify(plugins, never()).callEvent(isA(VehiclePreInteractEvent.class)); + when(player.hasPermission("vf.admin")).thenReturn(false); + ActiveVehicle car = vehicle("car"); register(car); when(car.ticketSource()).thenReturn(v); + Entity deck = mock(Entity.class); decks.when(() -> DeckBody.owner(deck)).thenReturn(car); + event = interact(player, deck); manager.vehicleInteract(event); assertTrue(event.isCancelled()); + verify(player).sendMessage("§cThis is a server vehicle."); + } + } diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnerNamesTest.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnerNamesTest.java index 39dd241..eb8308b 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnerNamesTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnerNamesTest.java @@ -65,4 +65,10 @@ void onlinePlayersShowTheirDisplayNameInCharactersMode() { OwnershipFixture.mode(OwnershipIdentity.CHARACTERS); assertEquals("Hooded figure", OwnerNames.player(player)); } + + @Test void serverDisplayIsTheSameForEveryone() { + assertEquals("Server", OwnerNames.owner("SERVER", true)); + assertEquals("Server", OwnerNames.owner("server", false)); + } + } diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/ServerVehiclesTest.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/ServerVehiclesTest.java new file mode 100644 index 0000000..a9c0b72 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/ServerVehiclesTest.java @@ -0,0 +1,55 @@ +package net.tfminecraft.vehicleframework.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.events.ServerVehicleUseEvent; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class ServerVehiclesTest { + @Test void permissionsEventsAndTicketSources() { + Player player = mock(Player.class); + ActiveVehicle car = mock(ActiveVehicle.class), loco = mock(ActiveVehicle.class); + OwnerData data = new OwnerData(); data.setOwner("server"); + when(car.getOwnerData()).thenReturn(data); when(car.ticketSource()).thenReturn(car); + assertFalse(ServerVehicles.isServer(null)); + assertFalse(ServerVehicles.isServer(loco)); + PluginManager plugins = mock(PluginManager.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + assertFalse(OwnershipAccess.admits(car, AccessKey.name("Alex"))); + assertFalse(ServerVehicles.check(player, car)); + verify(player).sendMessage("§cThis is a server vehicle."); + when(player.hasPermission("vf.server.use")).thenReturn(true); + assertTrue(ServerVehicles.check(player, car)); + when(player.hasPermission("vf.server.use")).thenReturn(false); + when(player.hasPermission("vf.admin")).thenReturn(true); + assertTrue(ServerVehicles.mayUse(player, car)); + when(player.hasPermission("vf.admin")).thenReturn(false); + doAnswer(call -> { + ServerVehicleUseEvent event = call.getArgument(0); + assertSame(player, event.getPlayer()); assertSame(car, event.getVehicle()); + assertFalse(event.isAllowed()); assertFalse(event.isAsynchronous()); + assertSame(ServerVehicleUseEvent.getHandlerList(), event.getHandlers()); + event.setAllowed(true); return null; + }).when(plugins).callEvent(any(ServerVehicleUseEvent.class)); + assertTrue(ServerVehicles.mayUse(player, car)); + reset(plugins); + when(car.ticketSource()).thenReturn(loco); + when(loco.getOwnerData()).thenReturn(data); + OwnerData personal = new OwnerData(); when(car.getOwnerData()).thenReturn(personal); + assertFalse(ServerVehicles.mayAccess(player, car)); + when(player.hasPermission("vf.server.use")).thenReturn(true); + assertTrue(ServerVehicles.mayAccess(player, car)); + when(loco.getOwnerData()).thenReturn(personal); + assertTrue(ServerVehicles.mayAccess(player, car)); + } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRulesTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRulesTest.java index 39fb204..131909a 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRulesTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRulesTest.java @@ -174,4 +174,19 @@ private static OwnerData gatedAccess() { access.setWhiteListed(true); return access; } + + @Test void serverVehiclesIgnoreStaleAccessAndTickets() { + OwnerData data = new OwnerData(); data.setOwner("server"); + data.setWhiteListed(true); data.setWhiteList(java.util.List.of("player_Alex")); data.setTicketsEnabled(true); + assertFalse(VehicleTicketRules.gated(data)); + assertFalse(VehicleTicketRules.ownerOrWhitelisted(data, "Alex")); + assertFalse(VehicleTicketRules.mayEnter(data, data, false, SeatType.PASSENGER, true)); + assertFalse(VehicleTicketRules.mayOpenSeatMenu(data, data, false, true)); + assertTrue(VehicleTicketRules.mayEnter(data, data, true, SeatType.CAPTAIN, false)); + assertTrue(VehicleTicketRules.mayOpenSeatMenu(data, data, true, false)); + OwnerData car = new OwnerData(); car.setOwner("player_Other"); car.setWhiteListed(true); + assertFalse(VehicleTicketRules.mayEnter(car, data, false, SeatType.PASSENGER, true)); + assertFalse(VehicleTicketRules.mayOpenSeatMenu(car, data, false, true)); + } + } From 3a999a06356c8cee61b6d91564e91917cf1b4140 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:15:12 +0000 Subject: [PATCH 2/3] Log server conversions at info level Co-Authored-By: Claude Opus 5.5 --- .../tfminecraft/vehicleframework/managers/OwnershipManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java index 2af2c3e..02dd236 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java @@ -283,7 +283,7 @@ public boolean makeServer(Player p, ActiveVehicle v) { data.setTicketsEnabled(false); }) == Result.OK) { p.sendMessage("§e" + v.getName() + "§a is now a server vehicle."); - net.tfminecraft.vehicleframework.VFLogger.log("server-owned " + v.getUUID() + " (" + v.getId() + net.tfminecraft.vehicleframework.VFLogger.info("server-owned " + v.getUUID() + " (" + v.getId() + ") by " + p.getName() + ": " + previousOwner + " -> server"); } return true; From 3dec5e141670b71fb9503638b5ccece072f103c2 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:30:40 +0000 Subject: [PATCH 3/3] Cancel the other pending staff action and recheck server access on mount retry Arming /vf server now cancels a pending takeover and the reverse, so the next click does what the staff member last asked for. A rejected-mount retry rechecks server-vehicle access before seating the player. Co-Authored-By: Claude Opus 5.5 --- .../vehicleframework/managers/OwnershipManager.java | 2 ++ .../vehicleframework/managers/VehicleManager.java | 1 + .../managers/OwnershipManagerTest.java | 10 ++++++++++ .../managers/VehicleManagerCoverageTest.java | 8 ++++++-- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java index 02dd236..395a680 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java @@ -124,6 +124,7 @@ public void startTakeover(Player p, String targetName) { } target = named.get(); } + pending.cancel(p, Kind.SERVER); pending.start(p, Kind.TAKEOVER, null, actor.value().session(), target); if (target.name() == null) { p.sendMessage("§aRight-click a vehicle within 60 seconds to claim ownership."); @@ -249,6 +250,7 @@ public void startServer(Player p) { p.sendMessage(actor.refusal()); return; } + pending.cancel(p, Kind.TAKEOVER); pending.start(p, Kind.SERVER, null, actor.value().session(), null); p.sendMessage("§aRight-click a vehicle within 60 seconds to make it a server vehicle."); } diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java index 8f286af..331d8a9 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java @@ -1104,6 +1104,7 @@ private void retryMount(ActiveVehicle fresh, UUID riderId, String seatBone, Play } Entity rider = riderId == null ? null : Bukkit.getEntity(riderId); if (rider instanceof Player player && player.isOnline()) { + if (!ServerVehicles.check(player, fresh)) return; takeSeat(player, fresh, seat); return; } diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java index 7c0e294..ff10b21 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java @@ -826,6 +826,16 @@ void namesModeTakeoverUsesTheAccountNameAndRefusesTypedTargets() { assertEquals("none", v.getOwnerData().getOwner()); } + @Test void armingServerOrTakeoverDisarmsTheOther() { + ActiveVehicle v = vehicle(owner(kira)); + manager.startTakeover(staff, null); manager.startServer(staff); + assertFalse(manager.takeover(staff, v)); + assertEquals(owner(kira), v.getOwnerData().getOwner()); + manager.startTakeover(staff, null); + assertFalse(manager.makeServer(staff, v)); + assertEquals(owner(kira), v.getOwnerData().getOwner()); + } + @Test void conversionRechecksActorSessionAndPermissionAndHonorsVeto() { ActiveVehicle v = vehicle("none"); playing(staff, null); manager.startServer(staff); diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java index 5f43354..8f83589 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java @@ -799,7 +799,7 @@ void rejectedMountRecoveryDeduplicatesAndReleasesItsReservationWhenVehicleDisapp } @ParameterizedTest - @ValueSource(strings = {"unload-failed", "storage-closed", "missing-save", "unknown-type", "spawn-failed", "missing-seat", "missing-rider", "player", "animal", "rejected-animal"}) + @ValueSource(strings = {"unload-failed", "storage-closed", "missing-save", "unknown-type", "spawn-failed", "missing-seat", "missing-rider", "player", "server-denied", "animal", "rejected-animal"}) void rejectedMountReloadEitherRetriesTheSavedSeatOrReportsWhyItCannot(String outcome) { Player player = player("Ryan"); ActiveVehicle old = vehicle("v"), fresh = vehicle("v"); register(old); VehiclePersistence persistence = persistence(); @@ -819,9 +819,13 @@ void rejectedMountReloadEitherRetriesTheSavedSeatOrReportsWhyItCannot(String out UUID notifyId = player.getUniqueId(); bukkit.when(() -> Bukkit.getPlayer(notifyId)).thenReturn(player); when(fresh.addPassenger(rider, seat)).thenReturn(outcome.equals("rejected-animal") ? MountResult.REJECTED : MountResult.MOUNTED); if (outcome.equals("storage-closed")) storage.when(VehiclePersistence::current).thenReturn(persistence, (VehiclePersistence) null); + if (outcome.equals("server-denied")) fresh.getOwnerData().setOwner("server"); manager.recoverRejectedMount(old, rider, "seat", player); assertEquals(1, scheduled.size()); scheduled.removeFirst().run(); - if (Set.of("player", "animal", "rejected-animal").contains(outcome)) { + if (outcome.equals("server-denied")) { + verify(fresh, never()).addPassenger(any(), any()); + verify(player).sendMessage("§cThis is a server vehicle."); + } else if (Set.of("player", "animal", "rejected-animal").contains(outcome)) { verify(fresh).addPassenger(rider, seat); assertSame(fresh, manager.get(fresh.getEntity())); } else if (outcome.equals("unload-failed")) {
Owner values
{@code char_}owned by that character
{@code server}server-owned; use requires permission or an event grant
{@code none}unowned, claimable
{@code char_none}faction vehicle without a leader character; matches nobody and * can never be claimed