diff --git a/pom.xml b/pom.xml
index 0225a1a..03d251e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,7 +13,7 @@
UTF-8full25.0.0
- 2.0.0
+ 2.4.1
diff --git a/src/main/java/net/tfminecraft/vehicleframework/VFLogger.java b/src/main/java/net/tfminecraft/vehicleframework/VFLogger.java
index 1e96056..4158d1f 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/VFLogger.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/VFLogger.java
@@ -17,6 +17,10 @@ public static void log(String error) {
Bukkit.getLogger().warning("[VehicleFramework] Error! " + error);
}
+ public static void severe(String error) {
+ Bukkit.getLogger().severe("[VehicleFramework] " + error);
+ }
+
public static void info(String info) {
Bukkit.getLogger().info("[VehicleFramework] " + info);
}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java
index 6b507cd..e027ce2 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/VehicleFramework.java
@@ -1,6 +1,7 @@
package net.tfminecraft.vehicleframework;
import java.io.File;
+import java.util.Locale;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
@@ -8,6 +9,7 @@
import net.tfminecraft.coreprotect.CoreProtect;
import net.tfminecraft.coreprotect.CoreProtectAPI;
+import net.tfminecraft.vehicleframework.api.OwnershipIdentity;
import net.tfminecraft.vehicleframework.cache.Cache;
import net.tfminecraft.vehicleframework.database.LogWriter;
import net.tfminecraft.vehicleframework.database.PersistenceLog;
@@ -24,6 +26,8 @@
import net.tfminecraft.vehicleframework.loaders.DeathTemplateLoader;
import net.tfminecraft.vehicleframework.managers.CommandManager;
import net.tfminecraft.vehicleframework.managers.VehicleManager;
+import net.tfminecraft.vehicleframework.ownership.CharacterLookup;
+import net.tfminecraft.vehicleframework.ownership.OwnershipMode;
import net.tfminecraft.vehicleframework.protocol.VehiclePacketListener;
import org.bstats.bukkit.Metrics;
import net.tfminecraft.vehicleframework.util.MythicMobsIntegration;
@@ -60,6 +64,7 @@ public class VehicleFramework extends JavaPlugin{
private static TrackRegistry trackRegistry;
private static TrackDisplayManager trackDisplayManager;
private static VehiclePacketListener packetListener;
+ private String ownershipValue = "";
@Override
public void onEnable() {
@@ -81,6 +86,7 @@ public void onEnable() {
}
createConfigs();
loadConfigs();
+ configureOwnership();
Cache.applyTrackDisplayStyle();
VFLogger.info("Starting systems...");
registerListeners();
@@ -117,6 +123,7 @@ public void onDisable() {
}
public void registerListeners() {
getServer().getPluginManager().registerEvents(vehicleManager, this);
+ getServer().getPluginManager().registerEvents(vehicleManager.getOwnershipManager(), this);
getServer().getPluginManager().registerEvents(vehicleManager.getSpawnManager(), this);
getServer().getPluginManager().registerEvents(vehicleManager.getRepairManager(), this);
getServer().getPluginManager().registerEvents(new TrackToolListener(), this);
@@ -131,6 +138,16 @@ public void registerListeners() {
packetListener = listener;
listener.register();
}
+ /** Reads the identity mode once; a reload does not change it. */
+ void configureOwnership() {
+ String value = configLoader.readOwnershipIdentity(new File(getDataFolder(), "config.yml"));
+ ownershipValue = normalised(value);
+ boolean characters = getServer().getPluginManager().getPlugin(OwnershipMode.CHARACTER_PLUGIN) != null;
+ if (OwnershipMode.configure(value, characters) == OwnershipIdentity.CHARACTERS) {
+ CharacterLookup.probe();
+ }
+ }
+
public void startManagers() {
vehicleManager.start();
}
@@ -294,6 +311,7 @@ public void reload() {
Cache.removeLights();
Cache.removeProjectiles();
loadConfigs();
+ warnIfOwnershipChanged();
vehicleManager.reload();
if (trackDisplayManager != null) {
trackDisplayManager.reloadSwitches();
@@ -301,6 +319,18 @@ public void reload() {
PersistenceLog.append("RELOAD_END pendingSpawns cycle=" + PersistenceLog.spawnCycle());
}
+ private void warnIfOwnershipChanged() {
+ String value = normalised(configLoader.readOwnershipIdentity(new File(getDataFolder(), "config.yml")));
+ if (!value.equals(ownershipValue)) {
+ VFLogger.log(OwnershipMode.CONFIG_KEY + " is read at startup only; ownership stays "
+ + OwnershipMode.identity().name().toLowerCase(Locale.ROOT) + " until a restart.");
+ }
+ }
+
+ private static String normalised(String value) {
+ return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
+ }
+
//Access we dont need static variables all over the place:
public static TrackRegistry getTrackRegistry() {
return trackRegistry;
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/OwnerChange.java b/src/main/java/net/tfminecraft/vehicleframework/api/OwnerChange.java
new file mode 100644
index 0000000..c590bb0
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/OwnerChange.java
@@ -0,0 +1,44 @@
+package net.tfminecraft.vehicleframework.api;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * The owner a system write sets, plus the seeded whitelist entries for {@link SystemReason#MIGRATE}.
+ *
+ * @param owner {@code none}, {@code char_none} or {@code char_}, stored in canonical
+ * form
+ * @param whitelist the entries to store; only accepted with {@code MIGRATE}, {@code null} keeps the
+ * current entries
+ */
+public record OwnerChange(String owner, List whitelist) {
+
+ public OwnerChange {
+ Objects.requireNonNull(owner, "owner");
+ String given = owner;
+ owner = VehicleOwners.canonical(given).orElseThrow(
+ () -> new IllegalArgumentException("Not a character-mode owner value: " + given));
+ whitelist = whitelist == null ? null : List.copyOf(whitelist);
+ }
+
+ /** Owned by the character. */
+ public static OwnerChange character(UUID characterId) {
+ return new OwnerChange(VehicleOwners.character(characterId), null);
+ }
+
+ /** A faction vehicle without a leader character; nobody matches it and nobody can claim it. */
+ public static OwnerChange noCharacter() {
+ return new OwnerChange(VehicleOwners.NO_CHARACTER, null);
+ }
+
+ /** Unowned and claimable. */
+ public static OwnerChange unowned() {
+ return new OwnerChange(VehicleOwners.NONE, null);
+ }
+
+ /** The migration seed: owner plus the decided whitelist entries. */
+ public static OwnerChange seeded(String owner, List whitelist) {
+ return new OwnerChange(owner, Objects.requireNonNull(whitelist, "whitelist"));
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipIdentity.java b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipIdentity.java
new file mode 100644
index 0000000..43d3bc1
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipIdentity.java
@@ -0,0 +1,17 @@
+package net.tfminecraft.vehicleframework.api;
+
+/**
+ * How vehicle owners are identified, read once at enable from the {@code ownership-identity}
+ * config key.
+ */
+public enum OwnershipIdentity {
+ /** Owners and whitelist entries are characters ({@code char_}). */
+ CHARACTERS,
+ /** Owners and whitelist entries are account names ({@code player_}); no RPCharacters. */
+ NAMES,
+ /**
+ * The key is missing while RPCharacters is installed, or holds an unknown value. Every
+ * ownership feature is refused until the key is set and the server restarts.
+ */
+ DISABLED
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipMeta.java b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipMeta.java
new file mode 100644
index 0000000..83206a9
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipMeta.java
@@ -0,0 +1,42 @@
+package net.tfminecraft.vehicleframework.api;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * One vehicle's ownership as stored in SQLite and as held by its live object, kept apart so a
+ * verifier can compare them.
+ *
+ * @param vehicleId the vehicle
+ * @param typeId the vehicle type, from the live object when loaded
+ * @param name the vehicle name, from the live object when loaded
+ * @param storedOwnerColumn the SQLite {@code owner} column, or {@code null} without a stored row
+ * @param stored the stored payload's fields; empty without a row or when the payload
+ * cannot be read
+ * @param live the live object's fields; empty when the vehicle is not loaded
+ */
+public record OwnershipMeta(UUID vehicleId, String typeId, String name, String storedOwnerColumn,
+ Optional stored, Optional live) {
+
+ public OwnershipMeta {
+ Objects.requireNonNull(vehicleId, "vehicleId");
+ stored = stored == null ? Optional.empty() : stored;
+ live = live == null ? Optional.empty() : live;
+ }
+
+ /** Whether a non-tombstoned SQLite row exists. */
+ public boolean hasStoredRow() {
+ return storedOwnerColumn != null;
+ }
+
+ /** Whether the vehicle is loaded. */
+ public boolean isLive() {
+ return live.isPresent();
+ }
+
+ /** The live fields when loaded, otherwise the stored ones. */
+ public Optional current() {
+ return live.isPresent() ? live : stored;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipReason.java b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipReason.java
new file mode 100644
index 0000000..129973d
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipReason.java
@@ -0,0 +1,27 @@
+package net.tfminecraft.vehicleframework.api;
+
+/** Why a vehicle's owner, whitelist or tickets changed. */
+public enum OwnershipReason {
+ /** A player claimed an unowned vehicle, including a staff takeover of an unowned one. */
+ CLAIM,
+ /** The owner removed their ownership. */
+ CLEAR,
+ /** Staff took over a vehicle that already had an owner. */
+ TAKEOVER,
+ WHITELIST_ADD,
+ WHITELIST_REMOVE,
+ WHITELIST_TOGGLE,
+ /** Tickets toggled on the vehicle's ticket source (the locomotive of a coupled car). */
+ TICKETS_TOGGLE,
+ MIGRATE,
+ RESTAMP,
+ RELEASE,
+ HANDOVER,
+ CONSTRUCT,
+ TRANSFER;
+
+ /** Whether this reason comes from {@link VehicleFrameworkApi#setOwnership}. */
+ public boolean isSystem() {
+ return ordinal() >= MIGRATE.ordinal();
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipValues.java b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipValues.java
new file mode 100644
index 0000000..f07e37b
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/OwnershipValues.java
@@ -0,0 +1,27 @@
+package net.tfminecraft.vehicleframework.api;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Immutable copy of a vehicle's ownership fields.
+ *
+ * @param owner the owner value, {@code none} when blank
+ * @param whitelist raw whitelist entries in stored order
+ * @param whitelisted whether the whitelist is enforced
+ * @param ticketsEnabled whether passenger seats need a ticket
+ * @param ticketId the current ticket ID, or {@code null}
+ */
+public record OwnershipValues(String owner, List whitelist, boolean whitelisted, boolean ticketsEnabled,
+ String ticketId) {
+
+ public OwnershipValues {
+ owner = owner == null || owner.isBlank() ? VehicleOwners.NONE : owner;
+ List entries = new ArrayList<>();
+ if (whitelist != null) {
+ whitelist.stream().filter(Objects::nonNull).forEach(entries::add);
+ }
+ whitelist = List.copyOf(entries);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/QuarantinedVehicle.java b/src/main/java/net/tfminecraft/vehicleframework/api/QuarantinedVehicle.java
new file mode 100644
index 0000000..dd9bcd2
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/QuarantinedVehicle.java
@@ -0,0 +1,20 @@
+package net.tfminecraft.vehicleframework.api;
+
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * A vehicle whose owner could not be migrated and keeps its legacy value.
+ *
+ * @param vehicleId the vehicle
+ * @param owner its current owner value
+ * @param reason a short staff-facing reason
+ */
+public record QuarantinedVehicle(UUID vehicleId, String owner, String reason) {
+
+ public QuarantinedVehicle {
+ Objects.requireNonNull(vehicleId, "vehicleId");
+ owner = owner == null ? "" : owner;
+ reason = reason == null ? "" : reason;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/SystemReason.java b/src/main/java/net/tfminecraft/vehicleframework/api/SystemReason.java
new file mode 100644
index 0000000..b42326e
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/SystemReason.java
@@ -0,0 +1,35 @@
+package net.tfminecraft.vehicleframework.api;
+
+/**
+ * Reasons accepted by {@link VehicleFrameworkApi#setOwnership}. Each decides what happens to the
+ * whitelist entries and the whitelist flag; tickets are never changed.
+ *
+ *
+ *
Whitelist disposition
+ *
Reason
Entries
Flag
+ *
RESTAMP
kept
kept
+ *
MIGRATE
as seeded ({@link OwnerChange#whitelist()}, kept when null)
kept
+ *
CONSTRUCT
emptied
on if {@code allow-whitelist} and {@code whitelisted-by-default}
+ *
RELEASE, HANDOVER
emptied
off
+ *
TRANSFER
emptied
kept
+ *
+ */
+public enum SystemReason {
+ MIGRATE(OwnershipReason.MIGRATE),
+ RESTAMP(OwnershipReason.RESTAMP),
+ RELEASE(OwnershipReason.RELEASE),
+ HANDOVER(OwnershipReason.HANDOVER),
+ CONSTRUCT(OwnershipReason.CONSTRUCT),
+ TRANSFER(OwnershipReason.TRANSFER);
+
+ private final OwnershipReason reason;
+
+ SystemReason(OwnershipReason reason) {
+ this.reason = reason;
+ }
+
+ /** The reason carried by {@link net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent}. */
+ public OwnershipReason reason() {
+ return reason;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/VehicleFrameworkApi.java b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleFrameworkApi.java
new file mode 100644
index 0000000..09429d4
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleFrameworkApi.java
@@ -0,0 +1,68 @@
+package net.tfminecraft.vehicleframework.api;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import net.tfminecraft.vehicleframework.ownership.OwnershipMode;
+import net.tfminecraft.vehicleframework.ownership.OwnershipStore;
+
+/**
+ * Public ownership API for other plugins. Every method must be called on the main thread.
+ */
+public final class VehicleFrameworkApi {
+
+ /** Outcome of {@link #setOwnership}. */
+ public enum Result {
+ OK,
+ /** No loaded vehicle and no stored, non-tombstoned row has this ID. */
+ NOT_FOUND,
+ /** The write failed; nothing changed. */
+ SAVE_FAILED,
+ /** The identity mode is not {@link OwnershipIdentity#CHARACTERS}. */
+ REFUSED_MODE
+ }
+
+ private VehicleFrameworkApi() {
+ }
+
+ /** The identity mode read at enable. */
+ public static OwnershipIdentity ownershipIdentity() {
+ return OwnershipMode.identity();
+ }
+
+ /**
+ * Every stored row except tombstones, plus loaded vehicles without a row, with stored and live
+ * fields kept apart.
+ *
+ * @throws IllegalStateException if SQLite is not open
+ * @throws RuntimeException if the query fails
+ */
+ public static List listOwnershipMeta() {
+ return OwnershipStore.listOwnershipMeta();
+ }
+
+ /**
+ * One vehicle's stored and live fields, or empty if it is neither loaded nor stored.
+ *
+ * @throws IllegalStateException if SQLite is not open
+ * @throws RuntimeException if the query fails
+ */
+ public static Optional readOwnershipMeta(UUID vehicleId) {
+ return OwnershipStore.readOwnershipMeta(vehicleId);
+ }
+
+ /**
+ * Sets the owner for a system reason. A loaded vehicle is authoritative: its fields change and
+ * it is saved at once, and a failed save puts the old fields back. An unloaded vehicle has its
+ * SQLite {@code owner} column and payload rewritten in one transaction. The whitelist follows
+ * the reason (see {@link SystemReason}); tickets never change.
+ * {@link net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent} fires only on
+ * {@link Result#OK}.
+ *
+ * @throws IllegalArgumentException if {@code change} has a whitelist and the reason is not MIGRATE
+ */
+ public static Result setOwnership(UUID vehicleId, OwnerChange change, SystemReason reason) {
+ return OwnershipStore.setOwnership(vehicleId, change, reason);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwners.java b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwners.java
new file mode 100644
index 0000000..a3f93df
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwners.java
@@ -0,0 +1,129 @@
+package net.tfminecraft.vehicleframework.api;
+
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+import net.tfminecraft.tlibs.character.CharacterRef;
+
+/**
+ * Owner values and whitelist entries.
+ *
+ *
+ *
Owner values
+ *
{@code char_}
owned by that character
+ *
{@code none}
unowned, claimable
+ *
{@code char_none}
faction vehicle without a leader character; matches nobody and
+ * can never be claimed
+ *
{@code player_}
legacy account name; inert in characters mode
+ *
+ *
+ *
Character whitelist entries are {@code char_;;}, where the name
+ * is the one captured when the entry was added.
+ */
+public final class VehicleOwners {
+ public static final String NONE = "none";
+ public static final String NO_CHARACTER = "char_none";
+ public static final String CHARACTER_PREFIX = "char_";
+ public static final String LEGACY_PREFIX = "player_";
+
+ private static final String SEPARATOR = ";";
+ private static final int UUID_LENGTH = 36;
+
+ private VehicleOwners() {
+ }
+
+ /** The owner value for a character. */
+ public static String character(UUID characterId) {
+ return CHARACTER_PREFIX + Objects.requireNonNull(characterId, "characterId");
+ }
+
+ /** The character of a {@code char_} owner value; empty for anything else. */
+ public static Optional characterId(String owner) {
+ if (owner == null || !hasPrefix(owner, CHARACTER_PREFIX)) {
+ return Optional.empty();
+ }
+ return uuid(owner.substring(CHARACTER_PREFIX.length()));
+ }
+
+ /** Whether the vehicle has no owner and can be claimed. */
+ public static boolean isUnowned(String owner) {
+ return owner == null || owner.isBlank() || owner.equalsIgnoreCase(NONE);
+ }
+
+ /** Whether the value names a character. */
+ public static boolean isCharacterOwner(String owner) {
+ return characterId(owner).isPresent();
+ }
+
+ /** Whether the value is a legacy {@code player_} owner other than {@code player_none}. */
+ public static boolean isLegacyOwner(String owner) {
+ return owner != null
+ && hasPrefix(owner, LEGACY_PREFIX)
+ && owner.length() > LEGACY_PREFIX.length()
+ && !owner.equalsIgnoreCase(LEGACY_PREFIX + NONE);
+ }
+
+ /** Whether a single person owns the vehicle: a character or a legacy account name. */
+ public static boolean isPersonalOwner(String owner) {
+ return isCharacterOwner(owner) || isLegacyOwner(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.
+ */
+ public static Optional canonical(String owner) {
+ if (owner == null) {
+ return Optional.empty();
+ }
+ if (owner.equalsIgnoreCase(NONE)) {
+ return Optional.of(NONE);
+ }
+ if (owner.equalsIgnoreCase(NO_CHARACTER)) {
+ return Optional.of(NO_CHARACTER);
+ }
+ return characterId(owner).map(VehicleOwners::character);
+ }
+
+ /** The stored whitelist entry for a character. */
+ public static String whitelistEntry(CharacterRef character, String name) {
+ Objects.requireNonNull(character, "character");
+ return character(character.characterId()) + SEPARATOR + character.ownerId() + SEPARATOR
+ + (name == null ? "" : name);
+ }
+
+ /** The character entry stored in a raw whitelist value; empty for legacy or unreadable values. */
+ public static Optional parseWhitelistEntry(String raw) {
+ if (raw == null || !hasPrefix(raw, CHARACTER_PREFIX)) {
+ return Optional.empty();
+ }
+ String[] parts = raw.substring(CHARACTER_PREFIX.length()).split(SEPARATOR, 3);
+ if (parts.length < 3) {
+ return Optional.empty();
+ }
+ Optional characterId = uuid(parts[0]);
+ Optional ownerId = uuid(parts[1]);
+ if (characterId.isEmpty() || ownerId.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(new WhitelistEntry(new CharacterRef(ownerId.get(), characterId.get()), parts[2]));
+ }
+
+ private static boolean hasPrefix(String value, String prefix) {
+ return value.regionMatches(true, 0, prefix, 0, prefix.length());
+ }
+
+ private static Optional uuid(String value) {
+ if (value.length() != UUID_LENGTH) {
+ return Optional.empty();
+ }
+ try {
+ UUID parsed = UUID.fromString(value);
+ return parsed.toString().equals(value.toLowerCase(Locale.ROOT)) ? Optional.of(parsed) : Optional.empty();
+ } catch (IllegalArgumentException ex) {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwnershipAuthority.java b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwnershipAuthority.java
new file mode 100644
index 0000000..44f6234
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwnershipAuthority.java
@@ -0,0 +1,17 @@
+package net.tfminecraft.vehicleframework.api;
+
+/**
+ * Registered in Bukkit's {@code ServicesManager} by the plugin that vetoes ownership changes
+ * (SimpleFactions), once it handles {@link net.tfminecraft.vehicleframework.events.VehicleOwnershipChangeEvent}.
+ *
+ *
In characters mode, while SimpleFactions is installed (enabled or not) and no ready authority
+ * is registered, VehicleFramework refuses every ownership change by a player without
+ * {@code vf.admin}. The service is looked up on every call, so unregistering takes effect at once.
+ */
+public interface VehicleOwnershipAuthority {
+
+ /** Whether the authority can judge changes now. Returning false refuses them like a missing one. */
+ default boolean ready() {
+ return true;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/VehicleQuarantineSource.java b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleQuarantineSource.java
new file mode 100644
index 0000000..6e947e0
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/VehicleQuarantineSource.java
@@ -0,0 +1,14 @@
+package net.tfminecraft.vehicleframework.api;
+
+import java.util.List;
+
+/**
+ * Lists vehicles held back from the owner migration, for {@code /vf admin quarantine}. Register
+ * implementations in Bukkit's {@code ServicesManager}; every registration is listed. Called on the
+ * main thread.
+ */
+public interface VehicleQuarantineSource {
+
+ /** The quarantined vehicles, or an empty list. */
+ List quarantinedVehicles();
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/api/WhitelistEntry.java b/src/main/java/net/tfminecraft/vehicleframework/api/WhitelistEntry.java
new file mode 100644
index 0000000..537aa3c
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/api/WhitelistEntry.java
@@ -0,0 +1,21 @@
+package net.tfminecraft.vehicleframework.api;
+
+import java.util.Objects;
+
+import net.tfminecraft.tlibs.character.CharacterRef;
+
+/**
+ * A character whitelist entry: the character and the name captured when it was added.
+ */
+public record WhitelistEntry(CharacterRef character, String name) {
+
+ public WhitelistEntry {
+ Objects.requireNonNull(character, "character");
+ name = name == null ? "" : name;
+ }
+
+ /** The stored form, {@code char_;;}. */
+ public String encode() {
+ return VehicleOwners.whitelistEntry(character, name);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/data/NamingData.java b/src/main/java/net/tfminecraft/vehicleframework/data/NamingData.java
deleted file mode 100644
index 5a4a421..0000000
--- a/src/main/java/net/tfminecraft/vehicleframework/data/NamingData.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package net.tfminecraft.vehicleframework.data;
-
-import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
-
-public class NamingData {
- private ActiveVehicle v;
- private int count = 0;
-
- public NamingData(ActiveVehicle v) {
- this.v = v;
- }
-
- public boolean tick() {
- return count++>=30;
- }
-
- public ActiveVehicle getVehicle() {
- return v;
- }
-}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/data/OwnerData.java b/src/main/java/net/tfminecraft/vehicleframework/data/OwnerData.java
index 8681810..5c6ea14 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/data/OwnerData.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/data/OwnerData.java
@@ -18,6 +18,22 @@ public OwnerData() {
whiteListed = Cache.allowWhitelist && Cache.whitelistedByDefault;
}
+ /** A detached copy of every field. */
+ public OwnerData copy() {
+ OwnerData copy = new OwnerData();
+ copy.restore(this);
+ return copy;
+ }
+
+ /** Puts back every field of an earlier {@link #copy()}. */
+ public void restore(OwnerData from) {
+ this.owner = from.owner;
+ this.whiteList = new ArrayList<>(from.whiteList);
+ this.whiteListed = from.whiteListed;
+ this.ticketsEnabled = from.ticketsEnabled;
+ this.ticketId = from.ticketId;
+ }
+
// Owner
public void setOwner(String owner) {
this.owner = owner;
diff --git a/src/main/java/net/tfminecraft/vehicleframework/data/StoredVehicleMeta.java b/src/main/java/net/tfminecraft/vehicleframework/data/StoredVehicleMeta.java
index dd1822f..79974d1 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/data/StoredVehicleMeta.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/data/StoredVehicleMeta.java
@@ -1,5 +1,7 @@
package net.tfminecraft.vehicleframework.data;
+import net.tfminecraft.vehicleframework.api.VehicleOwners;
+
public final class StoredVehicleMeta {
private final String uuid;
private final String name;
@@ -29,13 +31,8 @@ public String getOwner() {
return owner;
}
+ /** A character ({@code char_}) or legacy account ({@code player_}) owner. */
public static boolean isPlayerOwner(String owner) {
- if (owner == null || owner.isBlank()) {
- return false;
- }
- if (!owner.regionMatches(true, 0, "player_", 0, 7)) {
- return false;
- }
- return !owner.equalsIgnoreCase("player_none") && owner.length() > 7;
+ return VehicleOwners.isPersonalOwner(owner);
}
}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/database/VehiclePersistence.java b/src/main/java/net/tfminecraft/vehicleframework/database/VehiclePersistence.java
index ab98688..5b71ebc 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/database/VehiclePersistence.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/database/VehiclePersistence.java
@@ -5,9 +5,6 @@
import java.util.Map;
import java.util.Optional;
-import org.json.simple.JSONArray;
-import org.json.simple.JSONObject;
-import org.json.simple.parser.JSONParser;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
@@ -219,40 +216,6 @@ public Optional readMeta(String uuid) {
}
}
- @SuppressWarnings("unchecked")
- public boolean clearOwnership(String uuid) {
- try {
- boolean[] saved = { false };
- repository.runInTransaction(() -> {
- Optional existing = repository.findLive(uuid);
- if (existing.isEmpty()) {
- return;
- }
- VehicleSnapshot row = existing.get();
- JSONParser parser = new JSONParser();
- JSONObject json;
- try {
- json = (JSONObject) parser.parse(row.getPayloadJson());
- } catch (Exception parseError) {
- throw new RuntimeException(parseError);
- }
- json.put("owner", "none");
- json.put("whitelisted", false);
- json.put("whitelist", new JSONArray());
- VehicleSnapshot next = row.withPayload(
- json.toJSONString(),
- row.getName(),
- "none",
- System.currentTimeMillis());
- saved[0] = repository.saveLive(next);
- });
- return saved[0];
- } catch (Exception ex) {
- log("SQLite clear ownership failed for " + uuid + ": " + ex.getMessage());
- return false;
- }
- }
-
public boolean applyStoredDecay(String uuid, double fractionOfMax, double minHealthFraction) {
try {
boolean[] saved = { false };
diff --git a/src/main/java/net/tfminecraft/vehicleframework/database/VehicleRepository.java b/src/main/java/net/tfminecraft/vehicleframework/database/VehicleRepository.java
index d1b3e8f..9eb88f3 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/database/VehicleRepository.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/database/VehicleRepository.java
@@ -135,11 +135,17 @@ ON CONFLICT(uuid) DO UPDATE SET
WHERE deleted = 0 AND owner = ? COLLATE NOCASE
""";
+ // Personal owners: legacy player_ or char_, never player_none or char_none.
private static final String SELECT_LIVE_PLAYER_OWNED = SELECT_COLUMNS + """
WHERE deleted = 0
- AND owner LIKE 'player_%' COLLATE NOCASE
- AND owner != 'player_none' COLLATE NOCASE
- AND length(owner) > 7
+ AND (
+ (owner LIKE 'player\\_%' ESCAPE '\\'
+ AND owner != 'player_none' COLLATE NOCASE
+ AND length(owner) > 7)
+ OR (owner LIKE 'char\\_%' ESCAPE '\\'
+ AND owner != 'char_none' COLLATE NOCASE
+ AND length(owner) = 41)
+ )
""";
private static final String TOMBSTONE_REVISION = """
diff --git a/src/main/java/net/tfminecraft/vehicleframework/enums/VFGUI.java b/src/main/java/net/tfminecraft/vehicleframework/enums/VFGUI.java
index c13fb39..5540def 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/enums/VFGUI.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/enums/VFGUI.java
@@ -8,4 +8,5 @@ public enum VFGUI {
CONTAINER_SELECT,
OWNERSHIP,
WHITELIST,
+ WHITELIST_PICKER,
}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipChangeEvent.java b/src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipChangeEvent.java
new file mode 100644
index 0000000..d5e9000
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipChangeEvent.java
@@ -0,0 +1,117 @@
+package net.tfminecraft.vehicleframework.events;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.bukkit.entity.Player;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.vehicleframework.api.OwnershipReason;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+
+/**
+ * Fired before a player's ownership change is written. Cancel to refuse it; a
+ * {@link VehicleOwnershipChangedEvent} with {@code success == false} and the same operation ID
+ * follows either way.
+ */
+public final class VehicleOwnershipChangeEvent extends Event implements Cancellable {
+ private static final HandlerList HANDLERS = new HandlerList();
+
+ private final Player player;
+ private final ActiveVehicle vehicle;
+ private final OwnershipReason reason;
+ private final UUID operationId;
+ private final CharacterRef actor;
+ private final boolean staff;
+ private final CharacterRef target;
+ private final String rawEntry;
+ private final String previousOwner;
+ private final String newOwner;
+ private boolean cancelled;
+
+ public VehicleOwnershipChangeEvent(Player player, ActiveVehicle vehicle, OwnershipReason reason, UUID operationId,
+ CharacterRef actor, boolean staff, CharacterRef target, String rawEntry, String previousOwner,
+ String newOwner) {
+ this.player = Objects.requireNonNull(player, "player");
+ this.vehicle = Objects.requireNonNull(vehicle, "vehicle");
+ this.reason = Objects.requireNonNull(reason, "reason");
+ this.operationId = Objects.requireNonNull(operationId, "operationId");
+ this.actor = actor;
+ this.staff = staff;
+ this.target = target;
+ this.rawEntry = rawEntry;
+ this.previousOwner = previousOwner;
+ this.newOwner = newOwner;
+ }
+
+ /** The acting account. */
+ public Player getPlayer() {
+ return player;
+ }
+
+ /** The vehicle actually changed; for a tickets toggle, the ticket source. */
+ public ActiveVehicle getVehicle() {
+ return vehicle;
+ }
+
+ public OwnershipReason getReason() {
+ return reason;
+ }
+
+ /** Minted by VehicleFramework for this action. */
+ public UUID getOperationId() {
+ return operationId;
+ }
+
+ /** The acting character; empty in names mode. */
+ public Optional getActor() {
+ return Optional.ofNullable(actor);
+ }
+
+ /** Whether the actor holds {@code vf.admin}. */
+ public boolean isStaff() {
+ return staff;
+ }
+
+ /** The character gaining ownership or added to or removed from the whitelist, if any. */
+ public Optional getTarget() {
+ return Optional.ofNullable(target);
+ }
+
+ /** The whitelist entry added or removed, in stored form. */
+ public Optional getRawEntry() {
+ return Optional.ofNullable(rawEntry);
+ }
+
+ public String getPreviousOwner() {
+ return previousOwner;
+ }
+
+ /** The owner after the change; equal to the previous owner for whitelist and ticket changes. */
+ public String getNewOwner() {
+ return newOwner;
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ @Override
+ public void setCancelled(boolean cancelled) {
+ this.cancelled = cancelled;
+ }
+
+ @Override
+ public HandlerList getHandlers() {
+ return HANDLERS;
+ }
+
+ public static HandlerList getHandlerList() {
+ return HANDLERS;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipChangedEvent.java b/src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipChangedEvent.java
new file mode 100644
index 0000000..05c26dc
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipChangedEvent.java
@@ -0,0 +1,116 @@
+package net.tfminecraft.vehicleframework.events;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.bukkit.entity.Player;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.vehicleframework.api.OwnershipReason;
+import net.tfminecraft.vehicleframework.api.OwnershipValues;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+
+/**
+ * Fired after an ownership write. Player actions fire it once per operation, also when the change
+ * was cancelled or the write failed; system writes fire it only on success.
+ */
+public final class VehicleOwnershipChangedEvent extends Event {
+ private static final HandlerList HANDLERS = new HandlerList();
+
+ private final boolean success;
+ private final OwnershipReason reason;
+ private final UUID operationId;
+ private final UUID vehicleId;
+ private final ActiveVehicle vehicle;
+ private final Player player;
+ private final CharacterRef actor;
+ private final boolean staff;
+ private final CharacterRef target;
+ private final String rawEntry;
+ private final OwnershipValues previous;
+ private final OwnershipValues current;
+
+ public VehicleOwnershipChangedEvent(boolean success, OwnershipReason reason, UUID operationId, UUID vehicleId,
+ ActiveVehicle vehicle, Player player, CharacterRef actor, boolean staff, CharacterRef target,
+ String rawEntry, OwnershipValues previous, OwnershipValues current) {
+ this.success = success;
+ this.reason = Objects.requireNonNull(reason, "reason");
+ this.operationId = Objects.requireNonNull(operationId, "operationId");
+ this.vehicleId = vehicleId;
+ this.vehicle = vehicle;
+ this.player = player;
+ this.actor = actor;
+ this.staff = staff;
+ this.target = target;
+ this.rawEntry = rawEntry;
+ this.previous = previous;
+ this.current = current;
+ }
+
+ /** Whether the change was written. */
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public OwnershipReason getReason() {
+ return reason;
+ }
+
+ /** The operation ID of the matching {@link VehicleOwnershipChangeEvent}, or a fresh one for system writes. */
+ public UUID getOperationId() {
+ return operationId;
+ }
+
+ /** The vehicle changed; {@code null} only if its ID is not a UUID. */
+ public UUID getVehicleId() {
+ return vehicleId;
+ }
+
+ /** The loaded vehicle, or empty for a stored write. */
+ public Optional getVehicle() {
+ return Optional.ofNullable(vehicle);
+ }
+
+ /** The acting account; empty for system writes. */
+ public Optional getPlayer() {
+ return Optional.ofNullable(player);
+ }
+
+ public Optional getActor() {
+ return Optional.ofNullable(actor);
+ }
+
+ public boolean isStaff() {
+ return staff;
+ }
+
+ public Optional getTarget() {
+ return Optional.ofNullable(target);
+ }
+
+ public Optional getRawEntry() {
+ return Optional.ofNullable(rawEntry);
+ }
+
+ /** The fields before the operation. */
+ public OwnershipValues getPrevious() {
+ return previous;
+ }
+
+ /** The fields now: the written ones on success, the unchanged ones otherwise. */
+ public OwnershipValues getCurrent() {
+ return current;
+ }
+
+ @Override
+ public HandlerList getHandlers() {
+ return HANDLERS;
+ }
+
+ public static HandlerList getHandlerList() {
+ return HANDLERS;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipEditCheckEvent.java b/src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipEditCheckEvent.java
new file mode 100644
index 0000000..3a48c70
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipEditCheckEvent.java
@@ -0,0 +1,80 @@
+package net.tfminecraft.vehicleframework.events;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import org.bukkit.entity.Player;
+import org.bukkit.event.Event;
+import org.bukkit.event.HandlerList;
+
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.vehicleframework.api.OwnershipReason;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+
+/**
+ * Asks whether a player may use an ownership control. Fired to decide which buttons to show and
+ * again on every click. Listeners must not change anything; deny to hide or refuse the control.
+ */
+public final class VehicleOwnershipEditCheckEvent extends Event {
+ private static final HandlerList HANDLERS = new HandlerList();
+
+ private final Player player;
+ private final ActiveVehicle vehicle;
+ private final OwnershipReason reason;
+ private final CharacterRef actor;
+ private final boolean staff;
+ private boolean allowed = true;
+
+ public VehicleOwnershipEditCheckEvent(Player player, ActiveVehicle vehicle, OwnershipReason reason,
+ CharacterRef actor, boolean staff) {
+ this.player = Objects.requireNonNull(player, "player");
+ this.vehicle = Objects.requireNonNull(vehicle, "vehicle");
+ this.reason = reason;
+ this.actor = actor;
+ this.staff = staff;
+ }
+
+ public Player getPlayer() {
+ return player;
+ }
+
+ /** The vehicle the control changes; for the tickets toggle, the ticket source. */
+ public ActiveVehicle getVehicle() {
+ return vehicle;
+ }
+
+ /** The change the control makes, or empty when opening the ownership menu. */
+ public Optional getReason() {
+ return Optional.ofNullable(reason);
+ }
+
+ /** Whether this check opens the ownership menu. */
+ public boolean isMenu() {
+ return reason == null;
+ }
+
+ public Optional getActor() {
+ return Optional.ofNullable(actor);
+ }
+
+ public boolean isStaff() {
+ return staff;
+ }
+
+ public boolean isAllowed() {
+ return allowed;
+ }
+
+ public void deny() {
+ allowed = false;
+ }
+
+ @Override
+ public HandlerList getHandlers() {
+ return HANDLERS;
+ }
+
+ public static HandlerList getHandlerList() {
+ return HANDLERS;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/loaders/ConfigLoader.java b/src/main/java/net/tfminecraft/vehicleframework/loaders/ConfigLoader.java
index 0534581..0e44239 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/loaders/ConfigLoader.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/loaders/ConfigLoader.java
@@ -11,9 +11,22 @@
import net.tfminecraft.vehicleframework.cache.Cache;
import net.tfminecraft.vehicleframework.VFLogger;
+import net.tfminecraft.vehicleframework.ownership.OwnershipMode;
public class ConfigLoader {
+ /** The raw {@code ownership-identity} value, or {@code null} when missing or unreadable. */
+ public String readOwnershipIdentity(File configFile) {
+ FileConfiguration config = new YamlConfiguration();
+ try {
+ config.load(configFile);
+ } catch (IOException | InvalidConfigurationException e) {
+ VFLogger.log("Could not read " + OwnershipMode.CONFIG_KEY + ": " + e.getMessage());
+ return null;
+ }
+ return config.getString(OwnershipMode.CONFIG_KEY);
+ }
+
public void load(File configFile) {
VFLogger.info("Loading config...");
FileConfiguration config = new YamlConfiguration();
diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java
index 4e84a38..96ca606 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java
@@ -22,6 +22,10 @@
import net.tfminecraft.vehicleframework.tracks.TrackCommands;
import net.tfminecraft.vehicleframework.util.EnumDisplayConverter;
import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary;
+import net.tfminecraft.vehicleframework.api.OwnershipIdentity;
+import net.tfminecraft.vehicleframework.ownership.OwnershipAccess;
+import net.tfminecraft.vehicleframework.ownership.OwnershipActions;
+import net.tfminecraft.vehicleframework.ownership.OwnershipMode;
import net.tfminecraft.vehicleframework.VFLogger;
import net.tfminecraft.vehicleframework.VehicleFramework;
import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
@@ -65,9 +69,15 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String
return true;
}
Player p = (Player) sender;
- String owner = "player_" + p.getName();
+ Optional owner = OwnershipAccess.ownerValue(p);
+ if(owner.isEmpty()) {
+ p.sendMessage(OwnershipMode.identity() == OwnershipIdentity.DISABLED
+ ? OwnershipActions.DISABLED
+ : OwnershipActions.NEED_CHARACTER);
+ return true;
+ }
List owned =
- VehicleFramework.getVehicleManager().listOwnedVehicles(owner);
+ VehicleFramework.getVehicleManager().listOwnedVehicles(owner.get());
if(owned.isEmpty()) {
p.sendMessage("§7You do not own any vehicles.");
return true;
@@ -128,13 +138,23 @@ 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("takeover") && args.length == 1) {
+ if(args[0].equalsIgnoreCase("takeover")) {
if(!(sender instanceof Player)) {
sender.sendMessage("§cOnly players can use this command.");
return true;
}
Player p = (Player) sender;
- VehicleFramework.getVehicleManager().startTakeover(p);
+ if (args.length == 1) {
+ VehicleFramework.getVehicleManager().startTakeover(p);
+ } else {
+ // A character name may contain spaces, so it is every remaining argument.
+ VehicleFramework.getVehicleManager().startTakeover(p,
+ String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)));
+ }
+ return true;
+ }
+ if(args[0].equalsIgnoreCase("admin") && args.length == 2 && args[1].equalsIgnoreCase("quarantine")) {
+ QuarantineCommand.list(sender);
return true;
}
if(args[0].equalsIgnoreCase("reload") && args.length == 1) {
diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java
index 3be837b..d6678bb 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/managers/InventoryManager.java
@@ -18,6 +18,7 @@
import net.tfminecraft.vehicleframework.enums.SeatType;
import net.tfminecraft.vehicleframework.enums.VFGUI;
import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder;
+import net.tfminecraft.vehicleframework.ownership.OwnershipActions;
import net.tfminecraft.vehicleframework.VehicleFramework;
import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
import net.tfminecraft.vehicleframework.vehicles.component.Engine;
@@ -52,9 +53,8 @@ public void seatSelection(Inventory i, Player p, ActiveVehicle v, boolean open)
i.setItem(x, getSeatItem(v, seat));
x++;
}
- // Slot 25 – ownership settings button (owner only)
- if(v.getOwnerData().getOwner().equalsIgnoreCase("player_" + p.getName())) {
-
+ // Slot 25 – ownership settings button (owner, or staff on an owned vehicle)
+ if(OwnershipActions.mayOpenMenu(p, v)) {
i.setItem(25, ownershipGUI.createOwnershipButton());
}
if(v.isPassenger(p, false)) i.setItem(26, createDismountButton());
diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipGUIManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipGUIManager.java
index 4ca7527..4b602a0 100644
--- a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipGUIManager.java
+++ b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipGUIManager.java
@@ -11,13 +11,25 @@
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
+import net.tfminecraft.vehicleframework.api.OwnershipReason;
+import net.tfminecraft.vehicleframework.api.VehicleOwners;
+import net.tfminecraft.vehicleframework.api.WhitelistEntry;
import net.tfminecraft.vehicleframework.enums.VFGUI;
import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder;
+import net.tfminecraft.vehicleframework.managers.inventory.WhitelistPickerHolder;
+import net.tfminecraft.vehicleframework.ownership.CharacterLookup;
+import net.tfminecraft.vehicleframework.ownership.OwnerNames;
+import net.tfminecraft.vehicleframework.ownership.OwnershipActions;
+import net.tfminecraft.vehicleframework.ownership.OwnershipMode;
+import net.tfminecraft.vehicleframework.permissions.Permissions;
import net.tfminecraft.vehicleframework.VehicleFramework;
import net.tfminecraft.vehicleframework.cache.Cache;
import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
public class OwnershipGUIManager {
+ public static final int PICKER_TYPED_SLOT = 25;
+ public static final int BACK_SLOT = 26;
+ public static final int OWNER_SLOT = 22;
// ── Ownership Settings GUI ──────────────────────────────────────────────
@@ -29,17 +41,28 @@ public void ownershipGui(Inventory i, Player p, ActiveVehicle v, boolean open) {
new VFInventoryHolder(v.getUUID(), VFGUI.OWNERSHIP, v), 27, "§7Ownership Settings");
}
- // Slot 0 – Toggle whitelisting
+ // Buttons the player may not use are hidden; each click checks again.
i.clear();
- i.setItem(0, createToggleWhitelistButton(v.getOwnerData().isWhiteListed()));
+ // Slot 0 – Toggle whitelisting
+ if (OwnershipActions.allowed(p, v, OwnershipReason.WHITELIST_TOGGLE)) {
+ i.setItem(0, createToggleWhitelistButton(v.getOwnerData().isWhiteListed()));
+ }
// Slot 2 – Add player to whitelist
- if(Cache.allowWhitelist) i.setItem(2, createAddToWhitelistButton());
+ if (Cache.allowWhitelist && OwnershipActions.allowed(p, v, OwnershipReason.WHITELIST_ADD)) {
+ i.setItem(2, createAddToWhitelistButton());
+ }
// Slot 4 – View / manage whitelist
if(Cache.allowWhitelist) i.setItem(4, createViewWhitelistButton(v.getOwnerData().getWhiteList().size()));
// Slot 6 – Remove ownership
- i.setItem(6, createRemoveOwnershipButton());
- // Slot 8 – Toggle tickets
- i.setItem(8, createToggleTicketsButton(v.ticketSource().getOwnerData().isTicketsEnabled()));
+ if (OwnershipActions.allowed(p, v, OwnershipReason.CLEAR)) i.setItem(6, createRemoveOwnershipButton());
+ // Slot 8 – Toggle tickets on the ticket source
+ ActiveVehicle tickets = v.ticketSource();
+ if (OwnershipActions.allowed(p, tickets, OwnershipReason.TICKETS_TOGGLE)) {
+ i.setItem(8, createToggleTicketsButton(tickets.getOwnerData().isTicketsEnabled()));
+ }
+ if (OwnershipMode.characters()) {
+ i.setItem(OWNER_SLOT, createOwnerInfo(v.getOwnerData().getOwner(), Permissions.isAdmin(p)));
+ }
fillGlass(i);
@@ -59,9 +82,10 @@ public void whitelistGui(Inventory i, Player p, ActiveVehicle v, boolean open) {
i.clear();
int slot = 0;
+ boolean staff = Permissions.isAdmin(p);
for (String entry : v.getOwnerData().getWhiteList()) {
if (slot >= 26) break; // slot 26 is the back button
- i.setItem(slot, createWhitelistEntry(entry));
+ i.setItem(slot, OwnershipMode.characters() ? createCharacterEntry(entry, staff) : createWhitelistEntry(entry));
slot++;
}
@@ -73,6 +97,28 @@ public void whitelistGui(Inventory i, Player p, ActiveVehicle v, boolean open) {
if (open) p.openInventory(i);
}
+ // ── Nearby-player picker (characters mode) ──────────────────────────────
+
+ // Keep the existing legacy text representation, formatting, and exact-string comparisons.
+ @SuppressWarnings("deprecation")
+ public void pickerGui(Player p, ActiveVehicle v, List candidates) {
+ WhitelistPickerHolder holder = new WhitelistPickerHolder(v, candidates.subList(0, Math.min(candidates.size(), PICKER_TYPED_SLOT)));
+ Inventory i = VehicleFramework.plugin.getServer().createInventory(holder, 27, "§7Add to Whitelist");
+ int slot = 0;
+ for (WhitelistPickerHolder.Candidate candidate : holder.getCandidates()) {
+ i.setItem(slot++, createCandidate(candidate.name()));
+ }
+ if (holder.getCandidates().isEmpty()) {
+ i.setItem(13, createInfo(Material.PAPER, "§7Nobody nearby", "§7Players within 16 blocks or aboard show here"));
+ }
+ if (CharacterLookup.typedNamesAvailable()) {
+ i.setItem(PICKER_TYPED_SLOT, createInfo(Material.WRITABLE_BOOK, "§aType a Name", "§7Click, then type a character name in chat"));
+ }
+ i.setItem(BACK_SLOT, createBackButton());
+ fillGlass(i);
+ p.openInventory(i);
+ }
+
// ── Seat-selection ownership button (shown to the owner) ─────────────────
// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@@ -112,7 +158,7 @@ private ItemStack createAddToWhitelistButton() {
ItemMeta m = i.getItemMeta();
m.setDisplayName("§aAdd to Whitelist");
List lore = new ArrayList<>();
- lore.add("§7Click, then type a player name in chat");
+ lore.add(OwnershipMode.characters() ? "§7Click to pick a nearby player" : "§7Click, then type a player name in chat");
m.setLore(lore);
i.setItemMeta(m);
return i;
@@ -177,6 +223,57 @@ private ItemStack createWhitelistEntry(String storedName) {
return i;
}
+ // Character entries show the name captured when added; anything else no longer grants access.
+ @SuppressWarnings("deprecation")
+ private ItemStack createCharacterEntry(String storedName, boolean staff) {
+ ItemStack i = new ItemStack(Material.PLAYER_HEAD);
+ ItemMeta m = i.getItemMeta();
+ List lore = new ArrayList<>();
+ java.util.Optional entry = VehicleOwners.parseWhitelistEntry(storedName);
+ if (entry.isPresent()) {
+ m.setDisplayName("§e" + entry.get().name());
+ if (staff) lore.add("§7Account: §f" + OwnerNames.account(entry.get().character().ownerId()));
+ } else {
+ m.setDisplayName("§8" + OwnerNames.legacy(storedName));
+ lore.add("§7Old entry: no longer grants access");
+ }
+ lore.add("§cClick to remove from whitelist");
+ m.setLore(lore);
+ NamespacedKey key = new NamespacedKey(VehicleFramework.plugin, "vf_whitelist_entry");
+ m.getPersistentDataContainer().set(key, PersistentDataType.STRING, storedName);
+ i.setItemMeta(m);
+ return i;
+ }
+
+ // Picker heads never carry the account's skin.
+ @SuppressWarnings("deprecation")
+ private ItemStack createCandidate(String name) {
+ ItemStack i = new ItemStack(Material.PLAYER_HEAD);
+ ItemMeta m = i.getItemMeta();
+ m.setDisplayName("§e" + name);
+ List lore = new ArrayList<>();
+ lore.add("§aClick to add to the whitelist");
+ m.setLore(lore);
+ i.setItemMeta(m);
+ return i;
+ }
+
+ private ItemStack createOwnerInfo(String owner, boolean staff) {
+ return createInfo(Material.NAME_TAG, "§7Owner: §f" + OwnerNames.owner(owner, staff), null);
+ }
+
+ @SuppressWarnings("deprecation")
+ private ItemStack createInfo(Material material, String title, String line) {
+ ItemStack i = new ItemStack(material);
+ ItemMeta m = i.getItemMeta();
+ m.setDisplayName(title);
+ List lore = new ArrayList<>();
+ if (line != null) lore.add(line);
+ m.setLore(lore);
+ i.setItemMeta(m);
+ return i;
+ }
+
// Keep the existing legacy text representation, formatting, and exact-string comparisons.
@SuppressWarnings("deprecation")
private ItemStack createBackButton() {
diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java
new file mode 100644
index 0000000..dd9e10f
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java
@@ -0,0 +1,577 @@
+package net.tfminecraft.vehicleframework.managers;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.Material;
+import org.bukkit.NamespacedKey;
+import org.bukkit.Sound;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.event.player.AsyncPlayerChatEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.persistence.PersistentDataType;
+
+import net.tfminecraft.tlibs.character.CharacterInfo;
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.tlibs.character.CharacterSession;
+import net.tfminecraft.tlibs.character.CharacterSessionEndEvent;
+import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter;
+import net.tfminecraft.vehicleframework.VehicleFramework;
+import net.tfminecraft.vehicleframework.api.OwnershipReason;
+import net.tfminecraft.vehicleframework.api.VehicleFrameworkApi.Result;
+import net.tfminecraft.vehicleframework.api.VehicleOwners;
+import net.tfminecraft.vehicleframework.api.WhitelistEntry;
+import net.tfminecraft.vehicleframework.cache.Cache;
+import net.tfminecraft.vehicleframework.data.OwnerData;
+import net.tfminecraft.vehicleframework.enums.VFGUI;
+import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder;
+import net.tfminecraft.vehicleframework.managers.inventory.WhitelistPickerHolder;
+import net.tfminecraft.vehicleframework.ownership.CharacterLookup;
+import net.tfminecraft.vehicleframework.ownership.OwnerNames;
+import net.tfminecraft.vehicleframework.ownership.OwnershipAccess;
+import net.tfminecraft.vehicleframework.ownership.OwnershipActions;
+import net.tfminecraft.vehicleframework.ownership.OwnershipActions.Actor;
+import net.tfminecraft.vehicleframework.ownership.OwnershipActions.Outcome;
+import net.tfminecraft.vehicleframework.ownership.OwnershipMode;
+import net.tfminecraft.vehicleframework.ownership.PendingActions;
+import net.tfminecraft.vehicleframework.ownership.PendingActions.Kind;
+import net.tfminecraft.vehicleframework.ownership.PendingActions.Pending;
+import net.tfminecraft.vehicleframework.ownership.PendingActions.Target;
+import net.tfminecraft.vehicleframework.permissions.Permissions;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+
+/**
+ * Claims, the ownership and whitelist menus, takeovers and the chat actions behind them. Every
+ * click checks again that the player owns the vehicle (or is staff) before anything changes.
+ */
+public class OwnershipManager implements Listener {
+ public static final double PICKER_RADIUS = 16.0;
+ public static final String NO_SINGLE_MATCH = "§cNo single living character is called §f%s§c.";
+ public static final String TYPED_NAMES_OFF = "§cTyped character names are not available on this server.";
+ public static final String SESSION_CHANGED = "§cYour character changed, so nothing was done.";
+ public static final String VEHICLE_GONE = "§cThat vehicle is no longer here, so nothing was done.";
+ public static final String ID_PREFIX = "id:";
+ public static final String TAKEOVER_USAGE = "§cUsage: /vf takeover [character name | id:]";
+ public static final String NAMES_MODE_TAKEOVER = "§cThis server identifies owners by account name, so a takeover is always for yourself: use /vf takeover.";
+ public static final String NOT_AN_ID = "§f%s§c is not a character ID. Use id:.";
+ public static final String UNKNOWN_ID = "§cNo character has the ID §f%s§c.";
+
+ private final VehicleManager vehicles;
+ private final OwnershipGUIManager gui = new OwnershipGUIManager();
+ private final PendingActions pending = new PendingActions();
+
+ public OwnershipManager(VehicleManager vehicles) {
+ this.vehicles = vehicles;
+ }
+
+ public OwnershipGUIManager getGui() {
+ return gui;
+ }
+
+ public PendingActions getPending() {
+ return pending;
+ }
+
+ // ── Claims and takeovers ─────────────────────────────────────────────────
+
+ /** Claims an unowned vehicle for the player's current character (or name in names mode). */
+ public boolean claim(Player p, ActiveVehicle v) {
+ if (!VehicleOwners.isUnowned(v.getOwnerData().getOwner())) {
+ return false;
+ }
+ Outcome actor = OwnershipActions.actor(p);
+ if (actor.refusal() != null || !OwnershipActions.authorityAllows(actor.value())) {
+ return false;
+ }
+ String owner = actor.value().ownerValue();
+ return write(actor.value(), v, OwnershipReason.CLAIM, actor.value().ref(), null, owned(owner)) == Result.OK;
+ }
+
+ /**
+ * Arms a staff takeover. Without a target the vehicle goes to the staff member's own character.
+ * In characters mode the target may be a character ID ({@code id:} or a bare UUID, any
+ * known character) or a real name, which must match exactly one living character.
+ */
+ public void startTakeover(Player p, String targetName) {
+ Outcome actor = OwnershipActions.actor(p);
+ if (actor.refusal() != null) {
+ p.sendMessage(actor.refusal());
+ return;
+ }
+ Target target;
+ if (targetName == null) {
+ target = new Target(actor.value().ownerValue(), actor.value().ref(), null);
+ } else {
+ Optional named = takeoverTarget(p, targetName.trim().replaceAll("\\s+", " "));
+ if (named.isEmpty()) {
+ return;
+ }
+ target = named.get();
+ }
+ 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.");
+ } else {
+ p.sendMessage("§aRight-click a vehicle within 60 seconds to give it to §e" + target.name() + "§a.");
+ }
+ }
+
+ /** Resolves a named takeover target, telling the player why when it cannot. */
+ private Optional takeoverTarget(Player p, String target) {
+ if (target.isEmpty()) {
+ p.sendMessage(TAKEOVER_USAGE);
+ return Optional.empty();
+ }
+ if (!OwnershipMode.characters()) {
+ p.sendMessage(NAMES_MODE_TAKEOVER);
+ return Optional.empty();
+ }
+ Optional id = characterId(target);
+ if (id.isPresent() || target.regionMatches(true, 0, ID_PREFIX, 0, ID_PREFIX.length())) {
+ if (id.isEmpty()) {
+ p.sendMessage(String.format(NOT_AN_ID, target));
+ return Optional.empty();
+ }
+ Optional info = CharacterLookup.info(id.get());
+ if (info.isEmpty()) {
+ p.sendMessage(String.format(UNKNOWN_ID, id.get()));
+ return Optional.empty();
+ }
+ return Optional.of(target(info.get()));
+ }
+ if (!CharacterLookup.typedNamesAvailable()) {
+ p.sendMessage(TYPED_NAMES_OFF);
+ return Optional.empty();
+ }
+ Optional found = single(target);
+ if (found.isEmpty()) {
+ p.sendMessage(String.format(NO_SINGLE_MATCH, target));
+ return Optional.empty();
+ }
+ return Optional.of(target(found.get()));
+ }
+
+ private static Target target(CharacterInfo info) {
+ return new Target(VehicleOwners.character(info.ref().characterId()), info.ref(), info.name());
+ }
+
+ /**
+ * Tab completions for {@code /vf takeover}: {@code id:} and the real names of online players'
+ * current characters. The typed words are the arguments after "takeover"; a suggestion
+ * continues from the last typed word, so names with spaces complete word by word.
+ */
+ public static List takeoverSuggestions(String[] typedWords) {
+ List suggestions = new ArrayList<>();
+ if (!OwnershipMode.characters() || typedWords.length == 0) {
+ return suggestions;
+ }
+ String typed = String.join(" ", typedWords).toLowerCase(Locale.ROOT);
+ if (typedWords.length == 1 && ID_PREFIX.startsWith(typed)) {
+ suggestions.add(ID_PREFIX);
+ }
+ for (Player other : Bukkit.getOnlinePlayers()) {
+ CharacterLookup.session(other)
+ .flatMap(session -> CharacterLookup.info(session.ref().characterId()))
+ .map(CharacterInfo::name)
+ .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(typed))
+ .ifPresent(name -> {
+ String[] words = name.split(" ", -1);
+ if (words.length >= typedWords.length) {
+ suggestions.add(String.join(" ",
+ Arrays.copyOfRange(words, typedWords.length - 1, words.length)));
+ }
+ });
+ }
+ return suggestions;
+ }
+
+ /** The UUID of {@code id:} or a bare UUID; empty for anything else. */
+ static Optional characterId(String target) {
+ String value = target.regionMatches(true, 0, ID_PREFIX, 0, ID_PREFIX.length())
+ ? target.substring(ID_PREFIX.length()).trim()
+ : target;
+ return VehicleOwners.characterId(VehicleOwners.CHARACTER_PREFIX + value);
+ }
+
+ /** Applies a pending takeover to the clicked vehicle; false when none is pending. */
+ public boolean takeover(Player p, ActiveVehicle v) {
+ Optional taken = pending.take(p, Kind.TAKEOVER);
+ if (taken.isEmpty()) {
+ return false;
+ }
+ Pending action = taken.get();
+ if (!Permissions.isAdmin(p)) {
+ p.sendMessage(OwnershipActions.NOT_ALLOWED);
+ return true;
+ }
+ if (!PendingActions.stillCurrent(action)) {
+ p.sendMessage(SESSION_CHANGED);
+ return true;
+ }
+ Outcome actor = OwnershipActions.actor(p);
+ if (actor.refusal() != null) {
+ p.sendMessage(actor.refusal());
+ return true;
+ }
+ Target target = action.target();
+ OwnershipReason reason = VehicleOwners.isUnowned(v.getOwnerData().getOwner())
+ ? OwnershipReason.CLAIM
+ : OwnershipReason.TAKEOVER;
+ Result result = write(actor.value(), v, reason, target.character(), null, owned(target.ownerValue()));
+ if (result == Result.OK) {
+ p.sendMessage(target.name() == null
+ ? "§aYou are now the owner of §e" + v.getName() + "§a."
+ : "§e" + v.getName() + "§a now belongs to §e" + target.name() + "§a.");
+ }
+ return true;
+ }
+
+ /** Starts renaming with a name tag. */
+ @SuppressWarnings("deprecation")
+ public void startRename(Player p, ActiveVehicle v) {
+ CharacterSession session = OwnershipAccess.session(p).orElse(null);
+ if (OwnershipMode.characters() && session == null) {
+ p.sendMessage(OwnershipActions.NEED_CHARACTER);
+ return;
+ }
+ pending.start(p, Kind.RENAME, v, session, null);
+ p.sendTitle("", "§eType the §aName §ein chat", 10, 80, 10);
+ p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f);
+ }
+
+ /** Opens the ownership menu from the seat menu if the player may see it. */
+ public void openMenu(Player p, ActiveVehicle v) {
+ if (OwnershipActions.mayOpenMenu(p, v)) {
+ p.closeInventory();
+ gui.ownershipGui(null, p, v, true);
+ }
+ }
+
+ // ── Menus ────────────────────────────────────────────────────────────────
+
+ @EventHandler
+ public void ownershipClick(InventoryClickEvent e) {
+ ActiveVehicle v = menuVehicle(e, VFGUI.OWNERSHIP);
+ if (v == null) return;
+ Player p = (Player) e.getWhoClicked();
+ switch (e.getSlot()) {
+ case 0 -> {
+ if (change(p, v, OwnershipReason.WHITELIST_TOGGLE, null, null,
+ data -> data.setWhiteListed(!data.isWhiteListed()))) {
+ gui.ownershipGui(e.getView().getTopInventory(), p, v, false);
+ }
+ }
+ case 2 -> startWhitelistAdd(p, v);
+ case 4 -> {
+ p.closeInventory();
+ if (mayView(p, v)) {
+ gui.whitelistGui(null, p, v, true);
+ }
+ }
+ case 6 -> {
+ if (change(p, v, OwnershipReason.CLEAR, null, null, data -> {
+ data.setOwner(VehicleOwners.NONE);
+ data.setWhiteListed(false);
+ if (OwnershipMode.characters()) data.setWhiteList(new ArrayList<>());
+ })) {
+ p.closeInventory();
+ p.sendMessage("§7Ownership of §f" + v.getName() + "§7 has been removed.");
+ }
+ }
+ case 8 -> {
+ if (change(p, v.ticketSource(), OwnershipReason.TICKETS_TOGGLE, null, null, OwnerData::toggleTickets)) {
+ gui.ownershipGui(e.getView().getTopInventory(), p, v, false);
+ }
+ }
+ default -> {
+ }
+ }
+ }
+
+ @EventHandler
+ public void whitelistClick(InventoryClickEvent e) {
+ ActiveVehicle v = menuVehicle(e, VFGUI.WHITELIST);
+ if (v == null) return;
+ Player p = (Player) e.getWhoClicked();
+ if (e.getSlot() == OwnershipGUIManager.BACK_SLOT) {
+ p.closeInventory();
+ openMenu(p, v);
+ return;
+ }
+ NamespacedKey key = new NamespacedKey(VehicleFramework.plugin, "vf_whitelist_entry");
+ String entry = e.getCurrentItem().getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING);
+ if (entry == null) return;
+ CharacterRef target = VehicleOwners.parseWhitelistEntry(entry).map(WhitelistEntry::character).orElse(null);
+ if (change(p, v, OwnershipReason.WHITELIST_REMOVE, target, entry, data -> data.removeFromWhiteList(entry))) {
+ p.sendMessage("§eRemoved §f" + entryName(entry) + "§e from the whitelist.");
+ gui.whitelistGui(e.getView().getTopInventory(), p, v, false);
+ }
+ }
+
+ @EventHandler
+ public void pickerClick(InventoryClickEvent e) {
+ ActiveVehicle v = menuVehicle(e, VFGUI.WHITELIST_PICKER);
+ if (v == null) return;
+ Player p = (Player) e.getWhoClicked();
+ WhitelistPickerHolder holder = (WhitelistPickerHolder) e.getView().getTopInventory().getHolder();
+ int slot = e.getSlot();
+ if (slot == OwnershipGUIManager.BACK_SLOT) {
+ p.closeInventory();
+ openMenu(p, v);
+ return;
+ }
+ if (slot == OwnershipGUIManager.PICKER_TYPED_SLOT) {
+ startTypedAdd(p, v);
+ return;
+ }
+ Optional candidate = holder.candidate(slot);
+ if (candidate.isEmpty()) return;
+ CharacterSession session = candidate.get().session();
+ if (!CharacterLookup.isCurrent(session)) {
+ p.sendMessage("§cThat player is no longer playing the same character.");
+ gui.pickerGui(p, v, candidates(v));
+ return;
+ }
+ if (addCharacter(p, v, session.ref(), candidate.get().name())) {
+ gui.pickerGui(p, v, candidates(v));
+ }
+ }
+
+ // ── Chat actions ─────────────────────────────────────────────────────────
+
+ // Retain Bukkit chat-event ordering and String message semantics for existing integrations.
+ @SuppressWarnings("deprecation")
+ @EventHandler
+ public void chat(AsyncPlayerChatEvent e) {
+ Player p = e.getPlayer();
+ boolean whitelist = pending.has(p, Kind.WHITELIST_ADD);
+ if (!whitelist && !pending.has(p, Kind.RENAME)) return;
+ e.setCancelled(true);
+ String message = e.getMessage();
+ Bukkit.getScheduler().runTask(VehicleFramework.plugin, () -> {
+ if (whitelist) {
+ pending.take(p, Kind.WHITELIST_ADD).ifPresent(action -> applyWhitelistAdd(p, action, message));
+ } else {
+ pending.take(p, Kind.RENAME).ifPresent(action -> applyRename(p, action, message));
+ }
+ });
+ }
+
+ @EventHandler
+ public void quit(PlayerQuitEvent e) {
+ pending.clear(e.getPlayer());
+ }
+
+ @EventHandler
+ public void sessionEnd(CharacterSessionEndEvent e) {
+ if (pending.clear(e.getPlayer())) {
+ e.getPlayer().sendMessage("§cYour pending vehicle action was cancelled.");
+ }
+ }
+
+ private void startWhitelistAdd(Player p, ActiveVehicle v) {
+ if (!Cache.allowWhitelist) return;
+ Optional refused = OwnershipActions.refusal(p, v, OwnershipReason.WHITELIST_ADD);
+ if (refused.isPresent()) {
+ p.sendMessage(refused.get());
+ return;
+ }
+ if (OwnershipMode.characters()) {
+ gui.pickerGui(p, v, candidates(v));
+ return;
+ }
+ p.closeInventory();
+ pending.start(p, Kind.WHITELIST_ADD, v, null, null);
+ p.sendMessage("§aType the player name in chat to add them to the whitelist. Type §ccancel §ato abort.");
+ }
+
+ private void startTypedAdd(Player p, ActiveVehicle v) {
+ if (!CharacterLookup.typedNamesAvailable()) {
+ p.sendMessage(TYPED_NAMES_OFF);
+ return;
+ }
+ Optional refused = OwnershipActions.refusal(p, v, OwnershipReason.WHITELIST_ADD);
+ if (refused.isPresent()) {
+ p.sendMessage(refused.get());
+ return;
+ }
+ p.closeInventory();
+ pending.start(p, Kind.WHITELIST_ADD, v, OwnershipAccess.session(p).orElse(null), null);
+ p.sendMessage("§aType the character's name in chat to add them to the whitelist. Type §ccancel §ato abort.");
+ }
+
+ private void applyWhitelistAdd(Player p, Pending action, String message) {
+ if (message.equalsIgnoreCase("cancel")) {
+ p.sendMessage("§cCancelled.");
+ return;
+ }
+ if (!PendingActions.stillCurrent(action)) {
+ p.sendMessage(SESSION_CHANGED);
+ return;
+ }
+ ActiveVehicle v = action.vehicle();
+ if (!stillLoaded(v)) {
+ p.sendMessage(VEHICLE_GONE);
+ return;
+ }
+ String name = message.trim();
+ if (OwnershipMode.characters()) {
+ Optional found = single(name);
+ if (found.isEmpty()) {
+ p.sendMessage(String.format(NO_SINGLE_MATCH, name));
+ return;
+ }
+ addCharacter(p, v, found.get().ref(), found.get().name());
+ return;
+ }
+ String entry = VehicleOwners.LEGACY_PREFIX + name;
+ if (v.getOwnerData().getWhiteList().contains(entry)) {
+ p.sendMessage("§c" + name + " is already on the whitelist.");
+ return;
+ }
+ if (change(p, v, OwnershipReason.WHITELIST_ADD, null, entry, data -> data.addToWhiteList(entry))) {
+ p.sendMessage("§aAdded §f" + name + "§a to the whitelist.");
+ }
+ }
+
+ private boolean addCharacter(Player p, ActiveVehicle v, CharacterRef character, String name) {
+ for (String existing : v.getOwnerData().getWhiteList()) {
+ Optional parsed = VehicleOwners.parseWhitelistEntry(existing);
+ if (parsed.isPresent() && parsed.get().character().characterId().equals(character.characterId())) {
+ p.sendMessage("§c" + name + " is already on the whitelist.");
+ return false;
+ }
+ }
+ String entry = VehicleOwners.whitelistEntry(character, name);
+ if (change(p, v, OwnershipReason.WHITELIST_ADD, character, entry, data -> data.addToWhiteList(entry))) {
+ p.sendMessage("§aAdded §f" + name + "§a to the whitelist.");
+ return true;
+ }
+ return false;
+ }
+
+ private void applyRename(Player p, Pending action, String message) {
+ if (!PendingActions.stillCurrent(action)) {
+ p.sendMessage(SESSION_CHANGED);
+ return;
+ }
+ ActiveVehicle v = action.vehicle();
+ if (!stillLoaded(v)) {
+ p.sendMessage(VEHICLE_GONE);
+ return;
+ }
+ v.setName(StringFormatter.formatHex(message.replace("_", " ")));
+ p.sendMessage("§aRenamed the vehicle to " + v.getName());
+ p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f);
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ /** Whether a pending action's vehicle is still the registered, undestroyed one. */
+ private boolean stillLoaded(ActiveVehicle v) {
+ return !v.isDestroyed() && vehicles.get(v.getEntity()) == v;
+ }
+
+ /** Players with a current character within 16 blocks of the vehicle or aboard it, not yet listed. */
+ List candidates(ActiveVehicle v) {
+ Location centre = v.getEntity().getLocation();
+ Set listed = new HashSet<>();
+ VehicleOwners.characterId(v.getOwnerData().getOwner()).ifPresent(listed::add);
+ for (String entry : v.getOwnerData().getWhiteList()) {
+ VehicleOwners.parseWhitelistEntry(entry).ifPresent(parsed -> listed.add(parsed.character().characterId()));
+ }
+ List found = new ArrayList<>();
+ for (Player other : Bukkit.getOnlinePlayers()) {
+ boolean near = centre.getWorld() != null && centre.getWorld().equals(other.getWorld())
+ && other.getLocation().distanceSquared(centre) <= PICKER_RADIUS * PICKER_RADIUS;
+ if (!near && !v.isPassenger(other, true)) continue;
+ Optional session = CharacterLookup.session(other);
+ if (session.isEmpty() || !listed.add(session.get().ref().characterId())) continue;
+ found.add(new WhitelistPickerHolder.Candidate(session.get(), CharacterLookup.displayName(other)));
+ }
+ return found;
+ }
+
+ private static Optional single(String name) {
+ List found = CharacterLookup.findLiving(name);
+ return found.size() == 1 ? Optional.of(found.get(0)) : Optional.empty();
+ }
+
+ private static boolean mayView(Player p, ActiveVehicle v) {
+ Outcome actor = OwnershipActions.actor(p);
+ if (actor.refusal() != null) {
+ p.sendMessage(actor.refusal());
+ return false;
+ }
+ if (!OwnershipActions.controls(actor.value(), v)) {
+ p.sendMessage(OwnershipActions.NOT_OWNER);
+ return false;
+ }
+ return true;
+ }
+
+ private static String entryName(String entry) {
+ return VehicleOwners.parseWhitelistEntry(entry).map(WhitelistEntry::name).orElseGet(() -> OwnerNames.legacy(entry));
+ }
+
+ /** The owner a claim or takeover writes; characters mode also empties the whitelist and turns it off. */
+ private static Consumer owned(String owner) {
+ return data -> {
+ data.setOwner(owner);
+ if (OwnershipMode.characters()) {
+ data.setWhiteList(new ArrayList<>());
+ data.setWhiteListed(false);
+ }
+ };
+ }
+
+ /** Re-checks the player, then runs the change through the events and the writer. */
+ private boolean change(Player p, ActiveVehicle target, OwnershipReason reason, CharacterRef character,
+ String entry, Consumer mutation) {
+ Optional refused = OwnershipActions.refusal(p, target, reason);
+ if (refused.isPresent()) {
+ p.sendMessage(refused.get());
+ return false;
+ }
+ Actor actor = OwnershipActions.actor(p).value();
+ return write(actor, target, reason, character, entry, mutation) == Result.OK;
+ }
+
+ private static Result write(Actor actor, ActiveVehicle v, OwnershipReason reason, CharacterRef character,
+ String entry, Consumer mutation) {
+ Optional result = OwnershipActions.perform(
+ new OwnershipActions.Change(actor, v, reason, character, entry, mutation));
+ if (result.isPresent() && result.get() == Result.SAVE_FAILED) {
+ actor.player().sendMessage(OwnershipActions.SAVE_FAILED);
+ }
+ return result.orElse(null);
+ }
+
+ private static ActiveVehicle menuVehicle(InventoryClickEvent e, VFGUI type) {
+ if (!(e.getView().getTopInventory().getHolder() instanceof VFInventoryHolder h)) return null;
+ if (!h.getType().equals(type)) return null;
+ e.setCancelled(true);
+ if (h.getVehicle().isEmpty()) return null;
+ ActiveVehicle v = h.getVehicle().get();
+ if (v.isDestroyed()) {
+ e.getWhoClicked().closeInventory();
+ return null;
+ }
+ ItemStack item = e.getCurrentItem();
+ if (item == null || item.getType().equals(Material.GRAY_STAINED_GLASS_PANE)) return null;
+ return v;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/QuarantineCommand.java b/src/main/java/net/tfminecraft/vehicleframework/managers/QuarantineCommand.java
new file mode 100644
index 0000000..8dd0340
--- /dev/null
+++ b/src/main/java/net/tfminecraft/vehicleframework/managers/QuarantineCommand.java
@@ -0,0 +1,64 @@
+package net.tfminecraft.vehicleframework.managers;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.bukkit.Bukkit;
+import org.bukkit.command.CommandSender;
+import org.bukkit.plugin.RegisteredServiceProvider;
+
+import net.tfminecraft.vehicleframework.VFLogger;
+import net.tfminecraft.vehicleframework.api.OwnershipMeta;
+import net.tfminecraft.vehicleframework.api.QuarantinedVehicle;
+import net.tfminecraft.vehicleframework.api.VehicleQuarantineSource;
+import net.tfminecraft.vehicleframework.ownership.OwnershipStore;
+
+/** {@code /vf admin quarantine}: vehicles the owner migration held back, from every registered source. */
+public final class QuarantineCommand {
+
+ private QuarantineCommand() {
+ }
+
+ public static void list(CommandSender sender) {
+ List> sources =
+ new ArrayList<>(Bukkit.getServicesManager().getRegistrations(VehicleQuarantineSource.class));
+ if (sources.isEmpty()) {
+ sender.sendMessage("§7No quarantine list is registered. SimpleFactions registers one once it has migrated vehicle owners.");
+ return;
+ }
+ List vehicles = new ArrayList<>();
+ for (RegisteredServiceProvider source : sources) {
+ try {
+ vehicles.addAll(source.getProvider().quarantinedVehicles());
+ } catch (RuntimeException ex) {
+ VFLogger.log("Quarantine list from " + source.getPlugin().getName() + " failed: " + ex);
+ sender.sendMessage("§c" + source.getPlugin().getName() + " could not list its quarantine; see the console.");
+ }
+ }
+ sender.sendMessage("§eQuarantined vehicles: §f" + vehicles.size());
+ Optional