Skip to content
Open
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* <table>
* <caption>Owner values</caption>
* <tr><td>{@code char_<characterId>}</td><td>owned by that character</td></tr>
* <tr><td>{@code server}</td><td>server-owned; use requires permission or an event grant</td></tr>
* <tr><td>{@code none}</td><td>unowned, claimable</td></tr>
* <tr><td>{@code char_none}</td><td>faction vehicle without a leader character; matches nobody and
* can never be claimed</td></tr>
Expand All @@ -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_";
Expand Down Expand Up @@ -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();
Expand All @@ -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_<uuid>}), 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_<uuid>}), or empty for anything else.
*/
public static Optional<String> canonical(String owner) {
if (owner == null) {
return Optional.empty();
}
if (isServer(owner)) {
return Optional.of(SERVER);
}
if (owner.equalsIgnoreCase(NONE)) {
return Optional.of(NONE);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -242,6 +243,54 @@ 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> actor = OwnershipActions.actor(p);
if (actor.refusal() != null) {
p.sendMessage(actor.refusal());
return;
}
pending.cancel(p, Kind.TAKEOVER);
pending.start(p, Kind.SERVER, null, actor.value().session(), null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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<Pending> 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> 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.info("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) {
Expand Down Expand Up @@ -270,6 +319,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1096,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;
}
Expand Down Expand Up @@ -1158,6 +1167,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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ private static Optional<Owned> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -91,6 +92,9 @@ public static boolean controls(Actor actor, ActiveVehicle vehicle) {
* source), or empty if they may. Fires {@link VehicleOwnershipEditCheckEvent}.
*/
public static Optional<String> refusal(Player player, ActiveVehicle vehicle, OwnershipReason reason) {
if (ServerVehicles.isServer(vehicle) && reason != OwnershipReason.CLEAR) {
return Optional.of(SERVER_ACCESS);
}
Outcome<Actor> actor = actor(player);
if (actor.refusal() != null) {
return Optional.of(actor.refusal());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading