From ff9cc5cc2394257ea408f3d603253f4d8ffc1323 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:38:35 +0000 Subject: [PATCH 1/5] build: pin TLibs 2.4.1 and drop folia-supported VehicleFramework now uses the TLibs character session API. The typed-name lookup from TLibs 2.5.0 is probed at runtime instead of pinned. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 2 +- .../vehicleframework/data/NamingData.java | 20 ------------------- src/main/resources/plugin.yml | 1 - 3 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 src/main/java/net/tfminecraft/vehicleframework/data/NamingData.java diff --git a/pom.xml b/pom.xml index 0225a1a..03d251e 100644 --- a/pom.xml +++ b/pom.xml @@ -13,7 +13,7 @@ UTF-8 full 25.0.0 - 2.0.0 + 2.4.1 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/resources/plugin.yml b/src/main/resources/plugin.yml index e68443a..1eb67d2 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,7 +3,6 @@ api-version: '1.21.4' name: VehicleFramework version: ${project.version} author: Drefvelin -folia-supported: true depend: [NBTAPI, ModelEngine, ProtocolLib, TLibs] softdepend: [MythicMobs, CoreProtect] From cf6e2cfd9a7673cf7869c5f1a3b1cb320efae016 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:38:35 +0000 Subject: [PATCH 2/5] feat: add the character ownership API and system writes Owners can be characters (char_), nobody (none) or a faction without a leader (char_none). VehicleFrameworkApi lists stored and live ownership apart, and setOwnership writes a loaded vehicle (restoring it if the save fails) or rewrites the stored column and payload in one transaction. Player changes go through a pre-write event, the legacy claim event for claims and a post-write event that always fires once the change started. In characters mode every check asks TLibs for the current session and fails closed, and changes by non-staff need SimpleFactions' authority while it is installed. The ownership-identity key picks the mode once at enable. Co-Authored-By: Claude Opus 5.5 --- .../vehicleframework/VFLogger.java | 4 + .../vehicleframework/api/OwnerChange.java | 44 +++ .../api/OwnershipIdentity.java | 17 + .../vehicleframework/api/OwnershipMeta.java | 42 +++ .../vehicleframework/api/OwnershipReason.java | 27 ++ .../vehicleframework/api/OwnershipValues.java | 27 ++ .../api/QuarantinedVehicle.java | 20 ++ .../vehicleframework/api/SystemReason.java | 35 ++ .../api/VehicleFrameworkApi.java | 68 ++++ .../vehicleframework/api/VehicleOwners.java | 129 +++++++ .../api/VehicleOwnershipAuthority.java | 17 + .../api/VehicleQuarantineSource.java | 14 + .../vehicleframework/api/WhitelistEntry.java | 21 ++ .../vehicleframework/data/OwnerData.java | 16 + .../data/StoredVehicleMeta.java | 11 +- .../database/VehicleRepository.java | 12 +- .../events/VehicleOwnershipChangeEvent.java | 117 +++++++ .../events/VehicleOwnershipChangedEvent.java | 116 +++++++ .../VehicleOwnershipEditCheckEvent.java | 80 +++++ .../vehicleframework/ownership/AccessKey.java | 38 +++ .../ownership/CharacterLookup.java | 96 ++++++ .../ownership/OwnerNames.java | 55 +++ .../ownership/OwnershipAccess.java | 49 +++ .../ownership/OwnershipActions.java | 193 +++++++++++ .../ownership/OwnershipMode.java | 58 ++++ .../ownership/OwnershipStore.java | 305 +++++++++++++++++ .../ownership/PendingActions.java | 94 +++++ .../vehicles/handlers/VehicleTicketRules.java | 33 +- .../api/OwnershipValuesTest.java | 70 ++++ .../api/VehicleOwnersTest.java | 93 +++++ .../database/VehiclePersistenceTest.java | 20 ++ .../events/OwnershipEventsTest.java | 76 +++++ .../ownership/CharacterLookupTest.java | 117 +++++++ .../ownership/OwnerNamesTest.java | 68 ++++ .../ownership/OwnershipAccessTest.java | 132 +++++++ .../ownership/OwnershipActionsTest.java | 256 ++++++++++++++ .../ownership/OwnershipFixture.java | 37 ++ .../ownership/OwnershipModeTest.java | 54 +++ .../ownership/OwnershipStoreTest.java | 321 ++++++++++++++++++ .../ownership/PendingActionsTest.java | 105 ++++++ .../RuntimeSupportCoverageTest.java | 2 +- 41 files changed, 3067 insertions(+), 22 deletions(-) create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/OwnerChange.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/OwnershipIdentity.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/OwnershipMeta.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/OwnershipReason.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/OwnershipValues.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/QuarantinedVehicle.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/SystemReason.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/VehicleFrameworkApi.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwners.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/VehicleOwnershipAuthority.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/VehicleQuarantineSource.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/api/WhitelistEntry.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipChangeEvent.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipChangedEvent.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/events/VehicleOwnershipEditCheckEvent.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/ownership/AccessKey.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/ownership/CharacterLookup.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/ownership/OwnerNames.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccess.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipActions.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipMode.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipStore.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/ownership/PendingActions.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/api/OwnershipValuesTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/api/VehicleOwnersTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/events/OwnershipEventsTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/ownership/CharacterLookupTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/ownership/OwnerNamesTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccessTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipActionsTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipFixture.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipModeTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipStoreTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/ownership/PendingActionsTest.java 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/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
ReasonEntriesFlag
RESTAMPkeptkept
MIGRATEas seeded ({@link OwnerChange#whitelist()}, kept when null)kept
CONSTRUCTemptiedon if {@code allow-whitelist} and {@code whitelisted-by-default}
RELEASE, HANDOVERemptiedoff
TRANSFERemptiedkept
+ */ +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/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/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/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/ownership/AccessKey.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/AccessKey.java new file mode 100644 index 0000000..2d5e209 --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/AccessKey.java @@ -0,0 +1,38 @@ +package net.tfminecraft.vehicleframework.ownership; + +import java.util.UUID; + +import net.tfminecraft.vehicleframework.api.VehicleOwners; + +/** + * Who a player counts as for owner and whitelist checks: a character in characters mode, an + * account name in names mode. + */ +public record AccessKey(String ownerValue, UUID characterId, String legacyName) { + + public static AccessKey character(UUID characterId) { + return new AccessKey(VehicleOwners.character(characterId), characterId, null); + } + + /** Names mode; {@code null} for a {@code null} name. */ + public static AccessKey name(String playerName) { + return playerName == null ? null : new AccessKey(VehicleOwners.LEGACY_PREFIX + playerName, null, playerName); + } + + public boolean owns(String owner) { + return owner != null && owner.equalsIgnoreCase(ownerValue); + } + + /** Character entries match by character; legacy entries match account names in names mode only. */ + public boolean matchesEntry(String entry) { + if (entry == null) { + return false; + } + if (characterId != null) { + return VehicleOwners.parseWhitelistEntry(entry) + .map(parsed -> parsed.character().characterId().equals(characterId)) + .orElse(false); + } + return entry.equalsIgnoreCase(ownerValue) || entry.equalsIgnoreCase(legacyName); + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/CharacterLookup.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/CharacterLookup.java new file mode 100644 index 0000000..596ef08 --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/CharacterLookup.java @@ -0,0 +1,96 @@ +package net.tfminecraft.vehicleframework.ownership; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.entity.Player; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.VFLogger; + +/** TLibs character calls, with the typed-name lookup probed once (TLibs 2.5.0). */ +public final class CharacterLookup { + private static Optional findByName; + + private CharacterLookup() { + } + + /** The player's session, if it is current. */ + public static Optional session(Player player) { + if (player == null) { + return Optional.empty(); + } + return Characters.currentSession(player).filter(Characters::isCurrent); + } + + public static boolean isCurrent(CharacterSession session) { + return session != null && Characters.isCurrent(session); + } + + public static Optional info(UUID characterId) { + return characterId == null ? Optional.empty() : Characters.info(characterId); + } + + public static String displayName(Player player) { + return Characters.displayName(player); + } + + /** Whether typed character names can be looked up. */ + public static boolean typedNamesAvailable() { + return probe().isPresent(); + } + + /** Living characters whose real name is {@code name}; empty without the lookup. */ + public static List findLiving(String name) { + Optional method = probe(); + if (method.isEmpty() || name == null || name.isBlank()) { + return List.of(); + } + List living = new ArrayList<>(); + try { + Object result = method.get().invoke(null, name.trim()); + if (result instanceof List list) { + for (Object entry : list) { + if (entry instanceof CharacterInfo info && info.alive()) { + living.add(info); + } + } + } + } catch (ReflectiveOperationException | RuntimeException ex) { + VFLogger.log("Character name lookup failed: " + ex); + return List.of(); + } + return living; + } + + /** Probes once, logging the result. */ + public static synchronized Optional probe() { + if (findByName == null) { + findByName = probe(Characters.class); + if (findByName.isEmpty()) { + VFLogger.info("TLibs has no Characters.findByName (needs 2.5.0): typed whitelist names are off; " + + "the nearby picker still works."); + } + } + return findByName; + } + + static Optional probe(Class facade) { + try { + Method method = facade.getMethod("findByName", String.class); + return List.class.isAssignableFrom(method.getReturnType()) ? Optional.of(method) : Optional.empty(); + } catch (ReflectiveOperationException | LinkageError ex) { + return Optional.empty(); + } + } + + /** Replaces the probed lookup; for tests. */ + static synchronized void useLookup(Class facade) { + findByName = facade == null ? null : probe(facade); + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnerNames.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnerNames.java new file mode 100644 index 0000000..f13f573 --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnerNames.java @@ -0,0 +1,55 @@ +package net.tfminecraft.vehicleframework.ownership; + +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.vehicleframework.api.VehicleOwners; + +/** Names shown to players. Characters show their real name; staff also see the account. */ +public final class OwnerNames { + + private OwnerNames() { + } + + /** The name others see for an online player. */ + public static String player(Player player) { + return OwnershipMode.characters() ? CharacterLookup.displayName(player) : player.getName(); + } + + /** An owner value as text, such as "Kira" or, for staff, "Kira (Steve)". */ + public static String owner(String owner, boolean staff) { + if (VehicleOwners.isUnowned(owner)) { + return "nobody"; + } + if (owner.equalsIgnoreCase(VehicleOwners.NO_CHARACTER)) { + return "no leader"; + } + Optional characterId = VehicleOwners.characterId(owner); + if (characterId.isPresent()) { + Optional info = CharacterLookup.info(characterId.get()); + if (info.isEmpty()) { + return "an unknown character"; + } + String name = info.get().name(); + return staff ? name + " (" + account(info.get().ref().ownerId()) + ")" : name; + } + return legacy(owner); + } + + /** An account's last known name, or its UUID. */ + public static String account(UUID accountId) { + String name = Bukkit.getOfflinePlayer(accountId).getName(); + return name == null ? accountId.toString() : name; + } + + /** A legacy value without its {@code player_} prefix. */ + public static String legacy(String value) { + return value.regionMatches(true, 0, VehicleOwners.LEGACY_PREFIX, 0, VehicleOwners.LEGACY_PREFIX.length()) + ? value.substring(VehicleOwners.LEGACY_PREFIX.length()) + : value; + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccess.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccess.java new file mode 100644 index 0000000..c845e03 --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccess.java @@ -0,0 +1,49 @@ +package net.tfminecraft.vehicleframework.ownership; + +import java.util.Optional; + +import org.bukkit.entity.Player; + +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; +import net.tfminecraft.vehicleframework.data.OwnerData; + +/** Resolves players to owner identities. Characters mode asks the provider on every call. */ +public final class OwnershipAccess { + + private OwnershipAccess() { + } + + /** The player's identity, or {@code null} when ownership is disabled or no character is current. */ + public static AccessKey key(Player player) { + if (player == null) { + return null; + } + OwnershipIdentity identity = OwnershipMode.identity(); + if (identity == OwnershipIdentity.NAMES) { + return AccessKey.name(player.getName()); + } + if (identity == OwnershipIdentity.CHARACTERS) { + return session(player).map(session -> AccessKey.character(session.ref().characterId())).orElse(null); + } + return null; + } + + /** The current session in characters mode; empty otherwise. */ + public static Optional session(Player player) { + if (!OwnershipMode.characters()) { + return Optional.empty(); + } + return CharacterLookup.session(player); + } + + /** The owner value the player owns vehicles under. */ + public static Optional ownerValue(Player player) { + return Optional.ofNullable(key(player)).map(AccessKey::ownerValue); + } + + public static boolean isOwner(Player player, OwnerData data) { + AccessKey key = key(player); + return key != null && data != null && key.owns(data.getOwner()); + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipActions.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipActions.java new file mode 100644 index 0000000..7f912a9 --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipActions.java @@ -0,0 +1,193 @@ +package net.tfminecraft.vehicleframework.ownership; + +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; +import net.tfminecraft.vehicleframework.api.OwnershipReason; +import net.tfminecraft.vehicleframework.api.OwnershipValues; +import net.tfminecraft.vehicleframework.api.VehicleFrameworkApi.Result; +import net.tfminecraft.vehicleframework.api.VehicleOwners; +import net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority; +import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.events.VehicleOwnerClaimedEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangeEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipEditCheckEvent; +import net.tfminecraft.vehicleframework.permissions.Permissions; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +/** Checks and the write pipeline for ownership changes made by players. */ +public final class OwnershipActions { + public static final String AUTHORITY_PLUGIN = "SimpleFactions"; + + public static final String NEED_CHARACTER = "§cYou need an active character to do that."; + public static final String DISABLED = "§cVehicle ownership is turned off on this server."; + 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 SAVE_FAILED = "§cCould not save the change. Nothing was changed."; + + private OwnershipActions() { + } + + /** Who is acting: the account, its current character in characters mode, and the staff flag. */ + public record Actor(Player player, CharacterSession session, boolean staff) { + public CharacterRef ref() { + return session == null ? null : session.ref(); + } + + public AccessKey key() { + return session == null ? AccessKey.name(player.getName()) : AccessKey.character(session.ref().characterId()); + } + + /** The owner value this actor would own under. */ + public String ownerValue() { + return key().ownerValue(); + } + } + + /** The actor, or the refusal message: ownership disabled, or no current character. */ + public static Outcome actor(Player player) { + OwnershipIdentity identity = OwnershipMode.identity(); + if (identity == OwnershipIdentity.DISABLED) { + return Outcome.refused(DISABLED); + } + boolean staff = Permissions.isAdmin(player); + if (identity == OwnershipIdentity.NAMES) { + return Outcome.of(new Actor(player, null, staff)); + } + Optional session = CharacterLookup.session(player); + return session.isPresent() ? Outcome.of(new Actor(player, session.get(), staff)) : Outcome.refused(NEED_CHARACTER); + } + + /** + * Whether non-staff changes may go ahead: in characters mode, while SimpleFactions is installed, + * a ready {@link VehicleOwnershipAuthority} must be registered. + */ + public static boolean authorityAllows(Actor actor) { + if (actor.staff() || !OwnershipMode.characters()) { + return true; + } + if (Bukkit.getPluginManager().getPlugin(AUTHORITY_PLUGIN) == null) { + return true; + } + VehicleOwnershipAuthority authority = Bukkit.getServicesManager().load(VehicleOwnershipAuthority.class); + return authority != null && authority.ready(); + } + + /** Whether the actor owns the vehicle or is staff. */ + public static boolean controls(Actor actor, ActiveVehicle vehicle) { + return actor.staff() || actor.key().owns(vehicle.getOwnerData().getOwner()); + } + + /** + * Why the player may not make this change to the vehicle (for the tickets toggle, the ticket + * source), or empty if they may. Fires {@link VehicleOwnershipEditCheckEvent}. + */ + public static Optional refusal(Player player, ActiveVehicle vehicle, OwnershipReason reason) { + Outcome actor = actor(player); + if (actor.refusal() != null) { + return Optional.of(actor.refusal()); + } + if (!controls(actor.value(), vehicle)) { + return Optional.of(NOT_OWNER); + } + if (!authorityAllows(actor.value())) { + return Optional.of(UNAVAILABLE); + } + return editCheck(actor.value(), vehicle, reason) ? Optional.empty() : Optional.of(NOT_ALLOWED); + } + + public static boolean allowed(Player player, ActiveVehicle vehicle, OwnershipReason reason) { + return refusal(player, vehicle, reason).isEmpty(); + } + + /** + * Whether the player sees the ownership menu: the owner, or staff on an owned vehicle, unless an + * edit check denies it. + */ + public static boolean mayOpenMenu(Player player, ActiveVehicle vehicle) { + Outcome actor = actor(player); + if (actor.refusal() != null) { + return false; + } + String owner = vehicle.getOwnerData().getOwner(); + boolean owns = actor.value().key().owns(owner); + if (!owns && !(actor.value().staff() && !VehicleOwners.isUnowned(owner))) { + return false; + } + return editCheck(actor.value(), vehicle, null); + } + + private static boolean editCheck(Actor actor, ActiveVehicle vehicle, OwnershipReason reason) { + VehicleOwnershipEditCheckEvent check = + new VehicleOwnershipEditCheckEvent(actor.player(), vehicle, reason, actor.ref(), actor.staff()); + Bukkit.getPluginManager().callEvent(check); + return check.isAllowed(); + } + + /** One player action, from the pre-write event to the post-write event. */ + public record Change(Actor actor, ActiveVehicle vehicle, OwnershipReason reason, CharacterRef target, + String rawEntry, Consumer mutation) { + } + + /** + * Fires the pre-write event (and the legacy claim event for claims), writes, and always fires + * the post-write event under the same operation ID. + * + * @return the write result, or empty if a listener cancelled the change + */ + public static Optional perform(Change change) { + ActiveVehicle vehicle = change.vehicle(); + Actor actor = change.actor(); + OwnerData data = vehicle.getOwnerData(); + OwnershipValues before = OwnershipStore.values(data); + String previousOwner = data.getOwner(); + OwnerData preview = data.copy(); + change.mutation().accept(preview); + String newOwner = preview.getOwner(); + UUID operationId = UUID.randomUUID(); + Result result = null; + try { + VehicleOwnershipChangeEvent pre = new VehicleOwnershipChangeEvent(actor.player(), vehicle, change.reason(), + operationId, actor.ref(), actor.staff(), change.target(), change.rawEntry(), previousOwner, newOwner); + Bukkit.getPluginManager().callEvent(pre); + if (pre.isCancelled()) { + return Optional.empty(); + } + if (change.reason() == OwnershipReason.CLAIM) { + VehicleOwnerClaimedEvent legacy = + new VehicleOwnerClaimedEvent(actor.player(), vehicle, previousOwner, newOwner); + Bukkit.getPluginManager().callEvent(legacy); + if (legacy.isCancelled()) { + return Optional.empty(); + } + } + result = OwnershipStore.writeLive(vehicle, change.mutation()); + return Optional.of(result); + } finally { + Bukkit.getPluginManager().callEvent(new VehicleOwnershipChangedEvent(result == Result.OK, change.reason(), + operationId, OwnershipStore.parseUuid(vehicle.getUUID()).orElse(null), vehicle, actor.player(), + actor.ref(), actor.staff(), change.target(), change.rawEntry(), before, + OwnershipStore.values(data))); + } + } + + /** A value or a refusal message. */ + public record Outcome(T value, String refusal) { + static Outcome of(T value) { + return new Outcome<>(value, null); + } + + static Outcome refused(String refusal) { + return new Outcome<>(null, refusal); + } + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipMode.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipMode.java new file mode 100644 index 0000000..3e2671f --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipMode.java @@ -0,0 +1,58 @@ +package net.tfminecraft.vehicleframework.ownership; + +import java.util.Locale; + +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; + +/** The identity mode, read once at enable from {@code ownership-identity}. */ +public final class OwnershipMode { + public static final String CONFIG_KEY = "ownership-identity"; + public static final String CHARACTER_PLUGIN = "RPCharacters"; + + private static volatile OwnershipIdentity identity = OwnershipIdentity.NAMES; + + private OwnershipMode() { + } + + public static OwnershipIdentity identity() { + return identity; + } + + public static boolean characters() { + return identity == OwnershipIdentity.CHARACTERS; + } + + /** + * Sets the mode from the config value. A missing value means names mode, unless RPCharacters is + * installed, when ownership is refused until staff set the key. + */ + public static OwnershipIdentity configure(String value, boolean characterPluginPresent) { + String key = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + if (key.isEmpty()) { + if (characterPluginPresent) { + refuse(CONFIG_KEY + " is not set and " + CHARACTER_PLUGIN + " is installed."); + return identity; + } + identity = OwnershipIdentity.NAMES; + } else if (key.equals("characters")) { + identity = OwnershipIdentity.CHARACTERS; + } else if (key.equals("names")) { + identity = OwnershipIdentity.NAMES; + } else { + refuse(CONFIG_KEY + " has the unknown value '" + value + "'."); + return identity; + } + VFLogger.info("Vehicle ownership identity: " + identity.name().toLowerCase(Locale.ROOT)); + return identity; + } + + private static void refuse(String why) { + identity = OwnershipIdentity.DISABLED; + VFLogger.severe("============================================================"); + VFLogger.severe("VEHICLE OWNERSHIP IS DISABLED: " + why); + VFLogger.severe("Claims, whitelists, takeovers and ownership API writes are refused."); + VFLogger.severe("Set " + CONFIG_KEY + ": characters (or names) in config.yml and restart."); + VFLogger.severe("============================================================"); + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipStore.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipStore.java new file mode 100644 index 0000000..30eea1c --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/OwnershipStore.java @@ -0,0 +1,305 @@ +package net.tfminecraft.vehicleframework.ownership; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; + +import org.bukkit.Bukkit; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; + +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.api.OwnerChange; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; +import net.tfminecraft.vehicleframework.api.OwnershipMeta; +import net.tfminecraft.vehicleframework.api.OwnershipValues; +import net.tfminecraft.vehicleframework.api.SystemReason; +import net.tfminecraft.vehicleframework.api.VehicleFrameworkApi.Result; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.database.VehiclePersistence; +import net.tfminecraft.vehicleframework.database.VehicleRepository; +import net.tfminecraft.vehicleframework.database.VehicleSnapshot; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +/** Reads and writes ownership fields for loaded vehicles and stored rows. */ +public final class OwnershipStore { + + private OwnershipStore() { + } + + public static OwnershipValues values(OwnerData data) { + return new OwnershipValues(data.getOwner(), data.getWhiteList(), data.isWhiteListed(), + data.isTicketsEnabled(), data.getTicketId()); + } + + /** + * Applies the mutation to a loaded vehicle and saves it. A failed save, or a mutation that + * throws, puts every field back. + */ + public static Result writeLive(ActiveVehicle vehicle, Consumer mutation) { + OwnerData data = vehicle.getOwnerData(); + OwnerData before = data.copy(); + boolean saved; + try { + mutation.accept(data); + VehiclePersistence persistence = VehiclePersistence.current(); + saved = persistence != null && persistence.saveLive(vehicle); + } catch (RuntimeException ex) { + VFLogger.log("Ownership write failed for " + vehicle.getUUID() + ": " + ex); + saved = false; + } + if (!saved) { + data.restore(before); + VFLogger.log("Could not save the ownership change of " + vehicle.getUUID() + "; nothing changed"); + return Result.SAVE_FAILED; + } + return Result.OK; + } + + public static Result setOwnership(UUID vehicleId, OwnerChange change, SystemReason reason) { + Objects.requireNonNull(vehicleId, "vehicleId"); + Objects.requireNonNull(change, "change"); + Objects.requireNonNull(reason, "reason"); + requireMainThread(); + if (change.whitelist() != null && reason != SystemReason.MIGRATE) { + throw new IllegalArgumentException("Whitelist entries are only accepted with MIGRATE"); + } + if (OwnershipMode.identity() != OwnershipIdentity.CHARACTERS) { + return Result.REFUSED_MODE; + } + Consumer mutation = data -> applySystem(data, change, reason); + ActiveVehicle live = VehicleFramework.getVehicleManager().getByUUID(vehicleId.toString()); + if (live != null) { + if (live.isDestroyed()) { + return Result.NOT_FOUND; + } + OwnershipValues before = values(live.getOwnerData()); + Result result = writeLive(live, mutation); + if (result == Result.OK) { + fireWritten(reason, vehicleId, live, before, values(live.getOwnerData())); + } + return result; + } + StoredWrite stored = writeStored(vehicleId.toString(), mutation); + if (stored.result() == Result.OK) { + fireWritten(reason, vehicleId, null, stored.before(), stored.after()); + } + return stored.result(); + } + + static void applySystem(OwnerData data, OwnerChange change, SystemReason reason) { + data.setOwner(change.owner()); + switch (reason) { + case MIGRATE -> { + if (change.whitelist() != null) { + data.setWhiteList(new ArrayList<>(change.whitelist())); + } + } + case CONSTRUCT -> { + data.setWhiteList(new ArrayList<>()); + data.setWhiteListed(Cache.allowWhitelist && Cache.whitelistedByDefault); + } + case RELEASE, HANDOVER -> { + data.setWhiteList(new ArrayList<>()); + data.setWhiteListed(false); + } + case TRANSFER -> data.setWhiteList(new ArrayList<>()); + case RESTAMP -> { + } + } + } + + private static void fireWritten(SystemReason reason, UUID vehicleId, ActiveVehicle live, OwnershipValues before, + OwnershipValues after) { + Bukkit.getPluginManager().callEvent(new VehicleOwnershipChangedEvent(true, reason.reason(), + UUID.randomUUID(), vehicleId, live, null, null, false, null, null, before, after)); + } + + record StoredWrite(Result result, OwnershipValues before, OwnershipValues after) { + } + + /** Rewrites the owner column and the payload's owner fields of a stored row in one transaction. */ + @SuppressWarnings("unchecked") + static StoredWrite writeStored(String uuid, Consumer mutation) { + VehicleRepository repository = VehicleFramework.getVehicleRepository(); + if (repository == null) { + VFLogger.log("Cannot change the owner of " + uuid + ": SQLite is not open"); + return new StoredWrite(Result.SAVE_FAILED, null, null); + } + StoredWrite[] outcome = { new StoredWrite(Result.NOT_FOUND, null, null) }; + try { + repository.runInTransaction(() -> { + Optional existing = repository.findLive(uuid); + if (existing.isEmpty()) { + return; + } + VehicleSnapshot row = existing.get(); + JSONObject json = parse(row.getPayloadJson()); + OwnerData data = fromPayload(json); + OwnershipValues before = values(data); + mutation.accept(data); + json.put("owner", data.getOwner()); + json.put("whitelisted", data.isWhiteListed()); + JSONArray entries = new JSONArray(); + entries.addAll(data.getWhiteList()); + json.put("whitelist", entries); + VehicleSnapshot next = row.withPayload(json.toJSONString(), row.getName(), data.getOwner(), + System.currentTimeMillis()); + if (!repository.saveLive(next)) { + throw new IllegalStateException("SQL wrote 0 rows"); + } + outcome[0] = new StoredWrite(Result.OK, before, values(data)); + }); + } catch (RuntimeException ex) { + VFLogger.log("Could not change the stored owner of " + uuid + ": " + ex); + return new StoredWrite(Result.SAVE_FAILED, null, null); + } + return outcome[0]; + } + + public static List listOwnershipMeta() { + requireMainThread(); + VehicleRepository repository = openRepository(); + Map rows = new LinkedHashMap<>(); + for (VehicleSnapshot row : repository.listAllLive()) { + builder(rows, row.getUuid()).ifPresent(builder -> builder.stored(row)); + } + for (ActiveVehicle vehicle : VehicleFramework.getVehicleManager().get().values()) { + if (!vehicle.isDestroyed()) { + builder(rows, vehicle.getUUID()).ifPresent(builder -> builder.live(vehicle)); + } + } + List result = new ArrayList<>(); + for (MetaBuilder builder : rows.values()) { + result.add(builder.build()); + } + return result; + } + + public static Optional readOwnershipMeta(UUID vehicleId) { + Objects.requireNonNull(vehicleId, "vehicleId"); + requireMainThread(); + VehicleRepository repository = openRepository(); + MetaBuilder builder = new MetaBuilder(vehicleId); + repository.findLive(vehicleId.toString()).ifPresent(builder::stored); + ActiveVehicle live = VehicleFramework.getVehicleManager().getByUUID(vehicleId.toString()); + if (live != null && !live.isDestroyed()) { + builder.live(live); + } + return builder.empty() ? Optional.empty() : Optional.of(builder.build()); + } + + private static VehicleRepository openRepository() { + VehicleRepository repository = VehicleFramework.getVehicleRepository(); + if (repository == null) { + throw new IllegalStateException("VehicleFramework SQLite is not open"); + } + return repository; + } + + private static Optional builder(Map rows, String uuid) { + Optional id = parseUuid(uuid); + if (id.isEmpty()) { + VFLogger.log("Skipping vehicle with an unreadable ID in the ownership list: " + uuid); + return Optional.empty(); + } + return Optional.of(rows.computeIfAbsent(id.get().toString(), key -> new MetaBuilder(id.get()))); + } + + static Optional parseUuid(String uuid) { + try { + return uuid == null ? Optional.empty() : Optional.of(UUID.fromString(uuid.toLowerCase(Locale.ROOT))); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + } + + private static JSONObject parse(String payload) { + try { + Object parsed = new JSONParser().parse(payload); + if (parsed instanceof JSONObject json) { + return json; + } + throw new IllegalStateException("payload is not a JSON object"); + } catch (ParseException ex) { + throw new IllegalStateException("payload is not valid JSON", ex); + } + } + + static OwnerData fromPayload(JSONObject json) { + OwnerData data = new OwnerData(); + Object owner = json.get("owner"); + data.setOwner(owner == null ? "none" : String.valueOf(owner)); + data.setWhiteListed(Boolean.TRUE.equals(json.get("whitelisted"))); + List entries = new ArrayList<>(); + if (json.get("whitelist") instanceof JSONArray array) { + for (Object entry : array) { + if (entry != null) { + entries.add(String.valueOf(entry)); + } + } + } + data.setWhiteList(entries); + Object ticketId = json.get("ticketId"); + data.setTicketId(ticketId == null ? null : String.valueOf(ticketId)); + if (Boolean.TRUE.equals(json.get("ticketsEnabled"))) { + data.setTicketsEnabled(true); + } + return data; + } + + private static void requireMainThread() { + if (!Bukkit.isPrimaryThread()) { + throw new IllegalStateException("Vehicle ownership must be used on the main thread"); + } + } + + private static final class MetaBuilder { + private final UUID vehicleId; + private String typeId; + private String name; + private String storedOwnerColumn; + private Optional stored = Optional.empty(); + private Optional live = Optional.empty(); + + MetaBuilder(UUID vehicleId) { + this.vehicleId = vehicleId; + } + + void stored(VehicleSnapshot row) { + typeId = row.getTypeId(); + name = row.getName(); + storedOwnerColumn = row.getOwner(); + try { + stored = Optional.of(values(fromPayload(parse(row.getPayloadJson())))); + } catch (RuntimeException ex) { + VFLogger.log("Unreadable payload for vehicle " + row.getUuid() + ": " + ex.getMessage()); + } + } + + void live(ActiveVehicle vehicle) { + typeId = vehicle.getId(); + name = vehicle.getName(); + live = Optional.of(values(vehicle.getOwnerData())); + } + + boolean empty() { + return storedOwnerColumn == null && live.isEmpty(); + } + + OwnershipMeta build() { + return new OwnershipMeta(vehicleId, typeId, name, storedOwnerColumn, stored, live); + } + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/ownership/PendingActions.java b/src/main/java/net/tfminecraft/vehicleframework/ownership/PendingActions.java new file mode 100644 index 0000000..f86483f --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/ownership/PendingActions.java @@ -0,0 +1,94 @@ +package net.tfminecraft.vehicleframework.ownership; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +/** + * Chat and click actions waiting for the player's next input. Each records the session it started + * under (characters mode), expires after 60 seconds and ends with the session. + */ +public final class PendingActions { + public static final long TIMEOUT_TICKS = 1200L; + + public enum Kind { + WHITELIST_ADD("§cWhitelist entry timed out."), + RENAME("§cNaming timed out."), + TAKEOVER("§cTakeover expired."); + + private final String timeoutMessage; + + Kind(String timeoutMessage) { + this.timeoutMessage = timeoutMessage; + } + } + + /** + * @param session the session it started under, or {@code null} outside characters mode + * @param target the takeover's owner value and display name, or {@code null} + */ + public record Pending(Kind kind, ActiveVehicle vehicle, CharacterSession session, Target target, int taskId) { + } + + /** The owner a takeover writes, with the character and the name to report. */ + public record Target(String ownerValue, CharacterRef character, String name) { + } + + private record Key(UUID player, Kind kind) { + } + + private final Map pending = new ConcurrentHashMap<>(); + + public void start(Player player, Kind kind, ActiveVehicle vehicle, CharacterSession session, Target target) { + cancel(player, kind); + Key key = new Key(player.getUniqueId(), kind); + Pending[] started = new Pending[1]; + int taskId = Bukkit.getScheduler().runTaskLater(VehicleFramework.plugin, () -> { + if (pending.remove(key, started[0])) { + player.sendMessage(kind.timeoutMessage); + } + }, TIMEOUT_TICKS).getTaskId(); + started[0] = new Pending(kind, vehicle, session, target, taskId); + pending.put(key, started[0]); + } + + public boolean has(Player player, Kind kind) { + return pending.containsKey(new Key(player.getUniqueId(), kind)); + } + + /** Removes and returns the action, cancelling its timeout. */ + public Optional take(Player player, Kind kind) { + Pending removed = pending.remove(new Key(player.getUniqueId(), kind)); + if (removed != null) { + Bukkit.getScheduler().cancelTask(removed.taskId()); + } + return Optional.ofNullable(removed); + } + + public void cancel(Player player, Kind kind) { + take(player, kind); + } + + /** Drops every action of the player; returns whether there were any. */ + public boolean clear(Player player) { + boolean any = false; + for (Kind kind : Kind.values()) { + any |= take(player, kind).isPresent(); + } + return any; + } + + /** Whether the session the action started under is still being played. */ + public static boolean stillCurrent(Pending action) { + return action.session() == null || CharacterLookup.isCurrent(action.session()); + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRules.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRules.java index 5666bfe..81f9da8 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRules.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketRules.java @@ -2,27 +2,30 @@ import net.tfminecraft.vehicleframework.data.OwnerData; import net.tfminecraft.vehicleframework.enums.SeatType; +import net.tfminecraft.vehicleframework.ownership.AccessKey; public final class VehicleTicketRules { private VehicleTicketRules() { } + /** Names mode: owner and entries are account names. */ public static boolean ownerOrWhitelisted(OwnerData data, String playerName) { - if (data == null || playerName == null) { + return ownerOrWhitelisted(data, AccessKey.name(playerName)); + } + + /** A {@code null} key (no identity) matches nobody. */ + public static boolean ownerOrWhitelisted(OwnerData data, AccessKey key) { + if (data == null || key == null) { return false; } - String playerEntry = "player_" + playerName; - if (data.getOwner() != null && data.getOwner().equalsIgnoreCase(playerEntry)) { + if (key.owns(data.getOwner())) { return true; } if (data.getWhiteList() == null) { return false; } for (String entry : data.getWhiteList()) { - if (entry == null) { - continue; - } - if (entry.equalsIgnoreCase(playerEntry) || entry.equalsIgnoreCase(playerName)) { + if (key.matchesEntry(entry)) { return true; } } @@ -30,15 +33,23 @@ public static boolean ownerOrWhitelisted(OwnerData data, String playerName) { } public static boolean mayEnter(OwnerData access, OwnerData tickets, String playerName, SeatType seat, boolean hasMatchingTicket) { - return mayEnter(tickets.isTicketsEnabled(), seat, ownerOrWhitelisted(access, playerName), hasMatchingTicket); + return mayEnter(access, tickets, AccessKey.name(playerName), seat, hasMatchingTicket); + } + + public static boolean mayEnter(OwnerData access, OwnerData tickets, AccessKey key, SeatType seat, boolean hasMatchingTicket) { + return mayEnter(tickets.isTicketsEnabled(), seat, ownerOrWhitelisted(access, key), hasMatchingTicket); } public static boolean mayOpenSeatMenu(OwnerData access, String playerName, boolean hasMatchingTicket) { - return mayOpenSeatMenu(access, access, playerName, hasMatchingTicket); + return mayOpenSeatMenu(access, access, AccessKey.name(playerName), hasMatchingTicket); } - // Whitelists belong to the selected vehicle; coupled cars share only the locomotive's tickets. public static boolean mayOpenSeatMenu(OwnerData access, OwnerData tickets, String playerName, boolean hasMatchingTicket) { + return mayOpenSeatMenu(access, tickets, AccessKey.name(playerName), hasMatchingTicket); + } + + // Whitelists belong to the selected vehicle; coupled cars share only the locomotive's tickets. + public static boolean mayOpenSeatMenu(OwnerData access, OwnerData tickets, AccessKey key, boolean hasMatchingTicket) { if (access == null) { return true; } @@ -47,7 +58,7 @@ public static boolean mayOpenSeatMenu(OwnerData access, OwnerData tickets, Strin || access.getOwner().equalsIgnoreCase("none")) { return true; } - if (ownerOrWhitelisted(access, playerName)) { + if (ownerOrWhitelisted(access, key)) { return true; } return tickets != null && tickets.isTicketsEnabled() && hasMatchingTicket; diff --git a/src/test/java/net/tfminecraft/vehicleframework/api/OwnershipValuesTest.java b/src/test/java/net/tfminecraft/vehicleframework/api/OwnershipValuesTest.java new file mode 100644 index 0000000..b699170 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/api/OwnershipValuesTest.java @@ -0,0 +1,70 @@ +package net.tfminecraft.vehicleframework.api; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.tlibs.character.CharacterRef; + +class OwnershipValuesTest { + private static final UUID CHARACTER = UUID.fromString("0b1f2e9c-6a3d-4c55-8a7e-2f1d9c3b4a51"); + + @Test + void valuesDefaultTheOwnerAndKeepAnImmutableCopyOfTheEntries() { + List entries = new ArrayList<>(Arrays.asList("a", null, "b")); + OwnershipValues values = new OwnershipValues(" ", entries, true, true, "ticket"); + entries.add("c"); + assertEquals("none", values.owner()); + assertEquals(List.of("a", "b"), values.whitelist()); + assertThrows(UnsupportedOperationException.class, () -> values.whitelist().add("x")); + assertEquals(List.of(), new OwnershipValues(null, null, false, false, null).whitelist()); + assertEquals("none", new OwnershipValues(null, null, false, false, null).owner()); + } + + @Test + void metaKeepsStoredAndLiveApartAndPrefersLiveForTheCurrentView() { + OwnershipValues stored = new OwnershipValues("char_none", List.of(), false, false, null); + OwnershipValues live = new OwnershipValues("none", List.of(), false, false, null); + OwnershipMeta both = new OwnershipMeta(CHARACTER, "cart", "Cart", "char_none", Optional.of(stored), Optional.of(live)); + assertTrue(both.hasStoredRow()); assertTrue(both.isLive()); assertSame(live, both.current().orElseThrow()); + OwnershipMeta storedOnly = new OwnershipMeta(CHARACTER, "cart", "Cart", "char_none", Optional.of(stored), null); + assertFalse(storedOnly.isLive()); assertSame(stored, storedOnly.current().orElseThrow()); + OwnershipMeta liveOnly = new OwnershipMeta(CHARACTER, "cart", "Cart", null, null, Optional.of(live)); + assertFalse(liveOnly.hasStoredRow()); assertTrue(liveOnly.stored().isEmpty()); + assertThrows(NullPointerException.class, () -> new OwnershipMeta(null, "cart", "Cart", null, null, null)); + } + + @Test + void ownerChangesAcceptOnlyCharacterModeValuesInCanonicalForm() { + assertEquals("char_" + CHARACTER, OwnerChange.character(CHARACTER).owner()); + assertEquals("char_none", OwnerChange.noCharacter().owner()); + assertEquals("none", OwnerChange.unowned().owner()); + assertNull(OwnerChange.unowned().whitelist()); + assertEquals("char_" + CHARACTER, new OwnerChange("CHAR_" + CHARACTER.toString().toUpperCase(), null).owner()); + List seed = new ArrayList<>(List.of("player_Old")); + OwnerChange seeded = OwnerChange.seeded("char_" + CHARACTER, seed); + seed.clear(); + assertEquals(List.of("player_Old"), seeded.whitelist()); + IllegalArgumentException refused = assertThrows(IllegalArgumentException.class, () -> new OwnerChange("player_Alex", null)); + assertTrue(refused.getMessage().contains("player_Alex")); + assertThrows(NullPointerException.class, () -> new OwnerChange(null, null)); + assertThrows(NullPointerException.class, () -> OwnerChange.seeded("none", null)); + } + + @Test + void entriesAndQuarantineRowsNormaliseMissingText() { + CharacterRef ref = new CharacterRef(UUID.randomUUID(), CHARACTER); + assertEquals("", new WhitelistEntry(ref, null).name()); + assertThrows(NullPointerException.class, () -> new WhitelistEntry(null, "Kira")); + QuarantinedVehicle row = new QuarantinedVehicle(CHARACTER, null, null); + assertEquals("", row.owner()); assertEquals("", row.reason()); + assertThrows(NullPointerException.class, () -> new QuarantinedVehicle(null, "a", "b")); + assertTrue(new VehicleOwnershipAuthority() { }.ready()); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/api/VehicleOwnersTest.java b/src/test/java/net/tfminecraft/vehicleframework/api/VehicleOwnersTest.java new file mode 100644 index 0000000..de1b26e --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/api/VehicleOwnersTest.java @@ -0,0 +1,93 @@ +package net.tfminecraft.vehicleframework.api; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.tlibs.character.CharacterRef; + +class VehicleOwnersTest { + private static final UUID CHARACTER = UUID.fromString("0b1f2e9c-6a3d-4c55-8a7e-2f1d9c3b4a51"); + private static final UUID ACCOUNT = UUID.fromString("5d7a9e2b-1c4f-4a3b-9e8d-7c6b5a4f3e21"); + + @Test + void characterOwnersRoundTripAndRejectLookalikes() { + String owner = VehicleOwners.character(CHARACTER); + assertEquals("char_" + CHARACTER, owner); + assertEquals(Optional.of(CHARACTER), VehicleOwners.characterId(owner)); + assertEquals(Optional.of(CHARACTER), VehicleOwners.characterId("CHAR_" + CHARACTER.toString().toUpperCase())); + for (String other : Arrays.asList(null, "none", "char_none", "char_", "char_1-1-1-1-1", + "char_" + CHARACTER + "x", "char_" + CHARACTER.toString().replace('-', 'x'), "player_" + CHARACTER)) { + assertTrue(VehicleOwners.characterId(other).isEmpty(), String.valueOf(other)); + assertFalse(VehicleOwners.isCharacterOwner(other)); + } + assertThrows(NullPointerException.class, () -> VehicleOwners.character(null)); + } + + @Test + void unownedLegacyAndPersonalOwnersFollowTheOwnerTable() { + for (String unowned : Arrays.asList(null, "", " ", "none", "NONE")) assertTrue(VehicleOwners.isUnowned(unowned)); + for (String owned : List.of("char_none", "player_none", "player_Alex", VehicleOwners.character(CHARACTER))) { + assertFalse(VehicleOwners.isUnowned(owned), owned); + } + assertTrue(VehicleOwners.isLegacyOwner("PLAYER_Alex")); + for (String notLegacy : Arrays.asList(null, "player_", "player_none", "char_none", "Alex")) { + assertFalse(VehicleOwners.isLegacyOwner(notLegacy), String.valueOf(notLegacy)); + } + assertTrue(VehicleOwners.isPersonalOwner("player_Alex")); + assertTrue(VehicleOwners.isPersonalOwner(VehicleOwners.character(CHARACTER))); + assertFalse(VehicleOwners.isPersonalOwner("char_none")); + assertFalse(VehicleOwners.isPersonalOwner("none")); + } + + @Test + void onlyCharacterModeValuesHaveACanonicalForm() { + assertEquals(Optional.of("none"), VehicleOwners.canonical("None")); + assertEquals(Optional.of("char_none"), VehicleOwners.canonical("CHAR_NONE")); + assertEquals(Optional.of("char_" + CHARACTER), VehicleOwners.canonical("Char_" + CHARACTER.toString().toUpperCase())); + for (String other : Arrays.asList(null, "", "player_Alex", "player_none", "faction_1")) { + assertTrue(VehicleOwners.canonical(other).isEmpty(), String.valueOf(other)); + } + } + + @Test + void whitelistEntriesCarryTheCharacterAndTheCapturedName() { + CharacterRef ref = new CharacterRef(ACCOUNT, CHARACTER); + String entry = VehicleOwners.whitelistEntry(ref, "Kira; of the Vale"); + assertEquals("char_" + CHARACTER + ";" + ACCOUNT + ";Kira; of the Vale", entry); + WhitelistEntry parsed = VehicleOwners.parseWhitelistEntry(entry).orElseThrow(); + assertEquals(ref, parsed.character()); + assertEquals("Kira; of the Vale", parsed.name()); + assertEquals(entry, parsed.encode()); + assertEquals("", VehicleOwners.parseWhitelistEntry(VehicleOwners.whitelistEntry(ref, null)).orElseThrow().name()); + assertThrows(NullPointerException.class, () -> VehicleOwners.whitelistEntry(null, "Kira")); + } + + @Test + void legacyAndDamagedWhitelistEntriesAreNotCharacters() { + List inert = new ArrayList<>(Arrays.asList(null, "player_Alex", "Alex", "char_none", + "char_" + CHARACTER, "char_" + CHARACTER + ";" + ACCOUNT, + "char_" + CHARACTER + ";not-a-uuid;Kira", "char_bad;" + ACCOUNT + ";Kira")); + for (String entry : inert) { + assertTrue(VehicleOwners.parseWhitelistEntry(entry).isEmpty(), String.valueOf(entry)); + } + } + + @Test + void reasonsKnowWhichAreSystemWrites() { + for (OwnershipReason reason : OwnershipReason.values()) { + boolean system = List.of("MIGRATE", "RESTAMP", "RELEASE", "HANDOVER", "CONSTRUCT", "TRANSFER").contains(reason.name()); + assertEquals(system, reason.isSystem(), reason.name()); + } + for (SystemReason reason : SystemReason.values()) { + assertEquals(reason.name(), reason.reason().name()); + assertTrue(reason.reason().isSystem()); + } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePersistenceTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePersistenceTest.java index 0ba6ec7..39bbbca 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePersistenceTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/database/VehiclePersistenceTest.java @@ -172,6 +172,26 @@ void ownerQueriesUseColumns() { } } + @Test + void personalOwnersAreCharactersOrLegacyNamesButNeverPlaceholders() { + VehicleRepository repository = VehicleRepository.open(tempDir.resolve("vehicles.db").toFile()); + try { + String character = "char_" + java.util.UUID.randomUUID(); + List owners = List.of(character, "player_Alice", "CHAR_NONE", "player_none", "none", "playerXAlice", + "charXnone", "char_short"); + for (int i = 0; i < owners.size(); i++) { + repository.saveLive(new VehicleSnapshot("v" + i, "horse_cart", "Cart", owners.get(i), "world", 0, 64, 0, 0, 0, 0, + PAYLOAD, 2, 1, false, 1)); + } + List personal = new VehiclePersistence(repository).listPlayerOwned().stream() + .map(net.tfminecraft.vehicleframework.data.StoredVehicleMeta::getOwner).sorted().toList(); + assertEquals(List.of(character, "player_Alice").stream().sorted().toList(), personal); + assertEquals(1, new VehiclePersistence(repository).listByOwner(character.toUpperCase()).size()); + } finally { + repository.close(); + } + } + @Test void payloadMutationBumpsRevision() { VehicleRepository repository = VehicleRepository.open(tempDir.resolve("vehicles.db").toFile()); diff --git a/src/test/java/net/tfminecraft/vehicleframework/events/OwnershipEventsTest.java b/src/test/java/net/tfminecraft/vehicleframework/events/OwnershipEventsTest.java new file mode 100644 index 0000000..02d94d8 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/events/OwnershipEventsTest.java @@ -0,0 +1,76 @@ +package net.tfminecraft.vehicleframework.events; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.vehicleframework.api.OwnershipReason; +import net.tfminecraft.vehicleframework.api.OwnershipValues; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class OwnershipEventsTest { + private final Player player = mock(Player.class); + private final ActiveVehicle vehicle = mock(ActiveVehicle.class); + private final CharacterRef actor = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + private final CharacterRef target = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + + @Test + void preWriteEventCarriesTheActionAndCanBeCancelled() { + UUID operation = UUID.randomUUID(); + VehicleOwnershipChangeEvent event = new VehicleOwnershipChangeEvent(player, vehicle, OwnershipReason.WHITELIST_ADD, + operation, actor, true, target, "char_x", "char_a", "char_a"); + assertSame(player, event.getPlayer()); assertSame(vehicle, event.getVehicle()); + assertEquals(OwnershipReason.WHITELIST_ADD, event.getReason()); assertEquals(operation, event.getOperationId()); + assertEquals(actor, event.getActor().orElseThrow()); assertTrue(event.isStaff()); + assertEquals(target, event.getTarget().orElseThrow()); assertEquals("char_x", event.getRawEntry().orElseThrow()); + assertEquals("char_a", event.getPreviousOwner()); assertEquals("char_a", event.getNewOwner()); + assertFalse(event.isCancelled()); event.setCancelled(true); assertTrue(event.isCancelled()); + assertSame(VehicleOwnershipChangeEvent.getHandlerList(), event.getHandlers()); + VehicleOwnershipChangeEvent names = new VehicleOwnershipChangeEvent(player, vehicle, OwnershipReason.CLAIM, + operation, null, false, null, null, "none", "player_Alex"); + assertTrue(names.getActor().isEmpty()); assertTrue(names.getTarget().isEmpty()); assertTrue(names.getRawEntry().isEmpty()); + assertThrows(NullPointerException.class, () -> new VehicleOwnershipChangeEvent(null, vehicle, OwnershipReason.CLAIM, + operation, null, false, null, null, "none", "none")); + } + + @Test + void postWriteEventReportsOutcomeAndBothValueSets() { + OwnershipValues before = new OwnershipValues("none", List.of(), false, false, null); + OwnershipValues after = new OwnershipValues("char_a", List.of(), false, false, null); + UUID operation = UUID.randomUUID(), id = UUID.randomUUID(); + VehicleOwnershipChangedEvent event = new VehicleOwnershipChangedEvent(true, OwnershipReason.CLAIM, operation, id, + vehicle, player, actor, false, target, "entry", before, after); + assertTrue(event.isSuccess()); assertEquals(OwnershipReason.CLAIM, event.getReason()); + assertEquals(operation, event.getOperationId()); assertEquals(id, event.getVehicleId()); + assertSame(vehicle, event.getVehicle().orElseThrow()); assertSame(player, event.getPlayer().orElseThrow()); + assertEquals(actor, event.getActor().orElseThrow()); assertFalse(event.isStaff()); + assertEquals(target, event.getTarget().orElseThrow()); assertEquals("entry", event.getRawEntry().orElseThrow()); + assertSame(before, event.getPrevious()); assertSame(after, event.getCurrent()); + assertSame(VehicleOwnershipChangedEvent.getHandlerList(), event.getHandlers()); + VehicleOwnershipChangedEvent system = new VehicleOwnershipChangedEvent(false, OwnershipReason.MIGRATE, operation, + id, null, null, null, false, null, null, before, before); + assertFalse(system.isSuccess()); assertTrue(system.getVehicle().isEmpty()); assertTrue(system.getPlayer().isEmpty()); + assertTrue(system.getActor().isEmpty()); assertTrue(system.getTarget().isEmpty()); assertTrue(system.getRawEntry().isEmpty()); + assertThrows(NullPointerException.class, () -> new VehicleOwnershipChangedEvent(true, null, operation, id, + null, null, null, false, null, null, before, after)); + } + + @Test + void editCheckIsAllowedUntilAListenerDeniesIt() { + VehicleOwnershipEditCheckEvent menu = new VehicleOwnershipEditCheckEvent(player, vehicle, null, null, true); + assertTrue(menu.isMenu()); assertTrue(menu.getReason().isEmpty()); assertTrue(menu.getActor().isEmpty()); + assertTrue(menu.isStaff()); assertSame(player, menu.getPlayer()); assertSame(vehicle, menu.getVehicle()); + assertTrue(menu.isAllowed()); menu.deny(); assertFalse(menu.isAllowed()); + VehicleOwnershipEditCheckEvent toggle = new VehicleOwnershipEditCheckEvent(player, vehicle, OwnershipReason.TICKETS_TOGGLE, actor, false); + assertFalse(toggle.isMenu()); assertEquals(OwnershipReason.TICKETS_TOGGLE, toggle.getReason().orElseThrow()); + assertEquals(actor, toggle.getActor().orElseThrow()); assertFalse(toggle.isStaff()); + assertSame(VehicleOwnershipEditCheckEvent.getHandlerList(), toggle.getHandlers()); + assertThrows(NullPointerException.class, () -> new VehicleOwnershipEditCheckEvent(player, null, null, null, false)); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/CharacterLookupTest.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/CharacterLookupTest.java new file mode 100644 index 0000000..6105876 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/CharacterLookupTest.java @@ -0,0 +1,117 @@ +package net.tfminecraft.vehicleframework.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.VFLogger; + +class CharacterLookupTest { + private MockedStatic characters; + private MockedStatic log; + + /** Stands in for TLibs 2.5.0's facade. */ + public static final class WithFindByName { + static final List results = new ArrayList<>(); + static String asked; + + public static List findByName(String name) { + asked = name; + if (name.equals("boom")) throw new IllegalStateException("provider failed"); + return results; + } + } + + /** A same-named method with the wrong return type is not the lookup. */ + public static final class WrongReturn { + public static String findByName(String name) { + return name; + } + } + + static CharacterInfo info(String name, CharacterStatus status) { + return new CharacterInfo(new CharacterRef(UUID.randomUUID(), UUID.randomUUID()), name, status, null, Set.of(), 0, Instant.EPOCH); + } + + @BeforeEach + void setup() { + characters = mockStatic(Characters.class); + log = mockStatic(VFLogger.class); + WithFindByName.results.clear(); + } + + @AfterEach + void cleanup() { + OwnershipFixture.lookup(null); + log.close(); + characters.close(); + } + + @Test + void sessionsCountOnlyWhileTheProviderSaysTheyAreCurrent() { + Player player = mock(Player.class); + CharacterSession session = OwnershipFixture.session("acct", "kira", 1); + assertTrue(CharacterLookup.session(null).isEmpty()); + assertTrue(CharacterLookup.session(player).isEmpty()); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of(session)); + assertTrue(CharacterLookup.session(player).isEmpty()); + characters.when(() -> Characters.isCurrent(session)).thenReturn(true); + assertEquals(session, CharacterLookup.session(player).orElseThrow()); + assertTrue(CharacterLookup.isCurrent(session)); + assertFalse(CharacterLookup.isCurrent(null)); + } + + @Test + void infoAndDisplayNamesComeFromTheProvider() { + Player player = mock(Player.class); + CharacterInfo kira = info("Kira", CharacterStatus.ALIVE); + characters.when(() -> Characters.info(kira.ref().characterId())).thenReturn(Optional.of(kira)); + characters.when(() -> Characters.displayName(player)).thenReturn("Masked"); + assertSame(kira, CharacterLookup.info(kira.ref().characterId()).orElseThrow()); + assertTrue(CharacterLookup.info(null).isEmpty()); + assertEquals("Masked", CharacterLookup.displayName(player)); + } + + @Test + void typedNamesAreOffWithoutFindByNameAndTheProbeIsLoggedOnce() { + OwnershipFixture.lookup(null); + assertFalse(CharacterLookup.typedNamesAvailable()); + assertFalse(CharacterLookup.typedNamesAvailable()); + log.verify(() -> VFLogger.info(contains("typed whitelist names are off")), times(1)); + assertTrue(CharacterLookup.findLiving("Kira").isEmpty()); + OwnershipFixture.lookup(WrongReturn.class); + assertFalse(CharacterLookup.typedNamesAvailable()); + } + + @Test + void typedLookupKeepsOnlyLivingCharactersAndSurvivesProviderFailures() { + OwnershipFixture.lookup(WithFindByName.class); + assertTrue(CharacterLookup.typedNamesAvailable()); + CharacterInfo alive = info("Kira", CharacterStatus.ALIVE), dead = info("Kira", CharacterStatus.DEAD); + WithFindByName.results.addAll(List.of(alive, dead, "not a character")); + assertEquals(List.of(alive), CharacterLookup.findLiving(" Kira ")); + assertEquals("Kira", WithFindByName.asked); + assertTrue(CharacterLookup.findLiving(" ").isEmpty()); + assertTrue(CharacterLookup.findLiving(null).isEmpty()); + assertTrue(CharacterLookup.findLiving("boom").isEmpty()); + log.verify(() -> VFLogger.log(contains("Character name lookup failed"))); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnerNamesTest.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnerNamesTest.java new file mode 100644 index 0000000..39dd241 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnerNamesTest.java @@ -0,0 +1,68 @@ +package net.tfminecraft.vehicleframework.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; +import java.util.Set; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; +import net.tfminecraft.vehicleframework.api.VehicleOwners; + +class OwnerNamesTest { + private MockedStatic bukkit; + private MockedStatic characters; + private final CharacterSession kira = OwnershipFixture.session("steve", "kira", 1); + + @BeforeEach + void setup() { + bukkit = mockStatic(Bukkit.class); + characters = mockStatic(Characters.class); + } + + @AfterEach + void cleanup() { + characters.close(); bukkit.close(); + OwnershipFixture.mode(OwnershipIdentity.NAMES); + } + + @Test + void ownersReadAsCharacterNamesWithTheAccountForStaffOnly() { + String owner = VehicleOwners.character(kira.ref().characterId()); + assertEquals("an unknown character", OwnerNames.owner(owner, false)); + characters.when(() -> Characters.info(kira.ref().characterId())).thenReturn(Optional.of( + new CharacterInfo(kira.ref(), "Kira", CharacterStatus.DEAD, null, Set.of(), 0, null))); + assertEquals("Kira", OwnerNames.owner(owner, false)); + OfflinePlayer account = mock(OfflinePlayer.class); + bukkit.when(() -> Bukkit.getOfflinePlayer(kira.ref().ownerId())).thenReturn(account); + assertEquals("Kira (" + kira.ref().ownerId() + ")", OwnerNames.owner(owner, true)); + when(account.getName()).thenReturn("Steve"); + assertEquals("Kira (Steve)", OwnerNames.owner(owner, true)); + assertEquals("nobody", OwnerNames.owner("none", true)); + assertEquals("no leader", OwnerNames.owner("CHAR_NONE", true)); + assertEquals("Steve", OwnerNames.owner("Player_Steve", false)); + assertEquals("faction_1", OwnerNames.owner("faction_1", false)); + } + + @Test + void onlinePlayersShowTheirDisplayNameInCharactersMode() { + Player player = mock(Player.class); + when(player.getName()).thenReturn("Steve"); + characters.when(() -> Characters.displayName(player)).thenReturn("Hooded figure"); + assertEquals("Steve", OwnerNames.player(player)); + OwnershipFixture.mode(OwnershipIdentity.CHARACTERS); + assertEquals("Hooded figure", OwnerNames.player(player)); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccessTest.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccessTest.java new file mode 100644 index 0000000..29da9da --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipAccessTest.java @@ -0,0 +1,132 @@ +package net.tfminecraft.vehicleframework.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; +import net.tfminecraft.vehicleframework.api.VehicleOwners; +import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.enums.SeatType; +import net.tfminecraft.vehicleframework.vehicles.handlers.VehicleTicketRules; + +class OwnershipAccessTest { + private MockedStatic characters; + private final Player alex = mock(Player.class); + private final CharacterSession kira = OwnershipFixture.session("alex", "kira", 1); + private final CharacterSession bren = OwnershipFixture.session("alex", "bren", 2); + + @BeforeEach + void setup() { + characters = mockStatic(Characters.class); + when(alex.getName()).thenReturn("Alex"); + } + + @AfterEach + void cleanup() { + characters.close(); + OwnershipFixture.mode(OwnershipIdentity.NAMES); + } + + private void plays(CharacterSession session) { + characters.when(() -> Characters.currentSession(alex)).thenReturn(Optional.of(session)); + characters.when(() -> Characters.isCurrent(session)).thenReturn(true); + } + + @Test + void namesModeKeepsAccountNameOwnershipAndLegacyBareEntries() { + OwnerData data = new OwnerData(); + data.setOwner("PLAYER_alex"); + assertTrue(OwnershipAccess.isOwner(alex, data)); + assertEquals("player_Alex", OwnershipAccess.ownerValue(alex).orElseThrow()); + assertTrue(OwnershipAccess.session(alex).isEmpty()); + data.setOwner("char_" + kira.ref().characterId()); + assertFalse(OwnershipAccess.isOwner(alex, data)); + data.setWhiteList(Arrays.asList("alex", VehicleOwners.whitelistEntry(kira.ref(), "Kira"))); + assertTrue(VehicleTicketRules.ownerOrWhitelisted(data, OwnershipAccess.key(alex))); + characters.verifyNoInteractions(); + assertNull(OwnershipAccess.key(null)); + assertFalse(OwnershipAccess.isOwner(alex, null)); + } + + @Test + void charactersModeMatchesTheCurrentCharacterAndTreatsItsSiblingAsAStranger() { + OwnershipFixture.mode(OwnershipIdentity.CHARACTERS); + OwnerData data = new OwnerData(); + data.setOwner(VehicleOwners.character(kira.ref().characterId())); + data.setWhiteListed(true); + plays(kira); + assertTrue(OwnershipAccess.isOwner(alex, data)); + assertEquals(kira, OwnershipAccess.session(alex).orElseThrow()); + plays(bren); + assertFalse(OwnershipAccess.isOwner(alex, data)); + assertFalse(VehicleTicketRules.mayOpenSeatMenu(data, data, OwnershipAccess.key(alex), false)); + data.setWhiteList(List.of(VehicleOwners.whitelistEntry(bren.ref(), "Bren"))); + assertTrue(VehicleTicketRules.mayOpenSeatMenu(data, data, OwnershipAccess.key(alex), false)); + } + + @Test + void charactersModeNeverMatchesLegacyEntriesOrTheNoLeaderStamp() { + OwnershipFixture.mode(OwnershipIdentity.CHARACTERS); + plays(kira); + OwnerData data = new OwnerData(); + data.setOwner("player_Alex"); + data.setWhiteListed(true); + data.setWhiteList(List.of("player_Alex", "Alex")); + assertFalse(OwnershipAccess.isOwner(alex, data)); + assertFalse(VehicleTicketRules.ownerOrWhitelisted(data, OwnershipAccess.key(alex))); + data.setOwner(VehicleOwners.NO_CHARACTER); + assertFalse(VehicleTicketRules.mayOpenSeatMenu(data, data, OwnershipAccess.key(alex), false)); + } + + @Test + void charactersModeFailsClosedWithoutAProviderOrSession() { + OwnershipFixture.mode(OwnershipIdentity.CHARACTERS); + OwnerData data = new OwnerData(); + data.setOwner(VehicleOwners.character(kira.ref().characterId())); + data.setWhiteListed(true); + characters.when(() -> Characters.currentSession(alex)).thenReturn(Optional.empty()); + assertNull(OwnershipAccess.key(alex)); + assertTrue(OwnershipAccess.ownerValue(alex).isEmpty()); + assertFalse(OwnershipAccess.isOwner(alex, data)); + assertFalse(VehicleTicketRules.mayOpenSeatMenu(data, data, OwnershipAccess.key(alex), false)); + data.setTicketsEnabled(true); + assertTrue(VehicleTicketRules.mayOpenSeatMenu(data, data, OwnershipAccess.key(alex), true)); + assertFalse(VehicleTicketRules.mayEnter(data, data, OwnershipAccess.key(alex), SeatType.PASSENGER, false)); + characters.when(() -> Characters.currentSession(alex)).thenReturn(Optional.of(kira)); + assertNull(OwnershipAccess.key(alex), "a session the provider no longer calls current does not count"); + } + + @Test + void disabledModeHasNoIdentityAndUnownedVehiclesStayOpen() { + OwnershipFixture.mode(OwnershipIdentity.DISABLED); + OwnerData data = new OwnerData(); + data.setWhiteListed(true); + assertNull(OwnershipAccess.key(alex)); + assertTrue(VehicleTicketRules.mayOpenSeatMenu(data, data, OwnershipAccess.key(alex), false)); + data.setOwner("player_Alex"); + assertFalse(VehicleTicketRules.mayOpenSeatMenu(data, data, OwnershipAccess.key(alex), false)); + characters.verifyNoInteractions(); + } + + @Test + void accessKeysCompareOwnersWithoutCase() { + AccessKey key = AccessKey.character(kira.ref().characterId()); + assertTrue(key.owns(("CHAR_" + kira.ref().characterId()).toUpperCase())); + assertFalse(key.owns(null)); + assertFalse(key.matchesEntry(null)); + assertNull(AccessKey.name(null)); + assertFalse(AccessKey.name("Alex").matchesEntry(null)); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipActionsTest.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipActionsTest.java new file mode 100644 index 0000000..3950185 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipActionsTest.java @@ -0,0 +1,256 @@ +package net.tfminecraft.vehicleframework.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.ServicesManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; +import net.tfminecraft.vehicleframework.api.OwnershipReason; +import net.tfminecraft.vehicleframework.api.VehicleFrameworkApi.Result; +import net.tfminecraft.vehicleframework.api.VehicleOwners; +import net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority; +import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.database.VehiclePersistence; +import net.tfminecraft.vehicleframework.events.VehicleOwnerClaimedEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangeEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipEditCheckEvent; +import net.tfminecraft.vehicleframework.ownership.OwnershipActions.Actor; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class OwnershipActionsTest { + private final List scopes = new ArrayList<>(); + private final PluginManager plugins = mock(PluginManager.class); + private final ServicesManager services = mock(ServicesManager.class); + private final VehiclePersistence persistence = mock(VehiclePersistence.class); + private final List events = new ArrayList<>(); + private final Player alex = mock(Player.class), staff = mock(Player.class); + private final CharacterSession kira = OwnershipFixture.session("alex", "kira", 1); + private final CharacterSession admin = OwnershipFixture.session("staff", "warden", 2); + private final ActiveVehicle vehicle = mock(ActiveVehicle.class); + private final OwnerData data = new OwnerData(); + private MockedStatic characters; + + @BeforeEach + void setup() { + MockedStatic bukkit = keep(mockStatic(Bukkit.class)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getServicesManager).thenReturn(services); + doAnswer(call -> events.add(call.getArgument(0))).when(plugins).callEvent(any(Event.class)); + characters = keep(mockStatic(Characters.class)); + keep(mockStatic(VFLogger.class)); + keep(mockStatic(VehiclePersistence.class)).when(VehiclePersistence::current).thenReturn(persistence); + when(alex.getName()).thenReturn("Alex"); + when(staff.getName()).thenReturn("Warden"); + when(staff.hasPermission("vf.admin")).thenReturn(true); + plays(alex, kira); plays(staff, admin); + when(vehicle.getOwnerData()).thenReturn(data); + when(vehicle.getUUID()).thenReturn("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); + when(persistence.saveLive(vehicle)).thenReturn(true); + OwnershipFixture.mode(OwnershipIdentity.CHARACTERS); + } + + @AfterEach + void cleanup() throws Exception { + for (int i = scopes.size() - 1; i >= 0; i--) scopes.get(i).close(); + OwnershipFixture.mode(OwnershipIdentity.NAMES); + } + + private T keep(T scope) { + scopes.add(scope); + return scope; + } + + private void plays(Player player, CharacterSession session) { + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.ofNullable(session)); + if (session != null) characters.when(() -> Characters.isCurrent(session)).thenReturn(true); + } + + private void ownedBy(CharacterSession session) { + data.setOwner(VehicleOwners.character(session.ref().characterId())); + } + + private void factionsInstalled(VehicleOwnershipAuthority authority) { + when(plugins.getPlugin("SimpleFactions")).thenReturn(mock(Plugin.class)); + when(services.load(VehicleOwnershipAuthority.class)).thenReturn(authority); + } + + private List fired(Class type) { + return events.stream().filter(type::isInstance).map(type::cast).toList(); + } + + @Test + void actorsNeedACurrentCharacterInCharactersModeAndCarryTheStaffFlag() { + Actor actor = OwnershipActions.actor(alex).value(); + assertEquals(kira, actor.session()); assertEquals(kira.ref(), actor.ref()); assertFalse(actor.staff()); + assertEquals("char_" + kira.ref().characterId(), actor.ownerValue()); + assertTrue(OwnershipActions.actor(staff).value().staff()); + plays(alex, null); + assertEquals(OwnershipActions.NEED_CHARACTER, OwnershipActions.actor(alex).refusal()); + plays(staff, null); + assertEquals(OwnershipActions.NEED_CHARACTER, OwnershipActions.actor(staff).refusal(), "staff need a character too"); + OwnershipFixture.mode(OwnershipIdentity.NAMES); + Actor named = OwnershipActions.actor(alex).value(); + assertNull(named.session()); assertNull(named.ref()); assertEquals("player_Alex", named.ownerValue()); + OwnershipFixture.mode(OwnershipIdentity.DISABLED); + assertEquals(OwnershipActions.DISABLED, OwnershipActions.actor(alex).refusal()); + } + + @Test + void simpleFactionsWithoutAReadyAuthorityRefusesEveryNonStaffChange() { + Actor player = OwnershipActions.actor(alex).value(), warden = OwnershipActions.actor(staff).value(); + assertTrue(OwnershipActions.authorityAllows(player), "no SimpleFactions installed"); + factionsInstalled(null); + assertFalse(OwnershipActions.authorityAllows(player)); + assertTrue(OwnershipActions.authorityAllows(warden)); + VehicleOwnershipAuthority authority = mock(VehicleOwnershipAuthority.class); + factionsInstalled(authority); + assertFalse(OwnershipActions.authorityAllows(player), "registered but not ready"); + when(authority.ready()).thenReturn(true); + assertTrue(OwnershipActions.authorityAllows(player)); + when(services.load(VehicleOwnershipAuthority.class)).thenReturn(null); + assertFalse(OwnershipActions.authorityAllows(player), "looked up on every call"); + OwnershipFixture.mode(OwnershipIdentity.NAMES); + assertTrue(OwnershipActions.authorityAllows(OwnershipActions.actor(alex).value()), "names mode has no authority gate"); + } + + @Test + void refusalsNameTheFirstFailingCheckAndTheEditCheckRunsLast() { + ownedBy(kira); + assertTrue(OwnershipActions.refusal(alex, vehicle, OwnershipReason.CLEAR).isEmpty()); + VehicleOwnershipEditCheckEvent check = fired(VehicleOwnershipEditCheckEvent.class).getFirst(); + assertEquals(OwnershipReason.CLEAR, check.getReason().orElseThrow()); assertEquals(kira.ref(), check.getActor().orElseThrow()); + assertFalse(check.isStaff()); assertSame(vehicle, check.getVehicle()); + doAnswer(call -> { + if (call.getArgument(0) instanceof VehicleOwnershipEditCheckEvent edit) edit.deny(); + return null; + }).when(plugins).callEvent(any(VehicleOwnershipEditCheckEvent.class)); + assertEquals(OwnershipActions.NOT_ALLOWED, OwnershipActions.refusal(alex, vehicle, OwnershipReason.CLEAR).orElseThrow()); + assertFalse(OwnershipActions.allowed(alex, vehicle, OwnershipReason.CLEAR)); + factionsInstalled(null); + assertEquals(OwnershipActions.UNAVAILABLE, OwnershipActions.refusal(alex, vehicle, OwnershipReason.CLEAR).orElseThrow()); + ownedBy(admin); + assertEquals(OwnershipActions.NOT_OWNER, OwnershipActions.refusal(alex, vehicle, OwnershipReason.CLEAR).orElseThrow()); + plays(alex, null); + assertEquals(OwnershipActions.NEED_CHARACTER, OwnershipActions.refusal(alex, vehicle, OwnershipReason.CLEAR).orElseThrow()); + } + + @Test + void staffControlEveryVehicleButSeeTheMenuOnlyOnOwnedOnes() { + ownedBy(kira); + Actor warden = OwnershipActions.actor(staff).value(); + assertTrue(OwnershipActions.controls(warden, vehicle)); + assertTrue(OwnershipActions.mayOpenMenu(staff, vehicle)); + assertTrue(OwnershipActions.mayOpenMenu(alex, vehicle)); + assertTrue(fired(VehicleOwnershipEditCheckEvent.class).getFirst().isMenu()); + data.setOwner("none"); + assertFalse(OwnershipActions.mayOpenMenu(staff, vehicle)); + assertFalse(OwnershipActions.mayOpenMenu(alex, vehicle)); + ownedBy(admin); + assertFalse(OwnershipActions.mayOpenMenu(alex, vehicle)); + plays(alex, null); + ownedBy(kira); + assertFalse(OwnershipActions.mayOpenMenu(alex, vehicle)); + plays(alex, kira); + doAnswer(call -> { + if (call.getArgument(0) instanceof VehicleOwnershipEditCheckEvent edit) edit.deny(); + return null; + }).when(plugins).callEvent(any(VehicleOwnershipEditCheckEvent.class)); + assertFalse(OwnershipActions.mayOpenMenu(alex, vehicle)); + } + + private Optional claim(Consumer mutation) { + Actor actor = OwnershipActions.actor(alex).value(); + return OwnershipActions.perform(new OwnershipActions.Change(actor, vehicle, OwnershipReason.CLAIM, actor.ref(), null, mutation)); + } + + @Test + void aWrittenClaimFiresPreLegacyAndPostEventsUnderOneOperation() { + String owner = "char_" + kira.ref().characterId(); + assertEquals(Optional.of(Result.OK), claim(d -> d.setOwner(owner))); + assertEquals(owner, data.getOwner()); + assertEquals(3, events.size()); + VehicleOwnershipChangeEvent pre = (VehicleOwnershipChangeEvent) events.get(0); + VehicleOwnerClaimedEvent legacy = (VehicleOwnerClaimedEvent) events.get(1); + VehicleOwnershipChangedEvent post = (VehicleOwnershipChangedEvent) events.get(2); + assertEquals("none", pre.getPreviousOwner()); assertEquals(owner, pre.getNewOwner()); + assertEquals(kira.ref(), pre.getTarget().orElseThrow()); + assertEquals(owner, legacy.getNewOwner()); assertSame(alex, legacy.getPlayer()); + assertTrue(post.isSuccess()); assertEquals(pre.getOperationId(), post.getOperationId()); + assertEquals("none", post.getPrevious().owner()); assertEquals(owner, post.getCurrent().owner()); + assertEquals(java.util.UUID.fromString(vehicle.getUUID()), post.getVehicleId()); + } + + @Test + void aCancelledChangeStillReportsFailureUnderItsOperation() { + doAnswer(call -> { + if (call.getArgument(0) instanceof VehicleOwnershipChangeEvent pre) pre.setCancelled(true); + return events.add(call.getArgument(0)); + }).when(plugins).callEvent(any(Event.class)); + assertTrue(claim(d -> d.setOwner("char_x")).isEmpty()); + assertEquals("none", data.getOwner()); + assertTrue(fired(VehicleOwnerClaimedEvent.class).isEmpty()); + VehicleOwnershipChangedEvent post = fired(VehicleOwnershipChangedEvent.class).getFirst(); + assertFalse(post.isSuccess()); + assertEquals(fired(VehicleOwnershipChangeEvent.class).getFirst().getOperationId(), post.getOperationId()); + verify(persistence, never()).saveLive(any(ActiveVehicle.class)); + } + + @Test + void theLegacyClaimEventCanStillVetoAClaim() { + doAnswer(call -> { + if (call.getArgument(0) instanceof VehicleOwnerClaimedEvent legacy) legacy.setCancelled(true); + return events.add(call.getArgument(0)); + }).when(plugins).callEvent(any(Event.class)); + assertTrue(claim(d -> d.setOwner("char_x")).isEmpty()); + assertEquals("none", data.getOwner()); + assertFalse(fired(VehicleOwnershipChangedEvent.class).getFirst().isSuccess()); + } + + @Test + void aFailedSaveRestoresTheVehicleAndReportsFailure() { + data.setWhiteList(new ArrayList<>(List.of("char_old"))); + when(persistence.saveLive(vehicle)).thenReturn(false); + assertEquals(Optional.of(Result.SAVE_FAILED), claim(d -> { d.setOwner("char_x"); d.getWhiteList().clear(); })); + assertEquals("none", data.getOwner()); assertEquals(List.of("char_old"), data.getWhiteList()); + VehicleOwnershipChangedEvent post = fired(VehicleOwnershipChangedEvent.class).getFirst(); + assertFalse(post.isSuccess()); assertEquals("none", post.getCurrent().owner()); + } + + @Test + void anEventSystemFailureStillFiresThePostWriteEvent() { + doAnswer(call -> { + if (call.getArgument(0) instanceof VehicleOwnershipChangeEvent) throw new IllegalStateException("listener exploded"); + return events.add(call.getArgument(0)); + }).when(plugins).callEvent(any(Event.class)); + assertThrows(IllegalStateException.class, () -> claim(d -> d.setOwner("char_x"))); + assertFalse(fired(VehicleOwnershipChangedEvent.class).getFirst().isSuccess()); + assertEquals("none", data.getOwner()); + } + + @Test + void nonUuidVehicleIdsAreReportedWithoutAnId() { + when(vehicle.getUUID()).thenReturn("legacy-id"); + claim(d -> d.setOwner("char_x")); + assertNull(fired(VehicleOwnershipChangedEvent.class).getFirst().getVehicleId()); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipFixture.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipFixture.java new file mode 100644 index 0000000..a925536 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipFixture.java @@ -0,0 +1,37 @@ +package net.tfminecraft.vehicleframework.ownership; + +import java.lang.reflect.Field; +import java.util.UUID; + +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; + +/** Sets the identity mode without the startup log, and builds character sessions. */ +public final class OwnershipFixture { + private OwnershipFixture() { + } + + public static void mode(OwnershipIdentity identity) { + try { + Field field = OwnershipMode.class.getDeclaredField("identity"); + field.setAccessible(true); + field.set(null, identity); + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException(ex); + } + } + + public static CharacterSession session(String account, String character, long id) { + return new CharacterSession(new CharacterRef(uuid(account), uuid(character)), id); + } + + public static UUID uuid(String seed) { + return UUID.nameUUIDFromBytes(seed.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + /** Resets the typed-name probe; a facade without findByName turns typed names off. */ + public static void lookup(Class facade) { + CharacterLookup.useLookup(facade); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipModeTest.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipModeTest.java new file mode 100644 index 0000000..3eaa144 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipModeTest.java @@ -0,0 +1,54 @@ +package net.tfminecraft.vehicleframework.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; +import net.tfminecraft.vehicleframework.api.VehicleFrameworkApi; + +class OwnershipModeTest { + @AfterEach + void reset() { + OwnershipFixture.mode(OwnershipIdentity.NAMES); + } + + @Test + void theKeyChoosesCharactersOrNamesWhateverItsCase() { + try (MockedStatic log = mockStatic(VFLogger.class)) { + assertEquals(OwnershipIdentity.CHARACTERS, OwnershipMode.configure(" Characters ", true)); + assertTrue(OwnershipMode.characters()); + assertEquals(OwnershipIdentity.CHARACTERS, VehicleFrameworkApi.ownershipIdentity()); + log.verify(() -> VFLogger.info("Vehicle ownership identity: characters")); + assertEquals(OwnershipIdentity.NAMES, OwnershipMode.configure("names", true)); + assertFalse(OwnershipMode.characters()); + } + } + + @Test + void aMissingKeyMeansNamesOnlyWithoutRpCharacters() { + try (MockedStatic log = mockStatic(VFLogger.class)) { + assertEquals(OwnershipIdentity.NAMES, OwnershipMode.configure(null, false)); + assertEquals(OwnershipIdentity.NAMES, OwnershipMode.configure(" ", false)); + log.verify(() -> VFLogger.severe(anyString()), never()); + assertEquals(OwnershipIdentity.DISABLED, OwnershipMode.configure(null, true)); + log.verify(() -> VFLogger.severe(contains("ownership-identity is not set and RPCharacters is installed"))); + log.verify(() -> VFLogger.severe(contains("Set ownership-identity"))); + } + } + + @Test + void anUnknownValueRefusesOwnershipLoudly() { + try (MockedStatic log = mockStatic(VFLogger.class)) { + assertEquals(OwnershipIdentity.DISABLED, OwnershipMode.configure("accounts", false)); + assertEquals(OwnershipIdentity.DISABLED, OwnershipMode.identity()); + log.verify(() -> VFLogger.severe(contains("unknown value 'accounts'"))); + log.verify(() -> VFLogger.severe(contains("VEHICLE OWNERSHIP IS DISABLED"))); + } + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipStoreTest.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipStoreTest.java new file mode 100644 index 0000000..36ba1fc --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/OwnershipStoreTest.java @@ -0,0 +1,321 @@ +package net.tfminecraft.vehicleframework.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Entity; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.MockedStatic; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.api.OwnerChange; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; +import net.tfminecraft.vehicleframework.api.OwnershipMeta; +import net.tfminecraft.vehicleframework.api.OwnershipReason; +import net.tfminecraft.vehicleframework.api.OwnershipValues; +import net.tfminecraft.vehicleframework.api.SystemReason; +import net.tfminecraft.vehicleframework.api.VehicleFrameworkApi; +import net.tfminecraft.vehicleframework.api.VehicleFrameworkApi.Result; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.database.VehiclePersistence; +import net.tfminecraft.vehicleframework.database.VehicleRepository; +import net.tfminecraft.vehicleframework.database.VehicleSnapshot; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent; +import net.tfminecraft.vehicleframework.managers.VehicleManager; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class OwnershipStoreTest { + private static final UUID STORED = UUID.fromString("11111111-2222-4333-8444-555555555555"); + private static final UUID LOADED = UUID.fromString("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); + private static final UUID KIRA = UUID.fromString("0b1f2e9c-6a3d-4c55-8a7e-2f1d9c3b4a51"); + private static final String PAYLOAD = "{\"id\":\"cart\",\"owner\":\"player_Alice\",\"whitelisted\":true," + + "\"whitelist\":[\"player_Bob\"],\"ticketsEnabled\":true,\"ticketId\":\"t-1\",\"components\":{\"hull\":{\"damage\":5}}}"; + + @TempDir Path temp; + private final List scopes = new ArrayList<>(); + private final PluginManager plugins = mock(PluginManager.class); + private final VehicleManager manager = mock(VehicleManager.class); + private final VehiclePersistence persistence = mock(VehiclePersistence.class); + private final List events = new ArrayList<>(); + private final Map loaded = new HashMap<>(); + private MockedStatic bukkit; + private MockedStatic framework; + private MockedStatic persistenceAccess; + private VehicleRepository repository; + private boolean allowWhitelist, whitelistedByDefault; + + @BeforeEach + void setup() { + bukkit = keep(mockStatic(Bukkit.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + doAnswer(call -> events.add(call.getArgument(0))).when(plugins).callEvent(any(Event.class)); + keep(mockStatic(VFLogger.class)); + repository = VehicleRepository.open(temp.resolve("vehicles.db").toFile()); + framework = keep(mockStatic(VehicleFramework.class)); + framework.when(VehicleFramework::getVehicleManager).thenReturn(manager); + framework.when(VehicleFramework::getVehicleRepository).thenReturn(repository); + persistenceAccess = keep(mockStatic(VehiclePersistence.class)); + persistenceAccess.when(VehiclePersistence::current).thenReturn(persistence); + when(manager.get()).thenReturn(new HashMap<>(loaded)); + allowWhitelist = Cache.allowWhitelist; whitelistedByDefault = Cache.whitelistedByDefault; + OwnershipFixture.mode(OwnershipIdentity.CHARACTERS); + } + + @AfterEach + void cleanup() throws Exception { + repository.close(); + for (int i = scopes.size() - 1; i >= 0; i--) scopes.get(i).close(); + Cache.allowWhitelist = allowWhitelist; Cache.whitelistedByDefault = whitelistedByDefault; + OwnershipFixture.mode(OwnershipIdentity.NAMES); + } + + private T keep(T scope) { + scopes.add(scope); + return scope; + } + + private void store(UUID id, String payload) { + repository.saveLive(new VehicleSnapshot(id.toString(), "cart", "Stored cart", "player_Alice", "world", 0, 64, 0, 0, 0, 0, + payload, 2, 1, false, 1)); + } + + private ActiveVehicle live(UUID id, String owner) { + ActiveVehicle vehicle = mock(ActiveVehicle.class); + OwnerData data = new OwnerData(); + data.setOwner(owner); data.setWhiteListed(true); data.setWhiteList(new ArrayList<>(List.of("player_Bob"))); + data.setTicketsEnabled(true); + when(vehicle.getOwnerData()).thenReturn(data); + when(vehicle.getUUID()).thenReturn(id.toString()); + when(vehicle.getId()).thenReturn("boat"); + when(vehicle.getName()).thenReturn("Live boat"); + when(manager.getByUUID(id.toString())).thenReturn(vehicle); + loaded.put(mock(Entity.class), vehicle); + when(manager.get()).thenReturn(new HashMap<>(loaded)); + return vehicle; + } + + private JsonObject payload(UUID id) { + return JsonParser.parseString(repository.findLive(id.toString()).orElseThrow().getPayloadJson()).getAsJsonObject(); + } + + private List written() { + return events.stream().filter(VehicleOwnershipChangedEvent.class::isInstance).map(VehicleOwnershipChangedEvent.class::cast).toList(); + } + + @Test + void outsideCharactersModeEveryWriteIsRefusedBeforeAnythingChanges() { + store(STORED, PAYLOAD); + for (OwnershipIdentity identity : List.of(OwnershipIdentity.NAMES, OwnershipIdentity.DISABLED)) { + OwnershipFixture.mode(identity); + assertEquals(Result.REFUSED_MODE, VehicleFrameworkApi.setOwnership(STORED, OwnerChange.character(KIRA), SystemReason.RESTAMP)); + } + assertEquals("player_Alice", repository.findLive(STORED.toString()).orElseThrow().getOwner()); + assertTrue(events.isEmpty()); + } + + @Test + void writesAreMainThreadOnlyAndOnlyMigrateMaySeedTheWhitelist() { + assertThrows(IllegalArgumentException.class, + () -> VehicleFrameworkApi.setOwnership(STORED, OwnerChange.seeded("none", List.of()), SystemReason.RESTAMP)); + assertThrows(NullPointerException.class, () -> VehicleFrameworkApi.setOwnership(null, OwnerChange.unowned(), SystemReason.RESTAMP)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + assertThrows(IllegalStateException.class, () -> VehicleFrameworkApi.setOwnership(STORED, OwnerChange.unowned(), SystemReason.RESTAMP)); + assertThrows(IllegalStateException.class, VehicleFrameworkApi::listOwnershipMeta); + assertThrows(IllegalStateException.class, () -> VehicleFrameworkApi.readOwnershipMeta(STORED)); + } + + @Test + void aLoadedVehicleIsAuthoritativeAndItsStoredRowIsNotTouched() { + store(LOADED, PAYLOAD); + ActiveVehicle vehicle = live(LOADED, "player_Alice"); + when(persistence.saveLive(vehicle)).thenReturn(true); + assertEquals(Result.OK, VehicleFrameworkApi.setOwnership(LOADED, OwnerChange.character(KIRA), SystemReason.RESTAMP)); + assertEquals("char_" + KIRA, vehicle.getOwnerData().getOwner()); + assertEquals(List.of("player_Bob"), vehicle.getOwnerData().getWhiteList()); + assertEquals("player_Alice", repository.findLive(LOADED.toString()).orElseThrow().getOwner(), "the live save owns the row"); + VehicleOwnershipChangedEvent event = written().getFirst(); + assertTrue(event.isSuccess()); assertEquals(OwnershipReason.RESTAMP, event.getReason()); + assertEquals(LOADED, event.getVehicleId()); assertSame(vehicle, event.getVehicle().orElseThrow()); + assertEquals("player_Alice", event.getPrevious().owner()); assertEquals("char_" + KIRA, event.getCurrent().owner()); + assertTrue(event.getPlayer().isEmpty()); assertNotNull(event.getOperationId()); + } + + @Test + void aFailedLiveSaveRestoresEveryFieldAndFiresNoEvent() { + ActiveVehicle vehicle = live(LOADED, "player_Alice"); + OwnerData before = vehicle.getOwnerData().copy(); + when(persistence.saveLive(vehicle)).thenReturn(false); + assertEquals(Result.SAVE_FAILED, VehicleFrameworkApi.setOwnership(LOADED, OwnerChange.character(KIRA), SystemReason.HANDOVER)); + assertEquals(OwnershipStore.values(before), OwnershipStore.values(vehicle.getOwnerData())); + when(persistence.saveLive(vehicle)).thenThrow(new IllegalStateException("disk full")); + assertEquals(Result.SAVE_FAILED, VehicleFrameworkApi.setOwnership(LOADED, OwnerChange.character(KIRA), SystemReason.HANDOVER)); + assertEquals(OwnershipStore.values(before), OwnershipStore.values(vehicle.getOwnerData())); + persistenceAccess.when(VehiclePersistence::current).thenReturn(null); + assertEquals(Result.SAVE_FAILED, VehicleFrameworkApi.setOwnership(LOADED, OwnerChange.character(KIRA), SystemReason.HANDOVER)); + assertEquals(OwnershipStore.values(before), OwnershipStore.values(vehicle.getOwnerData())); + assertTrue(written().isEmpty()); + } + + @Test + void aDestroyedLoadedVehicleIsNotFoundAndHasNoStoredFallback() { + store(LOADED, PAYLOAD); + ActiveVehicle vehicle = live(LOADED, "player_Alice"); + when(vehicle.isDestroyed()).thenReturn(true); + assertEquals(Result.NOT_FOUND, VehicleFrameworkApi.setOwnership(LOADED, OwnerChange.character(KIRA), SystemReason.RESTAMP)); + assertEquals("player_Alice", repository.findLive(LOADED.toString()).orElseThrow().getOwner()); + verify(persistence, never()).saveLive(any(ActiveVehicle.class)); + } + + @ParameterizedTest + @EnumSource(SystemReason.class) + void eachReasonDisposesOfTheWhitelistAsDesigned(SystemReason reason) { + Cache.allowWhitelist = true; Cache.whitelistedByDefault = true; + store(STORED, PAYLOAD); + OwnerChange change = reason == SystemReason.MIGRATE + ? OwnerChange.seeded("char_" + KIRA, List.of("char_seed")) + : OwnerChange.character(KIRA); + assertEquals(Result.OK, VehicleFrameworkApi.setOwnership(STORED, change, reason)); + JsonObject json = payload(STORED); + List entries = new ArrayList<>(); + json.getAsJsonArray("whitelist").forEach(entry -> entries.add(entry.getAsString())); + List expectedEntries = switch (reason) { + case RESTAMP -> List.of("player_Bob"); + case MIGRATE -> List.of("char_seed"); + default -> List.of(); + }; + boolean expectedFlag = switch (reason) { + case RELEASE, HANDOVER -> false; + default -> true; + }; + assertEquals(expectedEntries, entries); + assertEquals(expectedFlag, json.get("whitelisted").getAsBoolean()); + assertEquals("char_" + KIRA, json.get("owner").getAsString()); + assertTrue(json.get("ticketsEnabled").getAsBoolean(), "tickets never change"); + assertEquals("t-1", json.get("ticketId").getAsString()); + assertEquals(5, json.getAsJsonObject("components").getAsJsonObject("hull").get("damage").getAsInt()); + assertEquals("char_" + KIRA, repository.findLive(STORED.toString()).orElseThrow().getOwner()); + VehicleOwnershipChangedEvent event = written().getFirst(); + assertEquals(reason.reason(), event.getReason()); assertTrue(event.getVehicle().isEmpty()); + assertEquals("player_Alice", event.getPrevious().owner()); assertEquals(entries, event.getCurrent().whitelist()); + } + + @Test + void constructTurnsTheFlagOffWhenWhitelistsAreNotOnByDefault() { + Cache.allowWhitelist = true; Cache.whitelistedByDefault = false; + ActiveVehicle vehicle = live(LOADED, "none"); + when(persistence.saveLive(vehicle)).thenReturn(true); + assertEquals(Result.OK, VehicleFrameworkApi.setOwnership(LOADED, OwnerChange.character(KIRA), SystemReason.CONSTRUCT)); + assertFalse(vehicle.getOwnerData().isWhiteListed()); + assertTrue(vehicle.getOwnerData().getWhiteList().isEmpty()); + assertTrue(vehicle.getOwnerData().isTicketsEnabled()); + } + + @Test + void migrateWithoutSeedKeepsTheEntries() { + store(STORED, PAYLOAD); + assertEquals(Result.OK, VehicleFrameworkApi.setOwnership(STORED, OwnerChange.noCharacter(), SystemReason.MIGRATE)); + assertEquals("player_Bob", payload(STORED).getAsJsonArray("whitelist").get(0).getAsString()); + assertEquals("char_none", repository.findLive(STORED.toString()).orElseThrow().getOwner()); + } + + @Test + void missingTombstonedAndUnreadableRowsAreNotFoundOrLeftUntouched() { + assertEquals(Result.NOT_FOUND, VehicleFrameworkApi.setOwnership(STORED, OwnerChange.unowned(), SystemReason.RELEASE)); + store(STORED, PAYLOAD); + repository.tombstone(STORED.toString()); + assertEquals(Result.NOT_FOUND, VehicleFrameworkApi.setOwnership(STORED, OwnerChange.unowned(), SystemReason.RELEASE)); + store(STORED, "[1]"); + assertEquals(Result.SAVE_FAILED, VehicleFrameworkApi.setOwnership(STORED, OwnerChange.unowned(), SystemReason.RELEASE)); + store(STORED, "{"); + assertEquals(Result.SAVE_FAILED, VehicleFrameworkApi.setOwnership(STORED, OwnerChange.unowned(), SystemReason.RELEASE)); + assertEquals("{", repository.findLive(STORED.toString()).orElseThrow().getPayloadJson()); + assertEquals("player_Alice", repository.findLive(STORED.toString()).orElseThrow().getOwner()); + assertTrue(written().isEmpty()); + } + + @Test + void aStoredWriteThatSavesNothingRollsBackAndAClosedDatabaseFails() { + VehicleRepository stuck = mock(VehicleRepository.class); + VehicleSnapshot row = new VehicleSnapshot(STORED.toString(), "cart", "Cart", "player_Alice", "world", 0, 64, 0, 0, 0, 0, + PAYLOAD, 2, 1, false, 1); + when(stuck.findLive(STORED.toString())).thenReturn(Optional.of(row)); + doAnswer(call -> { ((Runnable) call.getArgument(0)).run(); return null; }).when(stuck).runInTransaction(any()); + framework.when(VehicleFramework::getVehicleRepository).thenReturn(stuck); + assertEquals(Result.SAVE_FAILED, VehicleFrameworkApi.setOwnership(STORED, OwnerChange.unowned(), SystemReason.RELEASE)); + framework.when(VehicleFramework::getVehicleRepository).thenReturn(null); + assertEquals(Result.SAVE_FAILED, VehicleFrameworkApi.setOwnership(STORED, OwnerChange.unowned(), SystemReason.RELEASE)); + assertThrows(IllegalStateException.class, VehicleFrameworkApi::listOwnershipMeta); + assertThrows(IllegalStateException.class, () -> VehicleFrameworkApi.readOwnershipMeta(STORED)); + } + + @Test + void listingKeepsStoredAndLiveValuesApartAndSkipsTombstones() { + store(STORED, PAYLOAD); + store(LOADED, PAYLOAD); + UUID gone = UUID.randomUUID(), unreadable = UUID.randomUUID(), fresh = UUID.randomUUID(); + store(gone, PAYLOAD); repository.tombstone(gone.toString()); + store(unreadable, "{"); + repository.saveLive(new VehicleSnapshot("not-a-uuid", "cart", "x", "none", "world", 0, 64, 0, 0, 0, 0, PAYLOAD, 2, 1, false, 1)); + ActiveVehicle vehicle = live(LOADED, "char_" + KIRA); + live(fresh, "none"); + ActiveVehicle destroyed = live(UUID.randomUUID(), "none"); + when(destroyed.isDestroyed()).thenReturn(true); + Map rows = new HashMap<>(); + for (OwnershipMeta meta : VehicleFrameworkApi.listOwnershipMeta()) rows.put(meta.vehicleId(), meta); + assertEquals(4, rows.size()); + OwnershipMeta stored = rows.get(STORED); + assertFalse(stored.isLive()); assertEquals("player_Alice", stored.storedOwnerColumn()); + assertEquals(new OwnershipValues("player_Alice", List.of("player_Bob"), true, true, "t-1"), stored.stored().orElseThrow()); + OwnershipMeta both = rows.get(LOADED); + assertEquals("player_Alice", both.stored().orElseThrow().owner()); + assertEquals("char_" + KIRA, both.live().orElseThrow().owner()); + assertEquals("boat", both.typeId()); assertEquals(vehicle.getOwnerData().getOwner(), both.current().orElseThrow().owner()); + assertTrue(rows.get(unreadable).hasStoredRow()); assertTrue(rows.get(unreadable).stored().isEmpty()); + assertFalse(rows.get(fresh).hasStoredRow()); assertTrue(rows.get(fresh).isLive()); + assertFalse(rows.containsKey(gone)); + } + + @Test + void readingOneVehicleCombinesItsRowAndLiveObject() { + assertTrue(VehicleFrameworkApi.readOwnershipMeta(STORED).isEmpty()); + store(STORED, PAYLOAD); + OwnershipMeta stored = VehicleFrameworkApi.readOwnershipMeta(STORED).orElseThrow(); + assertTrue(stored.hasStoredRow()); assertFalse(stored.isLive()); assertEquals("cart", stored.typeId()); + live(LOADED, "none"); + OwnershipMeta live = VehicleFrameworkApi.readOwnershipMeta(LOADED).orElseThrow(); + assertFalse(live.hasStoredRow()); assertEquals("none", live.live().orElseThrow().owner()); + ActiveVehicle destroyed = live(STORED, "none"); + when(destroyed.isDestroyed()).thenReturn(true); + assertFalse(VehicleFrameworkApi.readOwnershipMeta(STORED).orElseThrow().isLive()); + } + + @Test + void payloadsWithMissingFieldsReadAsUnownedAndUnlisted() { + store(STORED, "{\"id\":\"cart\",\"whitelist\":[\"a\",null],\"whitelisted\":\"yes\"}"); + OwnershipValues values = VehicleFrameworkApi.readOwnershipMeta(STORED).orElseThrow().stored().orElseThrow(); + assertEquals(new OwnershipValues("none", List.of("a"), false, false, null), values); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/ownership/PendingActionsTest.java b/src/test/java/net/tfminecraft/vehicleframework/ownership/PendingActionsTest.java new file mode 100644 index 0000000..a9dd2f0 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/ownership/PendingActionsTest.java @@ -0,0 +1,105 @@ +package net.tfminecraft.vehicleframework.ownership; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.ownership.PendingActions.Kind; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class PendingActionsTest { + private final BukkitScheduler scheduler = mock(BukkitScheduler.class); + private final List timeouts = new ArrayList<>(); + private final List delays = new ArrayList<>(); + private final Player player = mock(Player.class); + private final PendingActions pending = new PendingActions(); + private MockedStatic bukkit; + private MockedStatic characters; + + @BeforeEach + void setup() { + bukkit = mockStatic(Bukkit.class); + characters = mockStatic(Characters.class); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(call -> { + timeouts.add(call.getArgument(1)); delays.add(call.getArgument(2)); + BukkitTask task = mock(BukkitTask.class); when(task.getTaskId()).thenReturn(timeouts.size()); return task; + }); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + } + + @AfterEach + void cleanup() { + characters.close(); + bukkit.close(); + } + + @Test + void actionsExpireAfterSixtySecondsWithTheirOwnMessage() { + ActiveVehicle vehicle = mock(ActiveVehicle.class); + pending.start(player, Kind.WHITELIST_ADD, vehicle, null, null); + assertEquals(List.of(1200L), delays); + assertTrue(pending.has(player, Kind.WHITELIST_ADD)); assertFalse(pending.has(player, Kind.RENAME)); + timeouts.getFirst().run(); + assertFalse(pending.has(player, Kind.WHITELIST_ADD)); + verify(player).sendMessage("§cWhitelist entry timed out."); + timeouts.getFirst().run(); + verify(player, times(1)).sendMessage(anyString()); + } + + @Test + void restartingOrTakingAnActionCancelsItsOldTimeout() { + pending.start(player, Kind.RENAME, null, null, null); + pending.start(player, Kind.RENAME, null, null, null); + verify(scheduler).cancelTask(1); + timeouts.getFirst().run(); + verify(player, never()).sendMessage(anyString()); + assertTrue(pending.take(player, Kind.RENAME).isPresent()); + verify(scheduler).cancelTask(2); + timeouts.get(1).run(); + verify(player, never()).sendMessage(anyString()); + assertTrue(pending.take(player, Kind.RENAME).isEmpty()); + } + + @Test + void clearingDropsEveryKindAndReportsWhetherAnyWerePending() { + assertFalse(pending.clear(player)); + pending.start(player, Kind.TAKEOVER, null, null, new PendingActions.Target("char_x", null, "Kira")); + pending.start(player, Kind.RENAME, null, null, null); + assertTrue(pending.clear(player)); + assertFalse(pending.has(player, Kind.TAKEOVER)); assertFalse(pending.has(player, Kind.RENAME)); + pending.start(player, Kind.TAKEOVER, null, null, null); + pending.cancel(player, Kind.TAKEOVER); + assertFalse(pending.has(player, Kind.TAKEOVER)); + timeouts.getLast().run(); + verify(player, never()).sendMessage("§cTakeover expired."); + } + + @Test + void anActionStaysValidOnlyWhileItsSessionIsCurrent() { + CharacterSession session = OwnershipFixture.session("alex", "kira", 7); + pending.start(player, Kind.RENAME, null, session, null); + PendingActions.Pending action = pending.take(player, Kind.RENAME).orElseThrow(); + assertSame(session, action.session()); + assertFalse(PendingActions.stillCurrent(action)); + characters.when(() -> Characters.isCurrent(session)).thenReturn(true); + assertTrue(PendingActions.stillCurrent(action)); + pending.start(player, Kind.RENAME, null, null, null); + assertTrue(PendingActions.stillCurrent(pending.take(player, Kind.RENAME).orElseThrow()), "names mode has no session"); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/RuntimeSupportCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/RuntimeSupportCoverageTest.java index d2af248..356fd2c 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/RuntimeSupportCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/vehicles/controller/RuntimeSupportCoverageTest.java @@ -102,7 +102,7 @@ class RuntimeSupportCoverageTest { @Test void accessRulesHandleMissingLegacyWhitelistEntriesWithoutGrantingStrangersAccess() { assertFalse(VehicleTicketRules.ownerOrWhitelisted(null, "Guest")); OwnerData access = new OwnerData(); access.setOwner("player_Owner"); access.setWhiteListed(true); - assertFalse(VehicleTicketRules.ownerOrWhitelisted(access, null)); assertTrue(VehicleTicketRules.ownerOrWhitelisted(access, "OWNER")); + assertFalse(VehicleTicketRules.ownerOrWhitelisted(access, (String) null)); assertTrue(VehicleTicketRules.ownerOrWhitelisted(access, "OWNER")); access.setWhiteList(null); assertFalse(VehicleTicketRules.ownerOrWhitelisted(access, "Guest")); access.setWhiteList(Arrays.asList(null, "player_Alice", "Bob")); assertTrue(VehicleTicketRules.ownerOrWhitelisted(access, "ALICE")); assertTrue(VehicleTicketRules.ownerOrWhitelisted(access, "bob")); From 1c679f30aa3dd663d74855fae27faf978fce3d5c Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:38:35 +0000 Subject: [PATCH 3/5] feat: make claims, menus and takeovers character-aware Claims, the ownership and whitelist menus and staff takeovers re-check the owner character or vf.admin on every click and run through the ownership events. Characters mode adds a nearby-player whitelist picker, typed names when TLibs can look them up, greyed legacy entries and owner names from RPCharacters. Whitelist, rename and takeover prompts remember the session, end with it and expire after 60 seconds. /vf admin quarantine lists the vehicles the owner migration held back. The uncalled clearOwnership methods are gone. Co-Authored-By: Claude Opus 5.5 --- .../vehicleframework/VehicleFramework.java | 30 + .../database/VehiclePersistence.java | 37 - .../vehicleframework/enums/VFGUI.java | 1 + .../loaders/ConfigLoader.java | 13 + .../managers/CommandManager.java | 26 +- .../managers/InventoryManager.java | 6 +- .../managers/OwnershipGUIManager.java | 113 ++- .../managers/OwnershipManager.java | 485 +++++++++++++ .../managers/QuarantineCommand.java | 62 ++ .../managers/VehicleManager.java | 245 +------ .../inventory/WhitelistPickerHolder.java | 32 + .../tracks/TrainTapeInteract.java | 4 +- .../vehicleframework/util/TabCompletion.java | 6 + .../handlers/VehicleTicketInteract.java | 4 +- src/main/resources/config.yml | 6 + .../VehicleFrameworkCoverageTest.java | 45 +- .../data/RuntimeValuesCoverageTest.java | 11 +- .../database/DatabaseRuntimeCoverageTest.java | 11 +- .../CommandAndOwnershipCoverageTest.java | 128 ++++ .../InventoryManagerCoverageTest.java | 3 + .../managers/OwnershipManagerTest.java | 658 ++++++++++++++++++ .../managers/QuarantineCommandTest.java | 84 +++ .../managers/VehicleManagerCoverageTest.java | 73 +- 23 files changed, 1733 insertions(+), 350 deletions(-) create mode 100644 src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/managers/QuarantineCommand.java create mode 100644 src/main/java/net/tfminecraft/vehicleframework/managers/inventory/WhitelistPickerHolder.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java create mode 100644 src/test/java/net/tfminecraft/vehicleframework/managers/QuarantineCommandTest.java 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/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/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/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..a13af2e 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,21 @@ 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") && (args.length == 1 || args.length == 2)) { 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 { + VehicleFramework.getVehicleManager().startTakeover(p, args[1]); + } + 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..32f8581 --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java @@ -0,0 +1,485 @@ +package net.tfminecraft.vehicleframework.managers; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +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."; + + 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 name the vehicle goes to the staff member's own character; + * with one (characters mode) to the single living character with that real name. + */ + 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 { + if (!OwnershipMode.characters() || !CharacterLookup.typedNamesAvailable()) { + p.sendMessage(TYPED_NAMES_OFF); + return; + } + Optional found = single(targetName); + if (found.isEmpty()) { + p.sendMessage(String.format(NO_SINGLE_MATCH, targetName)); + return; + } + CharacterRef ref = found.get().ref(); + target = new Target(VehicleOwners.character(ref.characterId()), ref, found.get().name()); + } + 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."); + } + } + + /** 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(); + 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 (vehicles.get(v.getEntity()) == null) 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 ────────────────────────────────────────────────────────────── + + /** 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..79cd4cd --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/QuarantineCommand.java @@ -0,0 +1,62 @@ +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()); + Map known = known(); + for (QuarantinedVehicle vehicle : vehicles) { + String label = Optional.ofNullable(known.get(vehicle.vehicleId())) + .map(meta -> " §8" + meta.typeId() + " '" + meta.name() + "'") + .orElse(" §8(not stored)"); + sender.sendMessage("§7" + vehicle.vehicleId() + label + " §f" + vehicle.owner() + " §7- " + vehicle.reason()); + } + } + + private static Map known() { + try { + return OwnershipStore.listOwnershipMeta().stream() + .collect(Collectors.toMap(OwnershipMeta::vehicleId, Function.identity())); + } catch (RuntimeException ex) { + VFLogger.log("Could not read vehicles for the quarantine list: " + ex); + return Map.of(); + } + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java index 1c3adfd..ae144ae 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/VehicleManager.java @@ -37,7 +37,6 @@ import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; import org.bukkit.event.inventory.InventoryDragEvent; -import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.event.player.PlayerAnimationEvent; import org.bukkit.event.player.PlayerAnimationType; import org.bukkit.event.player.PlayerInteractEntityEvent; @@ -55,10 +54,8 @@ import io.lumine.mythic.bukkit.MythicBukkit; import io.lumine.mythic.core.mobs.ActiveMob; import net.tfminecraft.tlibs.objects.api.ItemAPI; -import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter; import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.vehicleframework.cache.Cache; -import net.tfminecraft.vehicleframework.data.NamingData; import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; import net.tfminecraft.vehicleframework.data.StoredVehicleMeta; import net.tfminecraft.vehicleframework.database.IncompleteVehicle; @@ -72,13 +69,14 @@ import net.tfminecraft.vehicleframework.enums.VFGUI; import net.tfminecraft.vehicleframework.enums.VehicleRemoveReason; import net.tfminecraft.vehicleframework.events.VFEntityDamageEvent; -import net.tfminecraft.vehicleframework.events.VehicleOwnerClaimedEvent; import net.tfminecraft.vehicleframework.events.VehiclePreInteractEvent; import net.tfminecraft.vehicleframework.events.VehicleSpawnEvent; import net.tfminecraft.vehicleframework.loaders.FuelLoader; import net.tfminecraft.vehicleframework.loaders.VehicleLoader; import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder; import net.tfminecraft.vehicleframework.managers.spawner.VehicleSpawner; +import net.tfminecraft.vehicleframework.ownership.OwnerNames; +import net.tfminecraft.vehicleframework.ownership.OwnershipAccess; import net.tfminecraft.vehicleframework.protocol.PacketConverter; import net.tfminecraft.vehicleframework.tracks.TrainTapeInteract; import net.tfminecraft.vehicleframework.tracks.TrainCollision; @@ -118,19 +116,11 @@ public class VehicleManager implements Listener{ //Player management private HashMap tempVehicle = new HashMap<>(); private HashMap activeVehicle = new HashMap<>(); - private HashMap naming = new HashMap<>(); - - // Ownership – whitelist-add state (player typed name is pending) - private HashMap addingToWhitelist = new HashMap<>(); - private HashMap addingToWhitelistTimeout = new HashMap<>(); // Owner-eject cooldown: player cannot re-enter that vehicle until timestamp expires private HashMap> ejectCooldown = new HashMap<>(); - // Admin takeover: player is waiting to click a vehicle and claim ownership - private HashMap pendingTakeover = new HashMap<>(); - - private OwnershipGUIManager ownershipGUI = new OwnershipGUIManager(); + private final OwnershipManager ownership = new OwnershipManager(this); private HashMap tow = new HashMap<>(); @@ -159,6 +149,9 @@ public RepairManager getRepairManager() { public SpawnManager getSpawnManager() { return spawnManager; } + public OwnershipManager getOwnershipManager() { + return ownership; + } public HashMap get() { return vehicles; @@ -292,7 +285,6 @@ public void reload() { } private void vehicleSlowTickCycle() { new BukkitRunnable() { - @SuppressWarnings("unchecked") @Override public void run() { updateInventory(); @@ -303,12 +295,6 @@ public void run() { } catch (Exception e) { VFLogger.log(v.getId()+" has run into an issue"); } - } - for (Map.Entry entry : ((HashMap) naming.clone()).entrySet()) { - if(entry.getValue().tick()) { - naming.remove(entry.getKey()); - entry.getKey().sendMessage("§cNaming timed out."); - } } } }.runTaskTimer(VehicleFramework.plugin, 0L, 20L); @@ -431,7 +417,7 @@ public void seatInteract(Player p, ActiveVehicle v) { ActiveVehicle ticketSource = v.ticketSource(); boolean hasTicket = VehicleTicketItems.inventoryHas( p, ticketSource.getOwnerData().getTicketId()); - if (!VehicleTicketRules.mayOpenSeatMenu(v.getOwnerData(), ticketSource.getOwnerData(), p.getName(), hasTicket)) { + if (!VehicleTicketRules.mayOpenSeatMenu(v.getOwnerData(), ticketSource.getOwnerData(), OwnershipAccess.key(p), hasTicket)) { p.sendMessage(ticketSource.getOwnerData().isTicketsEnabled() ? "§cYou need a ticket to board this vehicle." : "§cThe owner has not allowed you aboard this vehicle."); @@ -502,48 +488,14 @@ private boolean isEntityAllowed(Entity entity, List whitelist) { return false; } + /** Arms a staff takeover for the player's own character. */ public void startTakeover(Player p) { - cancelTakeover(p); - int taskId = new BukkitRunnable() { - @Override - public void run() { - if(pendingTakeover.containsKey(p)) { - pendingTakeover.remove(p); - p.sendMessage("\u00a7cTakeover expired."); - } - } - }.runTaskLater(VehicleFramework.plugin, 300L).getTaskId(); - pendingTakeover.put(p, taskId); - p.sendMessage("\u00a7aRight-click a vehicle within 15 seconds to claim ownership."); - } - - private void cancelTakeover(Player p) { - if(pendingTakeover.containsKey(p)) { - VehicleFramework.plugin.getServer().getScheduler().cancelTask(pendingTakeover.get(p)); - pendingTakeover.remove(p); - } + ownership.startTakeover(p, null); } - /** - * @return true if ownership was committed to the player - */ - private boolean claimOwnership(Player player, ActiveVehicle vehicle) { - if (player == null || vehicle == null) { - return false; - } - String previous = vehicle.getOwnerData().getOwner(); - if (previous != null && !previous.equalsIgnoreCase("none")) { - return false; - } - String newOwner = "player_" + player.getName(); - VehicleOwnerClaimedEvent event = - new VehicleOwnerClaimedEvent(player, vehicle, previous, newOwner); - Bukkit.getPluginManager().callEvent(event); - if (event.isCancelled()) { - return false; - } - vehicle.getOwnerData().setOwner(newOwner); - return true; + /** Arms a staff takeover for the named character, or the player's own when the name is null. */ + public void startTakeover(Player p, String targetName) { + ownership.startTakeover(p, targetName); } public void towSelect(Player p, ActiveVehicle v) { @@ -614,21 +566,9 @@ public void vehicleInteract(PlayerInteractEntityEvent e){ } if(!vehicles.containsKey(entity)) return; ActiveVehicle v = vehicles.get(entity); - // Admin takeover - if(pendingTakeover.containsKey(p)) { - cancelTakeover(p); - String previousOwner = v.getOwnerData().getOwner(); - boolean claimed; - if (previousOwner == null || previousOwner.equalsIgnoreCase("none")) { - claimed = claimOwnership(p, v); - } else { - v.getOwnerData().setOwner("player_" + p.getName()); - claimed = true; - } - if (claimed) { - e.setCancelled(true); - p.sendMessage("\u00a7aYou are now the owner of \u00a7e" + v.getName() + "\u00a7a."); - } + // Admin takeover goes through the ownership events like any other change + if(ownership.takeover(p, v)) { + e.setCancelled(true); return; } VehiclePreInteractEvent preInteract = new VehiclePreInteractEvent(p, v); @@ -656,7 +596,7 @@ public void vehicleInteract(PlayerInteractEntityEvent e){ if(v.getContainerHandler().open(p)) return; } //Set Ownership - claimOwnership(p, v); + ownership.claim(p, v); //Destroy if(api.getChecker().checkItemWithPath(p.getInventory().getItemInMainHand(), Cache.destroyItem)) { v.remove(VehicleRemoveReason.PLAYER_DESTROY); @@ -736,130 +676,12 @@ public void vehicleInteract(PlayerInteractEntityEvent e){ return; } if(p.getInventory().getItemInMainHand().getType().equals(Material.NAME_TAG)) { - naming.put(p, new NamingData(v)); - p.sendTitle("", "§eType the §aName §ein chat", 10, 80, 10); - p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); + ownership.startRename(p, v); return; } seatInteract(p, v); } @EventHandler - public void ownershipClick(InventoryClickEvent e) { - Player p = (Player) e.getWhoClicked(); - if(!(e.getView().getTopInventory().getHolder() instanceof VFInventoryHolder)) return; - VFInventoryHolder h = (VFInventoryHolder) e.getView().getTopInventory().getHolder(); - if(!h.getType().equals(VFGUI.OWNERSHIP)) return; - e.setCancelled(true); - if(!h.getVehicle().isPresent()) return; - ActiveVehicle v = h.getVehicle().get(); - if(v.isDestroyed()) { p.closeInventory(); return; } - ItemStack item = e.getCurrentItem(); - if(item == null || item.getType().equals(Material.GRAY_STAINED_GLASS_PANE)) return; - switch(e.getSlot()) { - case 0: // Toggle whitelisting - v.getOwnerData().setWhiteListed(!v.getOwnerData().isWhiteListed()); - ownershipGUI.ownershipGui(e.getView().getTopInventory(), p, v, false); - break; - case 2: // Add to whitelist - p.closeInventory(); - addingToWhitelist.put(p, v); - addingToWhitelistTimeout.put(p, 0); - p.sendMessage("§aType the player name in chat to add them to the whitelist. Type §ccancel §ato abort."); - break; - case 4: // View whitelist - p.closeInventory(); - ownershipGUI.whitelistGui(null, p, v, true); - break; - case 6: // Remove ownership - v.getOwnerData().setOwner("none"); - v.getOwnerData().setWhiteListed(false); - p.closeInventory(); - p.sendMessage("§7Ownership of §f" + v.getName() + "§7 has been removed."); - break; - case 8: // Toggle tickets on the consist loco when attached - v.ticketSource().getOwnerData().toggleTickets(); - ownershipGUI.ownershipGui(e.getView().getTopInventory(), p, v, false); - break; - default: - break; - } - } - - @EventHandler - public void whitelistClick(InventoryClickEvent e) { - Player p = (Player) e.getWhoClicked(); - if(!(e.getView().getTopInventory().getHolder() instanceof VFInventoryHolder)) return; - VFInventoryHolder h = (VFInventoryHolder) e.getView().getTopInventory().getHolder(); - if(!h.getType().equals(VFGUI.WHITELIST)) return; - e.setCancelled(true); - if(!h.getVehicle().isPresent()) return; - ActiveVehicle v = h.getVehicle().get(); - if(v.isDestroyed()) { p.closeInventory(); return; } - ItemStack item = e.getCurrentItem(); - if(item == null || item.getType().equals(Material.GRAY_STAINED_GLASS_PANE)) return; - if(e.getSlot() == 26) { - // Back button - p.closeInventory(); - ownershipGUI.ownershipGui(null, p, v, true); - return; - } - // Player head – remove from whitelist - if(item.getType().equals(Material.PLAYER_HEAD)) { - NamespacedKey key = new NamespacedKey(VehicleFramework.plugin, "vf_whitelist_entry"); - String entry = item.getItemMeta().getPersistentDataContainer().get(key, PersistentDataType.STRING); - if(entry != null) { - v.getOwnerData().removeFromWhiteList(entry); - p.sendMessage("§eRemoved §f" + (entry.startsWith("player_") ? entry.substring(7) : entry) + "§e from the whitelist."); - ownershipGUI.whitelistGui(e.getView().getTopInventory(), p, v, false); - } - } - } - - // Retain Bukkit chat-event ordering and String message semantics for existing integrations. - @SuppressWarnings("deprecation") - @EventHandler - public void nameVehicle(AsyncPlayerChatEvent e) { - Player p = e.getPlayer(); - if(!naming.containsKey(p) && !addingToWhitelist.containsKey(p)) return; - e.setCancelled(true); - // Whitelist add - if(addingToWhitelist.containsKey(p)) { - new BukkitRunnable() { - @Override - public void run() { - ActiveVehicle v = addingToWhitelist.get(p); - addingToWhitelist.remove(p); - addingToWhitelistTimeout.remove(p); - if(e.getMessage().equalsIgnoreCase("cancel")) { - p.sendMessage("§cCancelled."); - return; - } - String playerName = e.getMessage().trim(); - String entry = "player_" + playerName; - if(v.getOwnerData().getWhiteList().contains(entry)) { - p.sendMessage("§c" + playerName + " is already on the whitelist."); - return; - } - v.getOwnerData().addToWhiteList(entry); - p.sendMessage("§aAdded §f" + playerName + "§a to the whitelist."); - } - }.runTask(VehicleFramework.plugin); - return; - } - if(!naming.containsKey(p)) return; - new BukkitRunnable() { - @Override - public void run() { - ActiveVehicle v = naming.get(p).getVehicle(); - if(get(v.getEntity()) == null) return; - v.setName(StringFormatter.formatHex(e.getMessage().replace("_", " "))); - naming.remove(p); - p.sendMessage("§aRenamed the vehicle to "+v.getName()); - p.playSound(p.getLocation(), Sound.BLOCK_NOTE_BLOCK_CHIME, 1f, 1f); - } - }.runTask(VehicleFramework.plugin); - } - @EventHandler public void playerLeave(PlayerQuitEvent e) { Player p = e.getPlayer(); packetSneak.remove(p.getUniqueId()); @@ -869,10 +691,7 @@ public void playerLeave(PlayerQuitEvent e) { } pendingEntityVehicle.remove(p); pendingEntitySeat.remove(p); - addingToWhitelist.remove(p); - addingToWhitelistTimeout.remove(p); ejectCooldown.remove(p); - cancelTakeover(p); for (Map.Entry entry : vehicles.entrySet()) { ActiveVehicle v = entry.getValue(); if(v.isPassenger(p, false)) { @@ -1072,7 +891,7 @@ private boolean allowTicketSeat(Player p, ActiveVehicle v, Seat seat) { } ActiveVehicle source = v.ticketSource(); boolean has = VehicleTicketItems.inventoryHas(p, source.getOwnerData().getTicketId()); - return VehicleTicketRules.mayEnter(v.getOwnerData(), source.getOwnerData(), p.getName(), seat.getType(), has); + return VehicleTicketRules.mayEnter(v.getOwnerData(), source.getOwnerData(), OwnershipAccess.key(p), seat.getType(), has); } private void putEjectCooldown(Player player, ActiveVehicle vehicle) { @@ -1232,11 +1051,8 @@ public void seatSelect(InventoryClickEvent e) { return; } if(e.getSlot() == 25) { - // Ownership settings – only owner can open this - if(v.getOwnerData().getOwner().equalsIgnoreCase("player_" + p.getName())) { - p.closeInventory(); - ownershipGUI.ownershipGui(null, p, v, true); - } + // Ownership settings – the owner, or staff on an owned vehicle + ownership.openMenu(p, v); return; } if(e.getSlot() == 26) { @@ -1270,7 +1086,7 @@ public void seatSelect(InventoryClickEvent e) { if(i.getType().equals(Material.YELLOW_CONCRETE)) { // Owner can eject the player occupying this seat if(seat != null && !seat.getType().equals(SeatType.ENTITY) - && v.getOwnerData().getOwner().equalsIgnoreCase("player_" + p.getName())) { + && OwnershipAccess.isOwner(p, v.getOwnerData())) { Entity occupant = seat.getEntity(); if(occupant instanceof Player) { Player ejected = (Player) occupant; @@ -1281,7 +1097,7 @@ public void seatSelect(InventoryClickEvent e) { v.dismountPassenger(ejected, false); putEjectCooldown(ejected, v); ejected.sendMessage("§cYou have been removed from this vehicle by the owner and cannot re-enter it for 60 seconds."); - p.sendMessage("§aEjected §e" + ejected.getName() + "§a from the vehicle."); + p.sendMessage("§aEjected §e" + OwnerNames.player(ejected) + "§a from the vehicle."); inv.seatSelection(p.getOpenInventory().getTopInventory(), p, v, false); return; } @@ -1688,21 +1504,4 @@ public Optional readStoredVehicle(String vehicleUuid) { } return persistence.readMeta(vehicleUuid); } - - public void clearOwnership(String vehicleUuid) { - if (vehicleUuid == null || vehicleUuid.isBlank()) { - return; - } - ActiveVehicle live = get(vehicleUuid); - if (live != null) { - live.getOwnerData().setOwner("none"); - live.getOwnerData().setWhiteListed(false); - saveLive(live); - return; - } - VehiclePersistence persistence = VehiclePersistence.current(); - if (persistence != null) { - persistence.clearOwnership(vehicleUuid); - } - } } diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/inventory/WhitelistPickerHolder.java b/src/main/java/net/tfminecraft/vehicleframework/managers/inventory/WhitelistPickerHolder.java new file mode 100644 index 0000000..ab0517c --- /dev/null +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/inventory/WhitelistPickerHolder.java @@ -0,0 +1,32 @@ +package net.tfminecraft.vehicleframework.managers.inventory; + +import java.util.List; +import java.util.Optional; + +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.vehicleframework.enums.VFGUI; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +/** The nearby-player picker, remembering each head's character session and the name shown. */ +public class WhitelistPickerHolder extends VFInventoryHolder { + + /** A player offered by the picker. */ + public record Candidate(CharacterSession session, String name) { + } + + private final List candidates; + + public WhitelistPickerHolder(ActiveVehicle vehicle, List candidates) { + super(vehicle.getUUID(), VFGUI.WHITELIST_PICKER, vehicle); + this.candidates = List.copyOf(candidates); + } + + public List getCandidates() { + return candidates; + } + + /** The candidate shown in a slot. */ + public Optional candidate(int slot) { + return slot >= 0 && slot < candidates.size() ? Optional.of(candidates.get(slot)) : Optional.empty(); + } +} diff --git a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainTapeInteract.java b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainTapeInteract.java index 98b668c..3e9a907 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainTapeInteract.java +++ b/src/main/java/net/tfminecraft/vehicleframework/tracks/TrainTapeInteract.java @@ -7,6 +7,7 @@ import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.ownership.OwnershipAccess; import net.tfminecraft.vehicleframework.permissions.Permissions; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; import net.tfminecraft.vehicleframework.vehicles.handlers.TrainHandler; @@ -50,8 +51,7 @@ private static boolean canEdit(Player player, ActiveVehicle vehicle) { if (Permissions.isAdmin(player)) { return true; } - String owner = vehicle.getOwnerData().getOwner(); - return owner != null && owner.equalsIgnoreCase("player_" + player.getName()); + return OwnershipAccess.isOwner(player, vehicle.getOwnerData()); } private static boolean toggleRecord(Player player, TrainHandler train, ItemStack hand) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java b/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java index 04fb804..f66b8c3 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java +++ b/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java @@ -31,10 +31,16 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe completions.add("takeover"); completions.add("reload"); completions.add("track"); + completions.add("admin"); } return completions; } + if (args[0].equalsIgnoreCase("admin") && args.length == 2 && Permissions.isAdmin(sender)) { + completions.add("quarantine"); + return completions; + } + if (args[0].equalsIgnoreCase("spawn") && args.length == 2) { if (!Permissions.canSpawn(sender)) return completions; for (Vehicle v : VehicleLoader.get().values()) { diff --git a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketInteract.java b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketInteract.java index 7870cba..883bcb1 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketInteract.java +++ b/src/main/java/net/tfminecraft/vehicleframework/vehicles/handlers/VehicleTicketInteract.java @@ -7,6 +7,7 @@ import net.tfminecraft.tlibs.TLibs; import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.ownership.OwnershipAccess; import net.tfminecraft.vehicleframework.permissions.Permissions; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; @@ -46,8 +47,7 @@ private static boolean canMint(Player player, ActiveVehicle vehicle) { if (Permissions.isAdmin(player)) { return true; } - String owner = vehicle.ticketSource().getOwnerData().getOwner(); - return owner != null && owner.equalsIgnoreCase("player_" + player.getName()); + return OwnershipAccess.isOwner(player, vehicle.ticketSource().getOwnerData()); } private static void mintOne(Player player, ItemStack hand, ActiveVehicle source) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index db7937d..e6031b5 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -110,6 +110,12 @@ skin-item: m.utils.vehicle_skin destroy-item: v.blaze_rod ticket-item: v.paper +##Who owns vehicles. Read at startup only. +## characters: owners and whitelist entries are RPCharacters characters (needs RPCharacters) +## names: owners and whitelist entries are account names (servers without RPCharacters) +##Leaving it unset while RPCharacters is installed turns ownership off until it is set. +#ownership-identity: characters + ##Can players set up whitelists for their vehicle allow-whitelist: true whitelisted-by-default: true diff --git a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java index 4b6847b..b2bcfc5 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/VehicleFrameworkCoverageTest.java @@ -105,11 +105,52 @@ class VehicleFrameworkCoverageTest { assertTrue(Files.isRegularFile(temp.resolve("plugin/data/vehicles.db"))); assertTrue(Files.isRegularFile(temp.resolve("plugin/templates/weapons/gun_turret.yml"))); assertNotNull(FuelLoader.getByString("coal")); assertFalse(WeaponTemplateLoader.map.isEmpty()); - verify(plugins, times(6)).registerEvents(any(Listener.class), same(plugin)); + verify(plugins, times(7)).registerEvents(any(Listener.class), same(plugin)); verify(protocol).addPacketListener(any()); verify(plugins, never()).disablePlugin(plugin); plugin.onDisable(); assertNull(VehicleFramework.getVehicleRepository()); } + @Test void ownershipIdentityIsReadOnceAtEnableAndAReloadOnlyWarnsAboutChanges() throws Exception { + plugin.createFolders(); plugin.createConfigs(); + Path config = temp.resolve("plugin/config.yml"); + String defaults = Files.readString(config); + assertTrue(defaults.contains("#ownership-identity: characters"), "the default config leaves the key unset"); + try { + plugin.onEnable(); + assertEquals(net.tfminecraft.vehicleframework.api.OwnershipIdentity.NAMES, + net.tfminecraft.vehicleframework.api.VehicleFrameworkApi.ownershipIdentity()); + plugin.reload(); + verify(log, never()).warning(contains("read at startup only")); + Files.writeString(config, defaults + "\nownership-identity: characters\n"); + plugin.reload(); + verify(log).warning(contains("ownership-identity is read at startup only; ownership stays names until a restart.")); + assertEquals(net.tfminecraft.vehicleframework.api.OwnershipIdentity.NAMES, + net.tfminecraft.vehicleframework.api.VehicleFrameworkApi.ownershipIdentity()); + plugin.configureOwnership(); + assertEquals(net.tfminecraft.vehicleframework.api.OwnershipIdentity.CHARACTERS, + net.tfminecraft.vehicleframework.api.VehicleFrameworkApi.ownershipIdentity()); + plugin.reload(); + verify(log, times(1)).warning(contains("read at startup only")); + } finally { + net.tfminecraft.vehicleframework.ownership.OwnershipMode.configure("names", false); + } + } + + @Test void aMissingKeyWithRpCharactersInstalledDisablesOwnershipLoudly() throws Exception { + when(plugins.getPlugin("RPCharacters")).thenReturn(mock(Plugin.class)); + try { + plugin.onEnable(); + assertEquals(net.tfminecraft.vehicleframework.api.OwnershipIdentity.DISABLED, + net.tfminecraft.vehicleframework.api.VehicleFrameworkApi.ownershipIdentity()); + verify(log, atLeastOnce()).severe(contains("VEHICLE OWNERSHIP IS DISABLED")); + verify(plugins, never()).disablePlugin(plugin); + assertNull(new ConfigLoader().readOwnershipIdentity(temp.resolve("plugin/missing.yml").toFile())); + verify(log).warning(contains("Could not read ownership-identity")); + } finally { + net.tfminecraft.vehicleframework.ownership.OwnershipMode.configure("names", false); + } + } + @Test void reloadRemovesDeletedFuelVehicleAndAmmunitionDefinitions() throws Exception { plugin.createFolders(); plugin.createConfigs(); Path oldVehicle = temp.resolve("plugin/vehicles/old.yml"), oldAmmo = temp.resolve("plugin/ammunition/old.yml"); @@ -203,7 +244,7 @@ void coldStartupLoadsValidDefinitionsDespiteABrokenSiblingFile(String folder, St () -> assertEquals(Set.of("good_shell"), AmmunitionLoader.get().keySet()), () -> verify(log, atLeastOnce()).warning(contains("bad.yml")), () -> assertNotNull(VehicleFramework.getVehicleRepository()), - () -> verify(plugins, times(6)).registerEvents(any(Listener.class), same(plugin)), + () -> verify(plugins, times(7)).registerEvents(any(Listener.class), same(plugin)), () -> verify(plugins, never()).disablePlugin(plugin), () -> verify(log).info(contains("Setup complete!"))); } diff --git a/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java index 282ade8..f66fb13 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/data/RuntimeValuesCoverageTest.java @@ -172,22 +172,17 @@ void ownerSettingsSupportWhitelistAndStableTicketIdentity() { } @Test - void namingTimeoutAndStoredSummariesRetainTheirPublicValues() { - ActiveVehicle vehicle = r.vehicle(); - NamingData naming = new NamingData(vehicle); - assertSame(vehicle, naming.getVehicle()); - for (int i = 0; i < 30; i++) assertFalse(naming.tick()); - assertTrue(naming.tick()); - assertTrue(naming.tick()); + void storedSummariesRetainTheirPublicValues() { StoredVehicleMeta stored = new StoredVehicleMeta("uuid", "Wagon", "cart", "player_alex"); assertEquals("uuid", stored.getUuid()); assertEquals("Wagon", stored.getName()); assertEquals("cart", stored.getTypeId()); assertEquals("player_alex", stored.getOwner()); for (String owner : - Arrays.asList(null, "", " ", "none", "player_", "player_none", "company_acme")) + Arrays.asList(null, "", " ", "none", "player_", "player_none", "company_acme", "char_none", "char_x")) assertFalse(StoredVehicleMeta.isPlayerOwner(owner)); assertTrue(StoredVehicleMeta.isPlayerOwner("PLAYER_alex")); + assertTrue(StoredVehicleMeta.isPlayerOwner("char_" + java.util.UUID.randomUUID())); Location location = new Location(r.world, 1, 64, 2); OwnedVehicleSummary summary = new OwnedVehicleSummary( diff --git a/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java index 80c1236..2596c28 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/database/DatabaseRuntimeCoverageTest.java @@ -74,7 +74,6 @@ class DatabaseRuntimeCoverageTest { assertTrue(closed.listByOwner("player_Alice").isEmpty()); assertTrue(closed.listPlayerOwned().isEmpty()); assertTrue(closed.readMeta(ID).isEmpty()); - assertFalse(closed.clearOwnership(ID)); assertFalse(closed.applyStoredDecay(ID, .1, .03)); assertFalse(closed.saveLive(snapshot(PAYLOAD))); } @@ -83,12 +82,11 @@ class DatabaseRuntimeCoverageTest { assertFalse(new VehiclePersistence(broken).hasLiveInChunk("world", 0, 0)); } - @Test void ownershipAndDecayChangesCommitTogetherAndSurviveRestart() { + @Test void decayChangesCommitAndSurviveRestart() { Path file = directory.resolve("mutations.db"); VehicleRepository repository = VehicleRepository.open(file.toFile()); try (var templates = mockStatic(VehicleLoader.class)) { VehiclePersistence persistence = new VehiclePersistence(repository); - assertFalse(persistence.clearOwnership("missing")); assertFalse(persistence.applyStoredDecay("missing", .1, .03)); assertTrue(persistence.loadIncomplete("missing").isEmpty()); assertFalse(persistence.tombstone(null)); assertFalse(persistence.tombstone(" ")); @@ -102,21 +100,16 @@ class DatabaseRuntimeCoverageTest { assertTrue(persistence.applyStoredDecay(ID, 0, .03)); assertEquals(1, repository.findLive(ID).orElseThrow().getRevision()); assertTrue(persistence.applyStoredDecay(ID, .1, .03)); - assertTrue(persistence.clearOwnership(ID)); assertTrue(persistence.checkpointWal(false)); assertTrue(persistence.checkpointWal(true)); } finally { repository.close(); } repository = VehicleRepository.open(file.toFile()); try { VehicleSnapshot row = repository.findLive(ID).orElseThrow(); var payload = JsonParser.parseString(row.getPayloadJson()).getAsJsonObject(); - assertEquals(3, row.getRevision()); assertEquals("none", row.getOwner()); - assertEquals("none", payload.get("owner").getAsString()); - assertFalse(payload.get("whitelisted").getAsBoolean()); - assertTrue(payload.getAsJsonArray("whitelist").isEmpty()); + assertEquals(2, row.getRevision()); assertEquals("player_Alice", payload.get("owner").getAsString()); assertEquals(15, payload.getAsJsonObject("components").getAsJsonObject("hull").get("damage").getAsDouble()); repository.saveLive(snapshot("{")); VehiclePersistence persistence = new VehiclePersistence(repository); - assertFalse(persistence.clearOwnership(ID)); assertFalse(persistence.applyStoredDecay(ID, .1, .03)); assertEquals("{", repository.findLive(ID).orElseThrow().getPayloadJson()); } finally { repository.close(); } diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java index 2065fd3..ed92e65 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java @@ -30,6 +30,12 @@ import net.tfminecraft.vehicleframework.vehicles.*; import net.tfminecraft.vehicleframework.vehicles.state.VehicleState; import net.tfminecraft.vehicleframework.weapons.ammunition.Ammunition; +import net.tfminecraft.tlibs.character.*; +import net.tfminecraft.vehicleframework.api.*; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipEditCheckEvent; +import net.tfminecraft.vehicleframework.managers.inventory.WhitelistPickerHolder; +import net.tfminecraft.vehicleframework.ownership.OwnershipActions; +import net.tfminecraft.vehicleframework.ownership.OwnershipFixture; class CommandAndOwnershipCoverageTest { private final List scopes = new ArrayList<>(); @@ -46,6 +52,12 @@ class CommandAndOwnershipCoverageTest { private VehicleFramework previousPlugin, plugin; private boolean previousAllowWhitelist; private MockedStatic framework; + private MockedStatic bukkit; + private MockedStatic characters; + private final org.bukkit.plugin.PluginManager plugins = mock(org.bukkit.plugin.PluginManager.class); + private final org.bukkit.plugin.ServicesManager services = mock(org.bukkit.plugin.ServicesManager.class); + private final CharacterSession kira = OwnershipFixture.session("alex", "kira", 1); + private final CharacterSession bren = OwnershipFixture.session("bren", "bren", 2); private final Map previousAmmo = new HashMap<>(AmmunitionLoader.get()); private final Map previousVehicles = new HashMap<>(VehicleLoader.get()); @@ -63,11 +75,17 @@ class CommandAndOwnershipCoverageTest { }); keep(mockConstruction(ItemStack.class, (item, context) -> configure(item, (Material) context.arguments().get(0)))); previousAllowWhitelist = Cache.allowWhitelist; Cache.allowWhitelist = true; + // Ownership buttons ask listeners before they are shown. + bukkit = keep(mockStatic(Bukkit.class)); bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getServicesManager).thenReturn(services); bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + characters = keep(mockStatic(Characters.class)); + owner.setOwner("player_Alex"); AmmunitionLoader.get().clear(); VehicleLoader.get().clear(); } @AfterEach void cleanup() throws Exception { Collections.reverse(scopes); for (AutoCloseable scope : scopes) scope.close(); VehicleFramework.plugin = previousPlugin; Cache.allowWhitelist = previousAllowWhitelist; + OwnershipFixture.mode(OwnershipIdentity.NAMES); OwnershipFixture.lookup(null); AmmunitionLoader.get().clear(); AmmunitionLoader.get().putAll(previousAmmo); VehicleLoader.get().clear(); VehicleLoader.get().putAll(previousVehicles); } @@ -151,6 +169,7 @@ void playerOnlyCommandsRejectConsoleWithoutPlayerCasts(String verb) { void ownershipMenuReflectsWhitelistTicketsAndLinkedTrainOwnership(boolean enabled) { owner.setWhiteListed(enabled); owner.setWhiteList(new ArrayList<>(List.of("player_Alex", "group_staff"))); ActiveVehicle locomotive = mock(ActiveVehicle.class); OwnerData trainOwner = new OwnerData(); trainOwner.setTicketsEnabled(enabled); + trainOwner.setOwner("player_Alex"); when(locomotive.getOwnerData()).thenReturn(trainOwner); when(vehicle.ticketSource()).thenReturn(locomotive); ownership.ownershipGui(null, player, vehicle, true); Inventory menu = menus.get(0); assertEquals(enabled ? Material.LIME_DYE : Material.GRAY_DYE, menu.getItem(0).getType()); @@ -198,6 +217,115 @@ void ownershipMenuReflectsWhitelistTicketsAndLinkedTrainOwnership(boolean enable when(command.getName()).thenReturn("other"); assertNull(completion.onTabComplete(player, command, "other", new String[]{""})); } + /** Stands in for TLibs 2.5.0's facade. */ + public static final class Lookup { + public static List findByName(String name) { return List.of(); } + } + + private void charactersMode() { + OwnershipFixture.mode(OwnershipIdentity.CHARACTERS); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of(kira)); + characters.when(() -> Characters.isCurrent(kira)).thenReturn(true); + owner.setOwner(VehicleOwners.character(kira.ref().characterId())); + characters.when(() -> Characters.info(kira.ref().characterId())).thenReturn(Optional.of( + new CharacterInfo(kira.ref(), "Kira", CharacterStatus.ALIVE, null, Set.of(), 0, null))); + OfflinePlayer account = mock(OfflinePlayer.class); when(account.getName()).thenReturn("Steve"); + bukkit.when(() -> Bukkit.getOfflinePlayer(kira.ref().ownerId())).thenReturn(account); + OfflinePlayer other = mock(OfflinePlayer.class); when(other.getName()).thenReturn("BrenAccount"); + bukkit.when(() -> Bukkit.getOfflinePlayer(bren.ref().ownerId())).thenReturn(other); + } + + @Test void characterOwnersSeeEveryControlAndTheirCharactersName() { + charactersMode(); + ownership.ownershipGui(null, player, vehicle, true); Inventory menu = menus.get(0); + for (int slot : new int[]{0, 2, 4, 6, 8}) assertNotEquals(Material.GRAY_STAINED_GLASS_PANE, menu.getItem(slot).getType(), "slot " + slot); + assertEquals("§7Owner: §fKira", menu.getItem(OwnershipGUIManager.OWNER_SLOT).getItemMeta().getDisplayName()); + assertEquals(List.of("§7Click to pick a nearby player"), menu.getItem(2).getItemMeta().getLore()); + when(player.hasPermission("vf.admin")).thenReturn(true); + ownership.ownershipGui(menu, player, vehicle, false); + assertEquals("§7Owner: §fKira (Steve)", menu.getItem(OwnershipGUIManager.OWNER_SLOT).getItemMeta().getDisplayName()); + } + + @Test void controlsTheViewerMayNotUseAreHidden() { + charactersMode(); + doAnswer(call -> { + if (call.getArgument(0) instanceof VehicleOwnershipEditCheckEvent check + && check.getReason().orElse(null) == OwnershipReason.TICKETS_TOGGLE) check.deny(); + return null; + }).when(plugins).callEvent(any(VehicleOwnershipEditCheckEvent.class)); + Inventory menu = inventory(27, null); ownership.ownershipGui(menu, player, vehicle, false); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, menu.getItem(8).getType()); + assertEquals(Material.BARRIER, menu.getItem(6).getType()); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of(bren)); + characters.when(() -> Characters.isCurrent(bren)).thenReturn(true); + ownership.ownershipGui(menu, player, vehicle, false); + for (int slot : new int[]{0, 2, 6, 8}) assertEquals(Material.GRAY_STAINED_GLASS_PANE, menu.getItem(slot).getType(), "slot " + slot); + assertEquals(Material.CHEST, menu.getItem(4).getType()); + } + + @Test void characterWhitelistsShowCapturedNamesAndGreyOutOldEntries() { + charactersMode(); + String crew = VehicleOwners.whitelistEntry(bren.ref(), "Bren"); + owner.setWhiteList(new ArrayList<>(List.of(crew, "player_Old"))); + ownership.whitelistGui(null, player, vehicle, true); Inventory menu = menus.get(0); + assertEquals("§eBren", menu.getItem(0).getItemMeta().getDisplayName()); + assertEquals(List.of("§cClick to remove from whitelist"), menu.getItem(0).getItemMeta().getLore()); + assertEquals(crew, menu.getItem(0).getItemMeta().getPersistentDataContainer().get(new NamespacedKey("vehicleframework", "vf_whitelist_entry"), PersistentDataType.STRING)); + assertEquals("§8Old", menu.getItem(1).getItemMeta().getDisplayName()); + assertEquals("§7Old entry: no longer grants access", menu.getItem(1).getItemMeta().getLore().getFirst()); + when(player.hasPermission("vf.admin")).thenReturn(true); + ownership.whitelistGui(menu, player, vehicle, false); + assertEquals("§7Account: §fBrenAccount", menu.getItem(0).getItemMeta().getLore().getFirst()); + } + + @Test void thePickerShowsUpToTwentyFiveHeadsAndTypedNamesOnlyWhenTLibsCanFindThem() { + charactersMode(); + List many = new ArrayList<>(); + for (int i = 0; i < 30; i++) many.add(new WhitelistPickerHolder.Candidate(OwnershipFixture.session("a" + i, "c" + i, i), "Player " + i)); + OwnershipFixture.lookup(Lookup.class); + ownership.pickerGui(player, vehicle, many); Inventory picker = menus.get(0); + WhitelistPickerHolder holder = (WhitelistPickerHolder) picker.getHolder(); + assertEquals(VFGUI.WHITELIST_PICKER, holder.getType()); assertEquals(25, holder.getCandidates().size()); + assertEquals("§ePlayer 24", picker.getItem(24).getItemMeta().getDisplayName()); + assertEquals(Material.PLAYER_HEAD, picker.getItem(0).getType()); + assertEquals(Material.WRITABLE_BOOK, picker.getItem(OwnershipGUIManager.PICKER_TYPED_SLOT).getType()); + assertEquals("§7Back", picker.getItem(OwnershipGUIManager.BACK_SLOT).getItemMeta().getDisplayName()); + assertTrue(holder.candidate(25).isEmpty()); assertTrue(holder.candidate(-1).isEmpty()); + assertEquals("Player 3", holder.candidate(3).orElseThrow().name()); + verify(player).openInventory(picker); + OwnershipFixture.lookup(String.class); + ownership.pickerGui(player, vehicle, List.of()); Inventory empty = menus.get(1); + assertEquals("§7Nobody nearby", empty.getItem(13).getItemMeta().getDisplayName()); + assertEquals(Material.GRAY_STAINED_GLASS_PANE, empty.getItem(OwnershipGUIManager.PICKER_TYPED_SLOT).getType()); + } + + @Test void findVehiclesListsTheCurrentCharactersVehiclesAndExplainsWhenItCannot() { + charactersMode(); + assertTrue(run(player, "findvehicles")); + verify(vehicles).listOwnedVehicles(VehicleOwners.character(kira.ref().characterId())); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.empty()); + assertTrue(run(player, "findvehicles")); verify(player).sendMessage(OwnershipActions.NEED_CHARACTER); + OwnershipFixture.mode(OwnershipIdentity.DISABLED); + assertTrue(run(player, "findvehicles")); verify(player).sendMessage(OwnershipActions.DISABLED); + verify(vehicles, times(1)).listOwnedVehicles(anyString()); + } + + @Test void staffCanNameTheTakeoverTargetAndListTheQuarantine() { + assertNull(new TabCompletion().onTabComplete(player, command, "vf", new String[]{"admin", ""})); + when(player.hasPermission("vf.admin")).thenReturn(true); + assertEquals(List.of("quarantine"), new TabCompletion().onTabComplete(player, command, "vf", new String[]{"admin", ""})); + assertTrue(new TabCompletion().onTabComplete(player, command, "vf", new String[]{""}).contains("admin")); + assertTrue(run(player, "takeover", "Bren")); verify(vehicles).startTakeover(player, "Bren"); + assertFalse(run(player, "takeover", "Bren", "extra")); + assertFalse(run(player, "admin", "other")); assertFalse(run(player, "admin")); + when(services.getRegistrations(VehicleQuarantineSource.class)).thenReturn(List.of()); + assertTrue(run(player, "admin", "QUARANTINE")); + verify(player).sendMessage(contains("No quarantine list is registered")); + CommandSender console = mock(CommandSender.class); when(console.hasPermission("vf.admin")).thenReturn(true); + assertTrue(run(console, "admin", "quarantine")); + verify(console).sendMessage(contains("No quarantine list is registered")); + } + private boolean run(CommandSender sender, String... args) { return commands.onCommand(sender, command, "vf", args); } private Inventory inventory(int size, InventoryHolder holder) { Inventory menu = mock(Inventory.class); ItemStack[] contents = new ItemStack[size]; diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java index 30700c2..d681848 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/InventoryManagerCoverageTest.java @@ -64,6 +64,9 @@ class InventoryManagerCoverageTest { when(VehicleFramework.plugin.getServer()).thenReturn(server); keep(mockConstruction(ItemStack.class, (item, context) -> configure(item, (Material) context.arguments().get(0)))); ownership = keep(mockConstruction(OwnershipGUIManager.class)); + // The ownership button asks listeners whether the menu may open. + keep(mockStatic(org.bukkit.Bukkit.class)).when(org.bukkit.Bukkit::getPluginManager) + .thenReturn(mock(org.bukkit.plugin.PluginManager.class)); when(server.createInventory(any(InventoryHolder.class), anyInt(), anyString())).thenAnswer(call -> { Inventory inventory = inventory(call.getArgument(1), call.getArgument(0)); created.add(inventory); return inventory; diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java new file mode 100644 index 0000000..b10c001 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java @@ -0,0 +1,658 @@ +package net.tfminecraft.vehicleframework.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.ServicesManager; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +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.character.CharacterStatus; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.VFLogger; +import net.tfminecraft.vehicleframework.VehicleFramework; +import net.tfminecraft.vehicleframework.api.OwnershipIdentity; +import net.tfminecraft.vehicleframework.api.OwnershipReason; +import net.tfminecraft.vehicleframework.api.VehicleOwners; +import net.tfminecraft.vehicleframework.cache.Cache; +import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.database.VehiclePersistence; +import net.tfminecraft.vehicleframework.enums.VFGUI; +import net.tfminecraft.vehicleframework.events.VehicleOwnerClaimedEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangeEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipEditCheckEvent; +import net.tfminecraft.vehicleframework.managers.inventory.VFInventoryHolder; +import net.tfminecraft.vehicleframework.managers.inventory.WhitelistPickerHolder; +import net.tfminecraft.vehicleframework.managers.inventory.WhitelistPickerHolder.Candidate; +import net.tfminecraft.vehicleframework.ownership.OwnershipActions; +import net.tfminecraft.vehicleframework.ownership.OwnershipFixture; +import net.tfminecraft.vehicleframework.ownership.PendingActions.Kind; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class OwnershipManagerTest { + @org.junit.jupiter.api.BeforeAll + static void initializeSounds() { + net.tfminecraft.vehicleframework.test.RegistryFixture.initialize(); + } + + /** Stands in for TLibs 2.5.0's facade. */ + public static final class Lookup { + static final List results = new ArrayList<>(); + + public static List findByName(String name) { + return results; + } + } + + private final List scopes = new ArrayList<>(); + private final List events = new ArrayList<>(); + private final List tasks = new ArrayList<>(); + private final PluginManager plugins = mock(PluginManager.class); + private final ServicesManager services = mock(ServicesManager.class); + private final BukkitScheduler scheduler = mock(BukkitScheduler.class); + private final VehiclePersistence persistence = mock(VehiclePersistence.class); + private final VehicleManager vehicles = mock(VehicleManager.class); + private final World world = mock(World.class); + private final List online = new ArrayList<>(); + private MockedStatic characters; + private MockedConstruction guis; + private OwnershipManager manager; + private OwnershipGUIManager gui; + private VehicleFramework previousPlugin; + private boolean previousAllowWhitelist; + + private Player alex, staff, bystander; + private final CharacterSession kira = OwnershipFixture.session("alex", "kira", 1); + private final CharacterSession warden = OwnershipFixture.session("staff", "warden", 2); + private final CharacterSession bren = OwnershipFixture.session("other", "bren", 3); + + @BeforeEach + void setup() { + previousPlugin = VehicleFramework.plugin; + VehicleFramework plugin = mock(VehicleFramework.class); + when(plugin.getName()).thenReturn("VehicleFramework"); when(plugin.namespace()).thenReturn("vehicleframework"); + VehicleFramework.plugin = plugin; + MockedStatic bukkit = keep(mockStatic(Bukkit.class)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::getServicesManager).thenReturn(services); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + bukkit.when(Bukkit::getOnlinePlayers).thenAnswer(call -> online); + doAnswer(call -> events.add(call.getArgument(0))).when(plugins).callEvent(any(Event.class)); + when(scheduler.runTask(any(), any(Runnable.class))).thenAnswer(call -> task(call.getArgument(1))); + when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(call -> task(call.getArgument(1))); + characters = keep(mockStatic(Characters.class)); + keep(mockStatic(VFLogger.class)); + keep(mockStatic(VehiclePersistence.class)).when(VehiclePersistence::current).thenReturn(persistence); + when(persistence.saveLive(any(ActiveVehicle.class))).thenReturn(true); + guis = keep(mockConstruction(OwnershipGUIManager.class)); + alex = player("Alex", kira, 0); staff = player("Warden", warden, 2); bystander = player("Bren", bren, 5); + when(staff.hasPermission("vf.admin")).thenReturn(true); + previousAllowWhitelist = Cache.allowWhitelist; Cache.allowWhitelist = true; + OwnershipFixture.mode(OwnershipIdentity.CHARACTERS); + OwnershipFixture.lookup(Lookup.class); + Lookup.results.clear(); + manager = new OwnershipManager(vehicles); + gui = guis.constructed().getFirst(); + } + + @AfterEach + void cleanup() throws Exception { + for (int i = scopes.size() - 1; i >= 0; i--) scopes.get(i).close(); + VehicleFramework.plugin = previousPlugin; + Cache.allowWhitelist = previousAllowWhitelist; + OwnershipFixture.mode(OwnershipIdentity.NAMES); + OwnershipFixture.lookup(null); + } + + private T keep(T scope) { + scopes.add(scope); + return scope; + } + + private BukkitTask task(Runnable runnable) { + tasks.add(runnable); + BukkitTask task = mock(BukkitTask.class); + when(task.getTaskId()).thenReturn(tasks.size()); + return task; + } + + private Player player(String name, CharacterSession session, double x) { + Player player = mock(Player.class); + when(player.getName()).thenReturn(name); + when(player.getUniqueId()).thenReturn(session.ref().ownerId()); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, x, 64, 0)); + playing(player, session); + characters.when(() -> Characters.displayName(player)).thenReturn("Masked " + name); + online.add(player); + return player; + } + + private void playing(Player player, CharacterSession session) { + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.ofNullable(session)); + if (session != null) characters.when(() -> Characters.isCurrent(session)).thenReturn(true); + } + + private ActiveVehicle vehicle(String owner) { + ActiveVehicle vehicle = mock(ActiveVehicle.class, RETURNS_DEEP_STUBS); + OwnerData data = new OwnerData(); + data.setOwner(owner); + when(vehicle.getOwnerData()).thenReturn(data); + when(vehicle.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(vehicle.getName()).thenReturn("Cart"); + when(vehicle.ticketSource()).thenReturn(vehicle); + when(vehicle.getEntity().getLocation()).thenReturn(new Location(world, 0, 64, 0)); + when(vehicle.isDestroyed()).thenReturn(false); + when(vehicles.get(vehicle.getEntity())).thenReturn(vehicle); + return vehicle; + } + + private static String owner(CharacterSession session) { + return VehicleOwners.character(session.ref().characterId()); + } + + private ItemStack item(Material material, String entry) { + ItemStack item = mock(ItemStack.class, RETURNS_DEEP_STUBS); + when(item.getType()).thenReturn(material); + when(item.getItemMeta().getPersistentDataContainer().get(any(NamespacedKey.class), eq(PersistentDataType.STRING))).thenReturn(entry); + return item; + } + + private InventoryClickEvent click(Player player, InventoryHolder holder, int slot, ItemStack item) { + InventoryClickEvent event = mock(InventoryClickEvent.class, RETURNS_DEEP_STUBS); + Inventory top = mock(Inventory.class); + when(top.getHolder()).thenReturn(holder); + when(event.getView().getTopInventory()).thenReturn(top); + when(event.getWhoClicked()).thenReturn(player); + when(event.getCurrentItem()).thenReturn(item); + when(event.getSlot()).thenReturn(slot); + return event; + } + + private InventoryClickEvent menu(Player player, ActiveVehicle v, int slot) { + return click(player, new VFInventoryHolder(v.getUUID(), VFGUI.OWNERSHIP, v), slot, item(Material.PAPER, null)); + } + + private List fired(Class type) { + return events.stream().filter(type::isInstance).map(type::cast).toList(); + } + + private void chat(Player player, String message) { + AsyncPlayerChatEvent chat = new AsyncPlayerChatEvent(true, player, message, new HashSet<>()); + manager.chat(chat); + assertTrue(chat.isCancelled()); + tasks.removeLast().run(); + } + + private static CharacterInfo info(CharacterSession session, String name) { + return new CharacterInfo(session.ref(), name, CharacterStatus.ALIVE, null, Set.of(), 0, Instant.EPOCH); + } + + // ── Claims ────────────────────────────────────────────────────────────── + + @Test + void aClaimStampsTheCurrentCharacterAndDropsThePreviousCrew() { + ActiveVehicle v = vehicle("none"); + v.getOwnerData().setWhiteList(new ArrayList<>(List.of("char_old"))); + v.getOwnerData().setWhiteListed(true); + assertTrue(manager.claim(alex, v)); + assertEquals(owner(kira), v.getOwnerData().getOwner()); + assertTrue(v.getOwnerData().getWhiteList().isEmpty()); assertFalse(v.getOwnerData().isWhiteListed()); + VehicleOwnershipChangeEvent pre = fired(VehicleOwnershipChangeEvent.class).getFirst(); + assertEquals(OwnershipReason.CLAIM, pre.getReason()); assertEquals(kira.ref(), pre.getActor().orElseThrow()); + assertEquals(kira.ref(), pre.getTarget().orElseThrow()); + assertEquals(owner(kira), fired(VehicleOwnerClaimedEvent.class).getFirst().getNewOwner()); + assertTrue(fired(VehicleOwnershipChangedEvent.class).getFirst().isSuccess()); + verify(persistence).saveLive(v); + } + + @Test + void claimsFailClosedWithoutACharacterOnOwnedVehiclesAndWithoutTheAuthority() { + ActiveVehicle v = vehicle("none"); + playing(alex, null); + assertFalse(manager.claim(alex, v)); + playing(alex, kira); + for (String owned : List.of("char_none", "player_Alex", owner(bren))) { + v.getOwnerData().setOwner(owned); + assertFalse(manager.claim(alex, v), owned); + } + v.getOwnerData().setOwner("none"); + when(plugins.getPlugin("SimpleFactions")).thenReturn(mock(Plugin.class)); + assertFalse(manager.claim(alex, v)); + assertTrue(events.isEmpty()); + assertTrue(manager.claim(staff, v), "staff are not held back by the missing authority"); + characters.when(() -> Characters.isCurrent(any())).thenReturn(false); + v.getOwnerData().setOwner("none"); + assertFalse(manager.claim(staff, v), "a provider that reports no current session refuses"); + } + + @Test + void namesModeClaimsKeepTodaysAccountNamesAndWhitelist() { + OwnershipFixture.mode(OwnershipIdentity.NAMES); + ActiveVehicle v = vehicle("none"); + v.getOwnerData().setWhiteList(new ArrayList<>(List.of("player_Bob"))); + v.getOwnerData().setWhiteListed(true); + assertTrue(manager.claim(alex, v)); + assertEquals("player_Alex", v.getOwnerData().getOwner()); + assertEquals(List.of("player_Bob"), v.getOwnerData().getWhiteList()); assertTrue(v.getOwnerData().isWhiteListed()); + assertTrue(fired(VehicleOwnershipChangeEvent.class).getFirst().getActor().isEmpty()); + characters.verifyNoInteractions(); + } + + @Test + void aClaimThatCannotBeSavedIsUndoneAndReported() { + ActiveVehicle v = vehicle("none"); + when(persistence.saveLive(v)).thenReturn(false); + assertFalse(manager.claim(alex, v)); + assertEquals("none", v.getOwnerData().getOwner()); + verify(alex).sendMessage(OwnershipActions.SAVE_FAILED); + } + + // ── Ownership menu ────────────────────────────────────────────────────── + + @Test + void everyMenuClickChecksTheOwnerAgainSoAStaleMenuIsRefused() { + ActiveVehicle v = vehicle(owner(kira)); + InventoryClickEvent toggle = menu(alex, v, 0); + manager.ownershipClick(toggle); + verify(toggle).setCancelled(true); + assertTrue(v.getOwnerData().isWhiteListed()); + verify(gui).ownershipGui(toggle.getView().getTopInventory(), alex, v, false); + v.getOwnerData().setOwner(owner(warden)); + manager.ownershipClick(menu(alex, v, 6)); + verify(alex).sendMessage(OwnershipActions.NOT_OWNER); + assertEquals(owner(warden), v.getOwnerData().getOwner()); + playing(alex, OwnershipFixture.session("alex", "alt", 4)); + v.getOwnerData().setOwner(owner(kira)); + manager.ownershipClick(menu(alex, v, 6)); + verify(alex, times(2)).sendMessage(OwnershipActions.NOT_OWNER); + assertEquals(owner(kira), v.getOwnerData().getOwner(), "a sibling character on the same account is a stranger"); + } + + @Test + void clearingOwnershipEmptiesTheCrewInCharactersModeOnly() { + ActiveVehicle v = vehicle(owner(kira)); + v.getOwnerData().setWhiteList(new ArrayList<>(List.of("char_crew"))); v.getOwnerData().setWhiteListed(true); + manager.ownershipClick(menu(alex, v, 6)); + assertEquals("none", v.getOwnerData().getOwner()); + assertTrue(v.getOwnerData().getWhiteList().isEmpty()); assertFalse(v.getOwnerData().isWhiteListed()); + verify(alex).sendMessage("§7Ownership of §fCart§7 has been removed."); + assertEquals(OwnershipReason.CLEAR, fired(VehicleOwnershipChangeEvent.class).getFirst().getReason()); + assertEquals("none", fired(VehicleOwnershipChangeEvent.class).getFirst().getNewOwner()); + OwnershipFixture.mode(OwnershipIdentity.NAMES); + ActiveVehicle named = vehicle("player_Alex"); + named.getOwnerData().setWhiteList(new ArrayList<>(List.of("player_Bob"))); named.getOwnerData().setWhiteListed(true); + manager.ownershipClick(menu(alex, named, 6)); + assertEquals("none", named.getOwnerData().getOwner()); + assertEquals(List.of("player_Bob"), named.getOwnerData().getWhiteList()); assertFalse(named.getOwnerData().isWhiteListed()); + } + + @Test + void theTicketsToggleChangesAndChecksTheLocomotive() { + ActiveVehicle car = vehicle(owner(kira)), loco = vehicle(owner(bren)); + when(car.ticketSource()).thenReturn(loco); + manager.ownershipClick(menu(alex, car, 8)); + verify(alex).sendMessage(OwnershipActions.NOT_OWNER); + assertFalse(loco.getOwnerData().isTicketsEnabled()); + loco.getOwnerData().setOwner(owner(kira)); + manager.ownershipClick(menu(alex, car, 8)); + assertTrue(loco.getOwnerData().isTicketsEnabled()); assertFalse(car.getOwnerData().isTicketsEnabled()); + VehicleOwnershipChangeEvent pre = fired(VehicleOwnershipChangeEvent.class).getFirst(); + assertSame(loco, pre.getVehicle()); assertEquals(OwnershipReason.TICKETS_TOGGLE, pre.getReason()); + assertSame(loco, fired(VehicleOwnershipEditCheckEvent.class).getLast().getVehicle()); + } + + @Test + void anEditCheckVetoRefusesTheClickBeforeAnyChange() { + ActiveVehicle v = vehicle(owner(kira)); + doAnswer(call -> { + if (call.getArgument(0) instanceof VehicleOwnershipEditCheckEvent check) check.deny(); + return null; + }).when(plugins).callEvent(any(VehicleOwnershipEditCheckEvent.class)); + manager.ownershipClick(menu(alex, v, 0)); + verify(alex).sendMessage(OwnershipActions.NOT_ALLOWED); + assertFalse(v.getOwnerData().isWhiteListed()); + assertTrue(fired(VehicleOwnershipChangeEvent.class).isEmpty()); + } + + @Test + void theWhitelistViewIsForTheOwnerOrStaff() { + ActiveVehicle v = vehicle(owner(kira)); + manager.ownershipClick(menu(alex, v, 4)); + verify(gui).whitelistGui(null, alex, v, true); + manager.ownershipClick(menu(staff, v, 4)); + verify(gui).whitelistGui(null, staff, v, true); + manager.ownershipClick(menu(bystander, v, 4)); + verify(bystander).sendMessage(OwnershipActions.NOT_OWNER); + playing(bystander, null); + manager.ownershipClick(menu(bystander, v, 4)); + verify(bystander).sendMessage(OwnershipActions.NEED_CHARACTER); + verify(gui, never()).whitelistGui(null, bystander, v, true); + } + + @Test + void menuClicksIgnoreOtherMenusFillerAndDestroyedVehicles() { + ActiveVehicle v = vehicle(owner(kira)); + InventoryClickEvent other = click(alex, new VFInventoryHolder(v.getUUID(), VFGUI.SEAT_SELECTION, v), 0, item(Material.PAPER, null)); + manager.ownershipClick(other); verify(other, never()).setCancelled(true); + InventoryClickEvent foreign = click(alex, mock(InventoryHolder.class), 0, item(Material.PAPER, null)); + manager.whitelistClick(foreign); verify(foreign, never()).setCancelled(true); + InventoryClickEvent detached = click(alex, new VFInventoryHolder(v.getUUID(), VFGUI.OWNERSHIP), 0, item(Material.PAPER, null)); + manager.ownershipClick(detached); verify(detached).setCancelled(true); + manager.ownershipClick(click(alex, new VFInventoryHolder(v.getUUID(), VFGUI.OWNERSHIP, v), 0, null)); + manager.ownershipClick(click(alex, new VFInventoryHolder(v.getUUID(), VFGUI.OWNERSHIP, v), 0, item(Material.GRAY_STAINED_GLASS_PANE, null))); + manager.ownershipClick(menu(alex, v, 13)); + assertFalse(v.getOwnerData().isWhiteListed()); + when(v.isDestroyed()).thenReturn(true); + manager.ownershipClick(menu(alex, v, 0)); + verify(alex).closeInventory(); + assertTrue(events.isEmpty()); + } + + @Test + void openingTheMenuFromTheSeatMenuNeedsOwnershipOrStaff() { + ActiveVehicle v = vehicle(owner(kira)); + manager.openMenu(alex, v); + verify(gui).ownershipGui(null, alex, v, true); + manager.openMenu(bystander, v); + verify(gui, never()).ownershipGui(null, bystander, v, true); + assertSame(gui, manager.getGui()); assertNotNull(manager.getPending()); + } + + // ── Whitelist ─────────────────────────────────────────────────────────── + + @Test + void removingAnEntryNamesItsCharacterOrRawLegacyValue() { + ActiveVehicle v = vehicle(owner(kira)); + String crew = VehicleOwners.whitelistEntry(bren.ref(), "Bren"); + v.getOwnerData().setWhiteList(new ArrayList<>(List.of(crew, "player_Old"))); + VFInventoryHolder holder = new VFInventoryHolder(v.getUUID(), VFGUI.WHITELIST, v); + InventoryClickEvent remove = click(alex, holder, 0, item(Material.PLAYER_HEAD, crew)); + manager.whitelistClick(remove); + verify(alex).sendMessage("§eRemoved §fBren§e from the whitelist."); + VehicleOwnershipChangeEvent pre = fired(VehicleOwnershipChangeEvent.class).getFirst(); + assertEquals(OwnershipReason.WHITELIST_REMOVE, pre.getReason()); + assertEquals(bren.ref(), pre.getTarget().orElseThrow()); assertEquals(crew, pre.getRawEntry().orElseThrow()); + manager.whitelistClick(click(alex, holder, 1, item(Material.PLAYER_HEAD, "player_Old"))); + verify(alex).sendMessage("§eRemoved §fOld§e from the whitelist."); + assertTrue(v.getOwnerData().getWhiteList().isEmpty()); + assertTrue(fired(VehicleOwnershipChangeEvent.class).getLast().getTarget().isEmpty()); + manager.whitelistClick(click(alex, holder, 2, item(Material.PLAYER_HEAD, null))); + manager.whitelistClick(click(alex, holder, OwnershipGUIManager.BACK_SLOT, item(Material.BARRIER, null))); + verify(gui).ownershipGui(null, alex, v, true); + v.getOwnerData().setWhiteList(new ArrayList<>(List.of(crew))); + manager.whitelistClick(click(bystander, holder, 0, item(Material.PLAYER_HEAD, crew))); + verify(bystander).sendMessage(OwnershipActions.NOT_OWNER); + assertEquals(List.of(crew), v.getOwnerData().getWhiteList()); + } + + @Test + void thePickerOffersNearbyAndBoardPlayersWithACharacterWhoAreNotListed() { + ActiveVehicle v = vehicle(owner(kira)); + Player far = player("Far", OwnershipFixture.session("far", "far", 9), 40); + Player aboard = player("Aboard", OwnershipFixture.session("aboard", "aboard", 10), 60); + when(v.isPassenger(aboard, true)).thenReturn(true); + Player lobby = player("Lobby", OwnershipFixture.session("lobby", "lobby", 11), 1); + playing(lobby, null); + Player listed = player("Listed", OwnershipFixture.session("listed", "listed", 12), 3); + v.getOwnerData().setWhiteList(new ArrayList<>(List.of(VehicleOwners.whitelistEntry( + OwnershipFixture.session("listed", "listed", 12).ref(), "Listed"), "player_Junk"))); + Player elsewhere = player("Elsewhere", OwnershipFixture.session("else", "else", 13), 0); + when(elsewhere.getWorld()).thenReturn(mock(World.class)); + List found = manager.candidates(v); + assertEquals(List.of("Masked Warden", "Masked Bren", "Masked Aboard"), found.stream().map(Candidate::name).toList()); + assertEquals(warden, found.getFirst().session()); + assertFalse(found.stream().anyMatch(c -> c.name().contains("Far") || c.name().contains("Lobby") || c.name().contains("Listed"))); + when(v.getEntity().getLocation()).thenReturn(new Location(null, 0, 64, 0)); + assertEquals(List.of("Masked Aboard"), manager.candidates(v).stream().map(Candidate::name).toList()); + assertNotNull(far); assertNotNull(listed); + } + + @Test + void addToWhitelistOpensThePickerInCharactersModeAndChatInNamesMode() { + ActiveVehicle v = vehicle(owner(kira)); + manager.ownershipClick(menu(alex, v, 2)); + verify(gui).pickerGui(eq(alex), eq(v), anyList()); + manager.ownershipClick(menu(bystander, v, 2)); + verify(bystander).sendMessage(OwnershipActions.NOT_OWNER); + Cache.allowWhitelist = false; + manager.ownershipClick(menu(alex, v, 2)); + verify(gui, times(1)).pickerGui(eq(alex), eq(v), anyList()); + Cache.allowWhitelist = true; + OwnershipFixture.mode(OwnershipIdentity.NAMES); + ActiveVehicle named = vehicle("player_Alex"); + manager.ownershipClick(menu(alex, named, 2)); + verify(alex).sendMessage("§aType the player name in chat to add them to the whitelist. Type §ccancel §ato abort."); + chat(alex, " Bob "); + assertEquals(List.of("player_Bob"), named.getOwnerData().getWhiteList()); + verify(alex).sendMessage("§aAdded §fBob§a to the whitelist."); + manager.ownershipClick(menu(alex, named, 2)); chat(alex, "Bob"); + verify(alex).sendMessage("§cBob is already on the whitelist."); + manager.ownershipClick(menu(alex, named, 2)); chat(alex, "CANCEL"); + verify(alex).sendMessage("§cCancelled."); + assertNull(fired(VehicleOwnershipChangeEvent.class).getFirst().getTarget().orElse(null)); + assertEquals("player_Bob", fired(VehicleOwnershipChangeEvent.class).getFirst().getRawEntry().orElseThrow()); + } + + @Test + void pickingAPlayerStoresTheirCharacterAndTheNameShownWhenPicked() { + ActiveVehicle v = vehicle(owner(kira)); + String existing = VehicleOwners.whitelistEntry(warden.ref(), "Warden"); + v.getOwnerData().setWhiteList(new ArrayList<>(List.of(existing))); + WhitelistPickerHolder holder = new WhitelistPickerHolder(v, List.of(new Candidate(bren, "Masked Bren"))); + manager.pickerClick(click(bystander, holder, 0, item(Material.PLAYER_HEAD, null))); + verify(bystander).sendMessage(OwnershipActions.NOT_OWNER); + manager.pickerClick(click(alex, holder, 0, item(Material.PLAYER_HEAD, null))); + String entry = VehicleOwners.whitelistEntry(bren.ref(), "Masked Bren"); + assertEquals(List.of(existing, entry), v.getOwnerData().getWhiteList()); + verify(alex).sendMessage("§aAdded §fMasked Bren§a to the whitelist."); + verify(gui).pickerGui(eq(alex), eq(v), anyList()); + VehicleOwnershipChangeEvent pre = fired(VehicleOwnershipChangeEvent.class).getFirst(); + assertEquals(OwnershipReason.WHITELIST_ADD, pre.getReason()); assertEquals(bren.ref(), pre.getTarget().orElseThrow()); + manager.pickerClick(click(alex, holder, 0, item(Material.PLAYER_HEAD, null))); + verify(alex).sendMessage("§cMasked Bren is already on the whitelist."); + manager.pickerClick(click(alex, holder, 7, item(Material.PAPER, null))); + characters.when(() -> Characters.isCurrent(bren)).thenReturn(false); + v.getOwnerData().setWhiteList(new ArrayList<>()); + manager.pickerClick(click(alex, holder, 0, item(Material.PLAYER_HEAD, null))); + verify(alex).sendMessage("§cThat player is no longer playing the same character."); + assertTrue(v.getOwnerData().getWhiteList().isEmpty()); + manager.pickerClick(click(alex, holder, OwnershipGUIManager.BACK_SLOT, item(Material.BARRIER, null))); + verify(gui).ownershipGui(null, alex, v, true); + } + + @Test + void typedNamesNeedExactlyOneLivingMatchAndRefuseZeroAndManyAlike() { + ActiveVehicle v = vehicle(owner(kira)); + WhitelistPickerHolder holder = new WhitelistPickerHolder(v, List.of()); + InventoryClickEvent typed = click(alex, holder, OwnershipGUIManager.PICKER_TYPED_SLOT, item(Material.WRITABLE_BOOK, null)); + manager.pickerClick(typed); + assertTrue(manager.getPending().has(alex, Kind.WHITELIST_ADD)); + chat(alex, "Nobody"); + String refusal = String.format(OwnershipManager.NO_SINGLE_MATCH, "Nobody"); + verify(alex).sendMessage(refusal); + Lookup.results.addAll(List.of(info(bren, "Bren"), info(warden, "Bren"))); + manager.pickerClick(typed); chat(alex, "Bren"); + verify(alex).sendMessage(String.format(OwnershipManager.NO_SINGLE_MATCH, "Bren")); + Lookup.results.removeLast(); + manager.pickerClick(typed); chat(alex, "bren"); + assertEquals(List.of(VehicleOwners.whitelistEntry(bren.ref(), "Bren")), v.getOwnerData().getWhiteList()); + manager.pickerClick(click(bystander, holder, OwnershipGUIManager.PICKER_TYPED_SLOT, item(Material.WRITABLE_BOOK, null))); + verify(bystander).sendMessage(OwnershipActions.NOT_OWNER); + OwnershipFixture.lookup(String.class); + manager.pickerClick(typed); + verify(alex).sendMessage(OwnershipManager.TYPED_NAMES_OFF); + } + + @Test + void aPendingWhitelistEntryEndsWithTheSessionThatStartedIt() { + ActiveVehicle v = vehicle(owner(kira)); + InventoryClickEvent typed = click(alex, new WhitelistPickerHolder(v, List.of()), OwnershipGUIManager.PICKER_TYPED_SLOT, item(Material.WRITABLE_BOOK, null)); + manager.pickerClick(typed); + manager.sessionEnd(new CharacterSessionEndEvent(alex, kira, CharacterSessionEndEvent.Reason.SWITCH)); + verify(alex).sendMessage("§cYour pending vehicle action was cancelled."); + assertFalse(manager.getPending().has(alex, Kind.WHITELIST_ADD)); + AsyncPlayerChatEvent ignored = new AsyncPlayerChatEvent(true, alex, "Bren", new HashSet<>()); + manager.chat(ignored); assertFalse(ignored.isCancelled()); + manager.sessionEnd(new CharacterSessionEndEvent(alex, kira, CharacterSessionEndEvent.Reason.QUIT)); + verify(alex, times(1)).sendMessage("§cYour pending vehicle action was cancelled."); + manager.pickerClick(typed); + characters.when(() -> Characters.isCurrent(kira)).thenReturn(false); + Lookup.results.add(info(bren, "Bren")); + chat(alex, "Bren"); + verify(alex).sendMessage(OwnershipManager.SESSION_CHANGED); + assertTrue(v.getOwnerData().getWhiteList().isEmpty()); + characters.when(() -> Characters.isCurrent(kira)).thenReturn(true); + manager.pickerClick(typed); + assertTrue(manager.getPending().has(alex, Kind.WHITELIST_ADD)); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); when(quit.getPlayer()).thenReturn(alex); + manager.quit(quit); + assertFalse(manager.getPending().has(alex, Kind.WHITELIST_ADD)); + } + + // ── Rename ────────────────────────────────────────────────────────────── + + @Test + void renamingRecordsTheSessionAndAppliesOnlyWhileItIsCurrent() { + ActiveVehicle v = vehicle("none"); + manager.startRename(alex, v); + verify(alex).sendTitle("", "§eType the §aName §ein chat", 10, 80, 10); + chat(alex, "Express_Cart"); + verify(v).setName("Express Cart"); + manager.startRename(alex, v); + characters.when(() -> Characters.isCurrent(kira)).thenReturn(false); + chat(alex, "Stolen"); + verify(alex).sendMessage(OwnershipManager.SESSION_CHANGED); + verify(v, never()).setName("Stolen"); + playing(alex, null); + manager.startRename(alex, v); + verify(alex).sendMessage(OwnershipActions.NEED_CHARACTER); + playing(alex, kira); + manager.startRename(alex, v); + when(vehicles.get(v.getEntity())).thenReturn(null); + chat(alex, "Gone"); + verify(v, never()).setName("Gone"); + tasks.getFirst().run(); + verify(alex, never()).sendMessage("§cNaming timed out."); + } + + // ── Takeover ──────────────────────────────────────────────────────────── + + @Test + void staffTakeoverOfAnOwnedVehicleGoesThroughTheTakeoverEvent() { + ActiveVehicle v = vehicle(owner(kira)); + v.getOwnerData().setWhiteList(new ArrayList<>(List.of("char_crew"))); v.getOwnerData().setWhiteListed(true); + assertFalse(manager.takeover(staff, v)); + manager.startTakeover(staff, null); + verify(staff).sendMessage("§aRight-click a vehicle within 60 seconds to claim ownership."); + assertTrue(manager.takeover(staff, v)); + assertEquals(owner(warden), v.getOwnerData().getOwner()); + assertTrue(v.getOwnerData().getWhiteList().isEmpty()); + VehicleOwnershipChangeEvent pre = fired(VehicleOwnershipChangeEvent.class).getFirst(); + assertEquals(OwnershipReason.TAKEOVER, pre.getReason()); assertTrue(pre.isStaff()); + assertEquals(warden.ref(), pre.getTarget().orElseThrow()); + assertTrue(fired(VehicleOwnerClaimedEvent.class).isEmpty()); + verify(staff).sendMessage("§aYou are now the owner of §eCart§a."); + } + + @Test + void staffTakeoverOfAnUnownedVehicleIsAClaimAndCanNameAnotherCharacter() { + Lookup.results.add(info(bren, "Bren")); + manager.startTakeover(staff, "Bren"); + verify(staff).sendMessage("§aRight-click a vehicle within 60 seconds to give it to §eBren§a."); + ActiveVehicle v = vehicle("none"); + assertTrue(manager.takeover(staff, v)); + assertEquals(owner(bren), v.getOwnerData().getOwner()); + assertEquals(OwnershipReason.CLAIM, fired(VehicleOwnershipChangeEvent.class).getFirst().getReason()); + assertEquals(1, fired(VehicleOwnerClaimedEvent.class).size()); + verify(staff).sendMessage("§eCart§a now belongs to §eBren§a."); + Lookup.results.clear(); + manager.startTakeover(staff, "Bren"); + verify(staff).sendMessage(String.format(OwnershipManager.NO_SINGLE_MATCH, "Bren")); + OwnershipFixture.lookup(String.class); + manager.startTakeover(staff, "Bren"); + verify(staff).sendMessage(OwnershipManager.TYPED_NAMES_OFF); + } + + @Test + void aTakeoverIsReverifiedWhenTheVehicleIsClicked() { + ActiveVehicle v = vehicle(owner(kira)); + manager.startTakeover(staff, null); + when(staff.hasPermission("vf.admin")).thenReturn(false); + assertTrue(manager.takeover(staff, v)); + verify(staff).sendMessage(OwnershipActions.NOT_ALLOWED); + when(staff.hasPermission("vf.admin")).thenReturn(true); + manager.startTakeover(staff, null); + characters.when(() -> Characters.isCurrent(warden)).thenReturn(false); + assertTrue(manager.takeover(staff, v)); + verify(staff).sendMessage(OwnershipManager.SESSION_CHANGED); + CharacterSession next = OwnershipFixture.session("staff", "warden", 20); + manager.startTakeover(staff, null); + assertEquals(OwnershipActions.NEED_CHARACTER, captureLast(staff)); + playing(staff, next); + manager.startTakeover(staff, null); + OwnershipFixture.mode(OwnershipIdentity.DISABLED); + assertTrue(manager.takeover(staff, v)); + verify(staff).sendMessage(OwnershipActions.DISABLED); + assertEquals(owner(kira), v.getOwnerData().getOwner()); + manager.startTakeover(staff, null); + verify(staff, times(2)).sendMessage(OwnershipActions.DISABLED); + } + + private String captureLast(Player player) { + var captor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(player, atLeastOnce()).sendMessage(captor.capture()); + return captor.getValue(); + } + + @Test + void namesModeTakeoverUsesTheAccountNameAndRefusesTypedTargets() { + OwnershipFixture.mode(OwnershipIdentity.NAMES); + manager.startTakeover(staff, "Bren"); + verify(staff).sendMessage(OwnershipManager.TYPED_NAMES_OFF); + ActiveVehicle v = vehicle("player_Other"); + manager.startTakeover(staff, null); + manager.takeover(staff, v); + assertEquals("player_Warden", v.getOwnerData().getOwner()); + tasks.getFirst().run(); + verify(staff, never()).sendMessage("§cTakeover expired."); + manager.startTakeover(staff, null); + tasks.getLast().run(); + verify(staff).sendMessage("§cTakeover expired."); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/QuarantineCommandTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/QuarantineCommandTest.java new file mode 100644 index 0000000..ab764b0 --- /dev/null +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/QuarantineCommandTest.java @@ -0,0 +1,84 @@ +package net.tfminecraft.vehicleframework.managers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.RegisteredServiceProvider; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.plugin.ServicesManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +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; + +class QuarantineCommandTest { + private static final UUID STORED = UUID.fromString("11111111-2222-4333-8444-555555555555"); + private static final UUID MISSING = UUID.fromString("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"); + private final ServicesManager services = mock(ServicesManager.class); + private final CommandSender staff = mock(CommandSender.class); + private MockedStatic bukkit; + private MockedStatic log; + private MockedStatic store; + + @BeforeEach + void setup() { + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getServicesManager).thenReturn(services); + log = mockStatic(VFLogger.class); + store = mockStatic(OwnershipStore.class); + } + + @AfterEach + void cleanup() { + store.close(); log.close(); bukkit.close(); + } + + private RegisteredServiceProvider source(String plugin, VehicleQuarantineSource provider) { + Plugin owner = mock(Plugin.class); + when(owner.getName()).thenReturn(plugin); + return new RegisteredServiceProvider<>(VehicleQuarantineSource.class, provider, ServicePriority.Normal, owner); + } + + @Test + void everySourceIsListedWithTheVehicleItNamesWhereKnown() { + VehicleQuarantineSource factions = () -> List.of( + new QuarantinedVehicle(STORED, "player_Gone", "name does not round-trip"), + new QuarantinedVehicle(MISSING, "player_Lost", "no heir")); + VehicleQuarantineSource broken = () -> { throw new IllegalStateException("plan unreadable"); }; + List> sources = List.of(source("SimpleFactions", factions), source("Other", broken)); + when(services.getRegistrations(VehicleQuarantineSource.class)).thenReturn(sources); + store.when(OwnershipStore::listOwnershipMeta).thenReturn(List.of( + new OwnershipMeta(STORED, "cart", "Old cart", "player_Gone", Optional.empty(), Optional.empty()))); + QuarantineCommand.list(staff); + verify(staff).sendMessage("§cOther could not list its quarantine; see the console."); + log.verify(() -> VFLogger.log(contains("Quarantine list from Other failed"))); + verify(staff).sendMessage("§eQuarantined vehicles: §f2"); + verify(staff).sendMessage("§7" + STORED + " §8cart 'Old cart' §fplayer_Gone §7- name does not round-trip"); + verify(staff).sendMessage("§7" + MISSING + " §8(not stored) §fplayer_Lost §7- no heir"); + } + + @Test + void aVehicleReadFailureStillListsTheQuarantine() { + VehicleQuarantineSource factions = () -> List.of(new QuarantinedVehicle(STORED, "player_Gone", "no heir")); + List> sources = List.of(source("SimpleFactions", factions)); + when(services.getRegistrations(VehicleQuarantineSource.class)).thenReturn(sources); + store.when(OwnershipStore::listOwnershipMeta).thenThrow(new IllegalStateException("SQLite is not open")); + QuarantineCommand.list(staff); + verify(staff).sendMessage("§7" + STORED + " §8(not stored) §fplayer_Gone §7- no heir"); + log.verify(() -> VFLogger.log(contains("Could not read vehicles for the quarantine list"))); + } +} diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java index 0fef0df..eb591e6 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java @@ -336,11 +336,10 @@ void persistenceFailuresPreserveLiveVehiclesAndSuccessfulUnloadRemovesThem() { } @Test - void storedAndLiveDamageOwnershipAndMetadataUseTheCurrentRepository() { + void storedAndLiveDamageAndMetadataUseTheCurrentRepository() { assertFalse(manager.unloadedDamage(null, .2, .03)); assertFalse(manager.unloadedDamage(" ", .2, .03)); assertFalse(manager.unloadedDamage("offline", .2, .03)); assertTrue(manager.readStoredVehicle("offline").isEmpty()); - manager.clearOwnership(null); manager.clearOwnership(" "); manager.clearOwnership("offline"); ActiveVehicle v = vehicle("v"); register(v); v.getOwnerData().setOwner("player_Ryan"); try (MockedStatic decay = mockStatic(VehicleHealthDecay.class)) { manager.unloadedDamage(" v ", .2, .03); @@ -349,11 +348,12 @@ void storedAndLiveDamageOwnershipAndMetadataUseTheCurrentRepository() { VehiclePersistence persistence = persistence(); when(persistence.applyStoredDecay("offline", .2, .03)).thenReturn(true); assertTrue(manager.unloadedDamage(" offline ", .2, .03)); + try (MockedStatic decay = mockStatic(VehicleHealthDecay.class)) { + when(persistence.saveLive(v)).thenReturn(true); + assertTrue(manager.unloadedDamage("v", .2, .03)); verify(persistence).saveLive(v); + } StoredVehicleMeta meta = new StoredVehicleMeta("offline", "Old cart", "cart", "player_Ryan"); when(persistence.readMeta("offline")).thenReturn(Optional.of(meta)); assertSame(meta, manager.readStoredVehicle("offline").orElseThrow()); - manager.clearOwnership("v"); assertEquals("none", v.getOwnerData().getOwner()); assertFalse(v.getOwnerData().isWhiteListed()); - verify(persistence).saveLive(v); - manager.clearOwnership("offline"); verify(persistence).clearOwnership("offline"); } @Test @@ -409,10 +409,9 @@ void diagnosticDescriptionsHandleMissingAndInvalidEntities() { @Test void timersRunIndependentTicksAndSkipUnavailablePersistence() throws Exception { ActiveVehicle v = vehicle("v"); register(v); Player player = player("Ryan"); - NamingData naming = mock(NamingData.class); when(naming.tick()).thenReturn(true); map("naming").put(player, naming); manager.start(); assertEquals(3, timers.size()); verify(manager.getSpawnManager()).start(); timers.get(0).run(); timers.get(1).run(); timers.get(2).run(); - verify(v).tick(); verify(v).slowTick(); verify(player).sendMessage("§cNaming timed out."); + verify(v).tick(); verify(v).slowTick(); doThrow(new IllegalStateException("broken tick")).when(v).tick(); doThrow(new IllegalStateException("broken slow tick")).when(v).slowTick(); manager.towSelect(player, v); when(v.getEntity().getLocation()).thenReturn(new Location(world, 100, 64, 0)); @@ -457,43 +456,17 @@ void seatMenusAndMountsRespectTicketsAndExistingRiders() { } @Test - void ownershipMenuTogglesListsTicketsAndClearOwnership() { - ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); - v.getOwnerData().setOwner("player_Ryan"); - InventoryClickEvent event = click(player, v, VFGUI.OWNERSHIP, 0, item(Material.PAPER, null)); - manager.ownershipClick(event); assertTrue(v.getOwnerData().isWhiteListed()); verify(event).setCancelled(true); - when(event.getSlot()).thenReturn(8); manager.ownershipClick(event); assertTrue(v.getOwnerData().isTicketsEnabled()); - when(event.getSlot()).thenReturn(4); manager.ownershipClick(event); verify(ownership.constructed().getFirst()).whitelistGui(null, player, v, true); - when(event.getSlot()).thenReturn(6); manager.ownershipClick(event); assertEquals("none", v.getOwnerData().getOwner()); assertFalse(v.getOwnerData().isWhiteListed()); - } - - @Test - void chatAddsWhitelistNamesRejectsDuplicatesAndSupportsCancellation() { - ActiveVehicle v = vehicle("v"); Player player = player("Ryan"); - InventoryClickEvent menu = click(player, v, VFGUI.OWNERSHIP, 2, item(Material.PAPER, null)); - for (String message : List.of(" Alice ", "Alice", "cancel")) { - manager.ownershipClick(menu); - AsyncPlayerChatEvent chat = new AsyncPlayerChatEvent(true, player, message, new HashSet<>()); - manager.nameVehicle(chat); assertTrue(chat.isCancelled()); - scheduled.removeFirst().run(); - } - assertEquals(List.of("player_Alice"), v.getOwnerData().getWhiteList()); - verify(player).sendMessage("§cAlice is already on the whitelist."); verify(player).sendMessage("§cCancelled."); - AsyncPlayerChatEvent normal = new AsyncPlayerChatEvent(true, player, "hello", new HashSet<>()); manager.nameVehicle(normal); assertFalse(normal.isCancelled()); - InventoryClickEvent remove = click(player, v, VFGUI.WHITELIST, 0, item(Material.PLAYER_HEAD, "player_Alice")); - manager.whitelistClick(remove); assertTrue(v.getOwnerData().getWhiteList().isEmpty()); - when(remove.getSlot()).thenReturn(26); manager.whitelistClick(remove); verify(ownership.constructed().getFirst()).ownershipGui(null, player, v, true); - } - - @Test - void takeoverCanExpireAndCanReplaceAnExistingOwner() { - Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); - manager.startTakeover(player); manager.startTakeover(player); verify(scheduler).cancelTask(1); - scheduled.removeLast().run(); verify(player).sendMessage("§cTakeover expired."); - manager.startTakeover(player); PlayerInteractEntityEvent event = interact(player, v.getEntity()); - manager.vehicleInteract(event); assertEquals("player_Ryan", v.getOwnerData().getOwner()); assertTrue(event.isCancelled()); - v.getOwnerData().setOwner("player_Other"); manager.startTakeover(player); manager.vehicleInteract(interact(player, v.getEntity())); - assertEquals("player_Ryan", v.getOwnerData().getOwner()); + void aPendingTakeoverConsumesTheNextVehicleClickThroughTheOwnershipEvents() { + Player player = player("Ryan"); when(player.hasPermission("vf.admin")).thenReturn(true); + ActiveVehicle v = vehicle("v"); register(v); v.getOwnerData().setOwner("player_Other"); + when(persistence().saveLive(v)).thenReturn(true); + manager.startTakeover(player, "Bren"); verify(player).sendMessage(OwnershipManager.TYPED_NAMES_OFF); + manager.startTakeover(player); + PlayerInteractEntityEvent event = interact(player, v.getEntity()); + manager.vehicleInteract(event); + assertTrue(event.isCancelled()); assertEquals("player_Ryan", v.getOwnerData().getOwner()); + verify(plugins).callEvent(isA(VehicleOwnershipChangeEvent.class)); + verify(plugins, never()).callEvent(isA(VehiclePreInteractEvent.class)); } @Test @@ -512,6 +485,7 @@ void interactionCancellationAndDeckMappingPrecedeOwnershipAndActions() { void interactionClaimsOwnershipAndRoutesToolsFuelSkinNamingAndSeatMenus() throws Exception { Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); when(v.getComponent(Component.HARNESS)).thenReturn(null); + when(persistence().saveLive(v)).thenReturn(true); manager.vehicleInteract(interact(player, v.getEntity())); assertEquals("player_Ryan", v.getOwnerData().getOwner()); verify(inventories.constructed().getFirst()).seatSelection(null, player, v, true); @@ -522,7 +496,7 @@ void interactionClaimsOwnershipAndRoutesToolsFuelSkinNamingAndSeatMenus() throws map("cooldown").clear(); ItemStack nameTag = item(Material.NAME_TAG, null); when(player.getInventory().getItemInMainHand()).thenReturn(nameTag); manager.vehicleInteract(interact(player, v.getEntity())); AsyncPlayerChatEvent chat = new AsyncPlayerChatEvent(true, player, "Express_Cart", new HashSet<>()); - manager.nameVehicle(chat); assertTrue(chat.isCancelled()); scheduled.removeFirst().run(); verify(v).setName("Express Cart"); + manager.getOwnershipManager().chat(chat); assertTrue(chat.isCancelled()); scheduled.removeLast().run(); verify(v).setName("Express Cart"); map("cooldown").clear(); when(items.getChecker().checkItemWithPath(any(), eq(Cache.destroyItem))).thenReturn(true); manager.vehicleInteract(interact(player, v.getEntity())); verify(v).remove(VehicleRemoveReason.PLAYER_DESTROY); } @@ -637,11 +611,10 @@ void damageBridgePublishesCustomEventAndDamagesOnlyUncancelledLivingEntities() { } @Test - void quitAndDeathDismountRidersAndCancelPendingTakeovers() { + void quitAndDeathDismountRiders() { Player player = player("Ryan"); ActiveVehicle v = vehicle("v"); register(v); when(v.isPassenger(player, false)).thenReturn(true); - manager.startTakeover(player); PlayerQuitEvent quit = mock(PlayerQuitEvent.class); when(quit.getPlayer()).thenReturn(player); - manager.playerLeave(quit); verify(scheduler).cancelTask(1); verify(v).dismountPassenger(player, false); + manager.playerLeave(quit); verify(v).dismountPassenger(player, false); PlayerDeathEvent death = mock(PlayerDeathEvent.class); when(death.getEntity()).thenReturn(player); manager.passengerDeath(death); verify(v, times(2)).dismountPassenger(player, false); } @@ -918,10 +891,6 @@ void damageAdapterFailureIsReportedWithoutEscapingTheEventHandler() { @Test void ownershipAndInputDefensiveHelpersRejectMissingParticipants() throws Exception { // These guards protect future event/API callers; assert their null contract without fabricated Bukkit events. - var claim = VehicleManager.class.getDeclaredMethod("claimOwnership", Player.class, ActiveVehicle.class); - claim.setAccessible(true); - assertEquals(false, claim.invoke(manager, null, vehicle("v"))); - assertEquals(false, claim.invoke(manager, player("Ryan"), null)); var shifting = VehicleManager.class.getDeclaredMethod("mountedShifting", Player.class); shifting.setAccessible(true); assertEquals(false, shifting.invoke(manager, new Object[]{null})); var cooldown = VehicleManager.class.getDeclaredMethod("putEjectCooldown", Player.class, ActiveVehicle.class); From 5f762183fc77385638d01fb55b9bf83a8d8a74c7 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:28:11 +0000 Subject: [PATCH 4/5] fix: take over for multi-word names and character IDs /vf takeover now reads every argument after the command as the character name, so names with spaces resolve, and accepts id: or a bare UUID for any known character (including dead ones and without typed-name lookup). Blank, malformed or unknown targets and names mode get a clear message instead of being ignored. Staff get tab completion for id: and online characters' real names, word by word. Co-Authored-By: Claude Opus 5.5 --- .../managers/CommandManager.java | 6 +- .../managers/OwnershipManager.java | 101 ++++++++++++++++-- .../vehicleframework/util/TabCompletion.java | 5 + .../CommandAndOwnershipCoverageTest.java | 4 +- .../managers/OwnershipManagerTest.java | 54 +++++++++- .../managers/VehicleManagerCoverageTest.java | 2 +- 6 files changed, 156 insertions(+), 16 deletions(-) diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java index a13af2e..96ca606 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/CommandManager.java @@ -138,7 +138,7 @@ 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 || args.length == 2)) { + if(args[0].equalsIgnoreCase("takeover")) { if(!(sender instanceof Player)) { sender.sendMessage("§cOnly players can use this command."); return true; @@ -147,7 +147,9 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String if (args.length == 1) { VehicleFramework.getVehicleManager().startTakeover(p); } else { - VehicleFramework.getVehicleManager().startTakeover(p, args[1]); + // 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; } diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java index 32f8581..f4c7d2d 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java @@ -1,8 +1,10 @@ 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; @@ -60,6 +62,11 @@ public class OwnershipManager implements Listener { 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 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(); @@ -93,8 +100,9 @@ public boolean claim(Player p, ActiveVehicle v) { } /** - * Arms a staff takeover. Without a name the vehicle goes to the staff member's own character; - * with one (characters mode) to the single living character with that real name. + * 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); @@ -106,17 +114,11 @@ public void startTakeover(Player p, String targetName) { if (targetName == null) { target = new Target(actor.value().ownerValue(), actor.value().ref(), null); } else { - if (!OwnershipMode.characters() || !CharacterLookup.typedNamesAvailable()) { - p.sendMessage(TYPED_NAMES_OFF); + Optional named = takeoverTarget(p, targetName.trim().replaceAll("\\s+", " ")); + if (named.isEmpty()) { return; } - Optional found = single(targetName); - if (found.isEmpty()) { - p.sendMessage(String.format(NO_SINGLE_MATCH, targetName)); - return; - } - CharacterRef ref = found.get().ref(); - target = new Target(VehicleOwners.character(ref.characterId()), ref, found.get().name()); + target = named.get(); } pending.start(p, Kind.TAKEOVER, null, actor.value().session(), target); if (target.name() == null) { @@ -126,6 +128,83 @@ public void startTakeover(Player p, String targetName) { } } + /** 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); diff --git a/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java b/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java index f66b8c3..cd19c3f 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java +++ b/src/main/java/net/tfminecraft/vehicleframework/util/TabCompletion.java @@ -41,6 +41,11 @@ public List onTabComplete(CommandSender sender, Command cmd, String labe return completions; } + if (args[0].equalsIgnoreCase("takeover") && args.length >= 2 && Permissions.isAdmin(sender)) { + return net.tfminecraft.vehicleframework.managers.OwnershipManager.takeoverSuggestions( + java.util.Arrays.copyOfRange(args, 1, args.length)); + } + if (args[0].equalsIgnoreCase("spawn") && args.length == 2) { if (!Permissions.canSpawn(sender)) return completions; for (Vehicle v : VehicleLoader.get().values()) { diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java index ed92e65..1204bdd 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/CommandAndOwnershipCoverageTest.java @@ -316,7 +316,9 @@ private void charactersMode() { assertEquals(List.of("quarantine"), new TabCompletion().onTabComplete(player, command, "vf", new String[]{"admin", ""})); assertTrue(new TabCompletion().onTabComplete(player, command, "vf", new String[]{""}).contains("admin")); assertTrue(run(player, "takeover", "Bren")); verify(vehicles).startTakeover(player, "Bren"); - assertFalse(run(player, "takeover", "Bren", "extra")); + assertTrue(run(player, "takeover", "Bren", "of", "the", "Vale")); verify(vehicles).startTakeover(player, "Bren of the Vale"); + assertEquals(List.of(), new TabCompletion().onTabComplete(player, command, "vf", new String[]{"takeover", "Br"}), + "names mode offers no takeover targets"); assertFalse(run(player, "admin", "other")); assertFalse(run(player, "admin")); when(services.getRegistrations(VehicleQuarantineSource.class)).thenReturn(List.of()); assertTrue(run(player, "admin", "QUARANTINE")); diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java index b10c001..98e17f5 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java @@ -73,8 +73,10 @@ static void initializeSounds() { /** Stands in for TLibs 2.5.0's facade. */ public static final class Lookup { static final List results = new ArrayList<>(); + static String asked; public static List findByName(String name) { + asked = name; return results; } } @@ -640,11 +642,61 @@ private String captureLast(Player player) { return captor.getValue(); } + @Test + void takeoverNamesMayContainSpacesAndAreMatchedWhole() { + Lookup.results.add(info(bren, "Bren of the Vale")); + manager.startTakeover(staff, " Bren of the Vale "); + assertEquals("Bren of the Vale", Lookup.asked); + verify(staff).sendMessage("§aRight-click a vehicle within 60 seconds to give it to §eBren of the Vale§a."); + ActiveVehicle v = vehicle(owner(kira)); + assertTrue(manager.takeover(staff, v)); + assertEquals(owner(bren), v.getOwnerData().getOwner()); + manager.startTakeover(staff, " "); + verify(staff).sendMessage(OwnershipManager.TAKEOVER_USAGE); + } + + @Test + void takeoverAcceptsACharacterIdForAnyKnownCharacterEvenWithoutTypedNames() { + OwnershipFixture.lookup(String.class); + UUID id = bren.ref().characterId(); + CharacterInfo dead = new CharacterInfo(bren.ref(), "Bren", CharacterStatus.DEAD, null, Set.of(), 0, Instant.EPOCH); + characters.when(() -> Characters.info(id)).thenReturn(Optional.of(dead)); + for (String form : List.of("id:" + id, "ID: " + id, id.toString().toUpperCase())) { + ActiveVehicle v = vehicle(owner(kira)); + manager.startTakeover(staff, form); + assertTrue(manager.takeover(staff, v), form); + assertEquals(owner(bren), v.getOwnerData().getOwner(), form); + } + UUID unknown = UUID.randomUUID(); + manager.startTakeover(staff, "id:" + unknown); + verify(staff).sendMessage(String.format(OwnershipManager.UNKNOWN_ID, unknown)); + manager.startTakeover(staff, "id:bren"); + verify(staff).sendMessage(String.format(OwnershipManager.NOT_AN_ID, "id:bren")); + manager.startTakeover(staff, "Bren"); + verify(staff).sendMessage(OwnershipManager.TYPED_NAMES_OFF); + assertFalse(manager.getPending().has(staff, Kind.TAKEOVER)); + } + + @Test + void takeoverCompletionOffersIdsAndOnlineCharacterNamesWordByWord() { + characters.when(() -> Characters.info(kira.ref().characterId())).thenReturn(Optional.of(info(kira, "Kira of the Vale"))); + characters.when(() -> Characters.info(bren.ref().characterId())).thenReturn(Optional.of(info(bren, "Bren"))); + assertEquals(List.of("Kira of the Vale"), OwnershipManager.takeoverSuggestions(new String[]{"k"})); + assertEquals(List.of("id:"), OwnershipManager.takeoverSuggestions(new String[]{"I"})); + assertEquals(List.of("id:", "Kira of the Vale", "Bren"), OwnershipManager.takeoverSuggestions(new String[]{""})); + assertEquals(List.of("of the Vale"), OwnershipManager.takeoverSuggestions(new String[]{"kira", ""})); + assertEquals(List.of("Vale"), OwnershipManager.takeoverSuggestions(new String[]{"Kira", "of", "the", "V"})); + assertTrue(OwnershipManager.takeoverSuggestions(new String[]{"Kira", "x"}).isEmpty()); + assertTrue(OwnershipManager.takeoverSuggestions(new String[0]).isEmpty()); + OwnershipFixture.mode(OwnershipIdentity.NAMES); + assertTrue(OwnershipManager.takeoverSuggestions(new String[]{""}).isEmpty()); + } + @Test void namesModeTakeoverUsesTheAccountNameAndRefusesTypedTargets() { OwnershipFixture.mode(OwnershipIdentity.NAMES); manager.startTakeover(staff, "Bren"); - verify(staff).sendMessage(OwnershipManager.TYPED_NAMES_OFF); + verify(staff).sendMessage(OwnershipManager.NAMES_MODE_TAKEOVER); ActiveVehicle v = vehicle("player_Other"); manager.startTakeover(staff, null); manager.takeover(staff, v); diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java index eb591e6..26d4e96 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/VehicleManagerCoverageTest.java @@ -460,7 +460,7 @@ void aPendingTakeoverConsumesTheNextVehicleClickThroughTheOwnershipEvents() { Player player = player("Ryan"); when(player.hasPermission("vf.admin")).thenReturn(true); ActiveVehicle v = vehicle("v"); register(v); v.getOwnerData().setOwner("player_Other"); when(persistence().saveLive(v)).thenReturn(true); - manager.startTakeover(player, "Bren"); verify(player).sendMessage(OwnershipManager.TYPED_NAMES_OFF); + manager.startTakeover(player, "Bren"); verify(player).sendMessage(OwnershipManager.NAMES_MODE_TAKEOVER); manager.startTakeover(player); PlayerInteractEntityEvent event = interact(player, v.getEntity()); manager.vehicleInteract(event); From 18abead115e7e30d64fb253ff87d004b76ac3d1d Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:07:30 +0000 Subject: [PATCH 5/5] fix: guard stale vehicles in whitelist adds and show unreadable quarantine metadata A typed whitelist entry now checks that its vehicle is still registered and undestroyed before reading or saving it, as renames do, and both tell the player when it is gone. The quarantine list shows "(metadata unavailable)" when stored vehicles cannot be read, keeping "(not stored)" for a successful read with no match. Co-Authored-By: Claude Opus 5.5 --- .../managers/OwnershipManager.java | 15 ++++++++++++- .../managers/QuarantineCommand.java | 18 ++++++++------- .../managers/OwnershipManagerTest.java | 22 +++++++++++++++++++ .../managers/QuarantineCommandTest.java | 6 +++-- 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java index f4c7d2d..dd9e10f 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/OwnershipManager.java @@ -62,6 +62,7 @@ public class OwnershipManager implements Listener { 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."; @@ -422,6 +423,10 @@ private void applyWhitelistAdd(Player p, Pending action, String message) { return; } ActiveVehicle v = action.vehicle(); + if (!stillLoaded(v)) { + p.sendMessage(VEHICLE_GONE); + return; + } String name = message.trim(); if (OwnershipMode.characters()) { Optional found = single(name); @@ -464,7 +469,10 @@ private void applyRename(Player p, Pending action, String message) { return; } ActiveVehicle v = action.vehicle(); - if (vehicles.get(v.getEntity()) == null) return; + 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); @@ -472,6 +480,11 @@ private void applyRename(Player p, Pending action, String message) { // ── 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(); diff --git a/src/main/java/net/tfminecraft/vehicleframework/managers/QuarantineCommand.java b/src/main/java/net/tfminecraft/vehicleframework/managers/QuarantineCommand.java index 79cd4cd..8dd0340 100644 --- a/src/main/java/net/tfminecraft/vehicleframework/managers/QuarantineCommand.java +++ b/src/main/java/net/tfminecraft/vehicleframework/managers/QuarantineCommand.java @@ -41,22 +41,24 @@ public static void list(CommandSender sender) { } } sender.sendMessage("§eQuarantined vehicles: §f" + vehicles.size()); - Map known = known(); + Optional> known = known(); for (QuarantinedVehicle vehicle : vehicles) { - String label = Optional.ofNullable(known.get(vehicle.vehicleId())) - .map(meta -> " §8" + meta.typeId() + " '" + meta.name() + "'") - .orElse(" §8(not stored)"); + String label = known.isEmpty() ? " §8(metadata unavailable)" + : Optional.ofNullable(known.get().get(vehicle.vehicleId())) + .map(meta -> " §8" + meta.typeId() + " '" + meta.name() + "'") + .orElse(" §8(not stored)"); sender.sendMessage("§7" + vehicle.vehicleId() + label + " §f" + vehicle.owner() + " §7- " + vehicle.reason()); } } - private static Map known() { + /** Stored vehicles by ID, or empty when they could not be read. */ + private static Optional> known() { try { - return OwnershipStore.listOwnershipMeta().stream() - .collect(Collectors.toMap(OwnershipMeta::vehicleId, Function.identity())); + return Optional.of(OwnershipStore.listOwnershipMeta().stream() + .collect(Collectors.toMap(OwnershipMeta::vehicleId, Function.identity()))); } catch (RuntimeException ex) { VFLogger.log("Could not read vehicles for the quarantine list: " + ex); - return Map.of(); + return Optional.empty(); } } } diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java index 98e17f5..e76b8e9 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/OwnershipManagerTest.java @@ -547,6 +547,27 @@ void aPendingWhitelistEntryEndsWithTheSessionThatStartedIt() { assertFalse(manager.getPending().has(alex, Kind.WHITELIST_ADD)); } + @Test + void aPendingWhitelistEntryForAStaleVehicleChangesAndSavesNothing() { + ActiveVehicle v = vehicle(owner(kira)); + InventoryClickEvent typed = click(alex, new WhitelistPickerHolder(v, List.of()), OwnershipGUIManager.PICKER_TYPED_SLOT, item(Material.WRITABLE_BOOK, null)); + Lookup.results.add(info(bren, "Bren")); + manager.pickerClick(typed); + when(v.isDestroyed()).thenReturn(true); + chat(alex, "Bren"); + when(v.isDestroyed()).thenReturn(false); + manager.pickerClick(typed); + when(vehicles.get(v.getEntity())).thenReturn(mock(ActiveVehicle.class)); + chat(alex, "Bren"); + manager.pickerClick(typed); + when(vehicles.get(v.getEntity())).thenReturn(null); + chat(alex, "Bren"); + verify(alex, times(3)).sendMessage(OwnershipManager.VEHICLE_GONE); + assertTrue(v.getOwnerData().getWhiteList().isEmpty()); + verify(persistence, never()).saveLive(any(ActiveVehicle.class)); + assertTrue(fired(VehicleOwnershipChangeEvent.class).isEmpty()); + } + // ── Rename ────────────────────────────────────────────────────────────── @Test @@ -569,6 +590,7 @@ void renamingRecordsTheSessionAndAppliesOnlyWhileItIsCurrent() { when(vehicles.get(v.getEntity())).thenReturn(null); chat(alex, "Gone"); verify(v, never()).setName("Gone"); + verify(alex).sendMessage(OwnershipManager.VEHICLE_GONE); tasks.getFirst().run(); verify(alex, never()).sendMessage("§cNaming timed out."); } diff --git a/src/test/java/net/tfminecraft/vehicleframework/managers/QuarantineCommandTest.java b/src/test/java/net/tfminecraft/vehicleframework/managers/QuarantineCommandTest.java index ab764b0..b463028 100644 --- a/src/test/java/net/tfminecraft/vehicleframework/managers/QuarantineCommandTest.java +++ b/src/test/java/net/tfminecraft/vehicleframework/managers/QuarantineCommandTest.java @@ -69,16 +69,18 @@ void everySourceIsListedWithTheVehicleItNamesWhereKnown() { verify(staff).sendMessage("§eQuarantined vehicles: §f2"); verify(staff).sendMessage("§7" + STORED + " §8cart 'Old cart' §fplayer_Gone §7- name does not round-trip"); verify(staff).sendMessage("§7" + MISSING + " §8(not stored) §fplayer_Lost §7- no heir"); + verify(staff, never()).sendMessage(contains("metadata unavailable")); } @Test - void aVehicleReadFailureStillListsTheQuarantine() { + void aVehicleReadFailureListsTheQuarantineAsMetadataUnavailable() { VehicleQuarantineSource factions = () -> List.of(new QuarantinedVehicle(STORED, "player_Gone", "no heir")); List> sources = List.of(source("SimpleFactions", factions)); when(services.getRegistrations(VehicleQuarantineSource.class)).thenReturn(sources); store.when(OwnershipStore::listOwnershipMeta).thenThrow(new IllegalStateException("SQLite is not open")); QuarantineCommand.list(staff); - verify(staff).sendMessage("§7" + STORED + " §8(not stored) §fplayer_Gone §7- no heir"); + verify(staff).sendMessage("§7" + STORED + " §8(metadata unavailable) §fplayer_Gone §7- no heir"); + verify(staff, never()).sendMessage(contains("(not stored)")); log.verify(() -> VFLogger.log(contains("Could not read vehicles for the quarantine list"))); } }