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