diff --git a/pom.xml b/pom.xml index 67b3922e..8a983caf 100644 --- a/pom.xml +++ b/pom.xml @@ -16,8 +16,8 @@ 0.3.0 2.16.0 2.4.1 - 2.10.0 - 2.1.0 + 2.13.0 + 2.2.0 diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java index 8c63f343..f0eb154b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java +++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java @@ -112,6 +112,15 @@ import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleUpkeepService; import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank; import net.tfminecraft.simplefactions.player.PlayerEconomyManager; +import net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleClaimsOff; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipServices; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleQuarantine; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleUpkeepReconcile; public class SimpleFactions extends JavaPlugin{ public static FileConfiguration config; @@ -242,10 +251,17 @@ public class SimpleFactions extends JavaPlugin{ private final VehicleHandoverListener vehicleHandoverListener = new VehicleHandoverListener( vehicleHandoverSessionManager, vehicleHandoverService, vehicleFeeConfirmations); private final VehicleReclaimFeeListener vehicleReclaimFeeListener = - new VehicleReclaimFeeListener(vehicleFeeStore, vehicleFeeConfirmations, this::saveVehicleFees); + new VehicleReclaimFeeListener(vehicleFeeStore, this::saveVehicleFees); private boolean registrationFeeRegistered; private boolean incompatibleCharacterApi; - private boolean vehicleIntegrationRegistered = false; + /** Once-guard for {@link #startVehicles()}, which both start() and VehicleFramework's enable can trigger. */ + private boolean vehiclesStarted; + private static final long VEHICLE_RECONCILE_TICKS = 24L * 60L * 60L * 20L; + // Seams for tests; production probes VehicleFramework and copies its SQLite database. + java.util.function.Supplier vehicleApiProbe = VehicleOwnerWrites::probe; + java.util.function.Function vehicleBackups = + VehicleOwnerMigration.SqliteBackups::new; + VehicleOwnerMigration.Identity vehicleIdentity = new VehicleOwnerMigration.LiveIdentity(); private boolean constructionFreezeRegistered; private final PlayerEconomyManager playerEconomyManager = new PlayerEconomyManager(); private final VehicleUpkeepService vehicleUpkeepService = new VehicleUpkeepService( @@ -285,6 +301,7 @@ public void onEnable() { started = false; startFailed = false; servicesBegun = false; + vehiclesStarted = false; config = getConfig(); plugin = this; FactionManager.inv = inventoryManager; @@ -297,6 +314,13 @@ public void onEnable() { getServer().getPluginManager().disablePlugin(this); return; } + // VehicleFramework may enable inside this enable window or after start(); startVehicles() runs once. + getServer().getPluginManager().registerEvents(new Listener() { + @EventHandler + public void onPluginEnable(PluginEnableEvent event) { + if ("VehicleFramework".equalsIgnoreCase(event.getPlugin().getName())) startVehicles(); + } + }, this); awaitIdentity(); } @@ -341,6 +365,7 @@ static boolean identityReady() { /** A persisted operation must finish before another character or money mutation is allowed. */ public void failFinancialState(Throwable error) { started = false; + LeaderStamps.enable(false); startupComplete = false; startFailed = true; getServer().getServicesManager().unregisterAll(this); @@ -384,6 +409,12 @@ void start() { try { startServices(); } catch (Exception error) { failFinancialState(error); } if (!started) return; + // Factions are up; vehicles follow once VehicleFramework is enabled too. The ready event says nothing about vehicles. + startVehicles(); + if (!started) return; + if (!vehiclesStarted) { + getLogger().info("Vehicle ownership waits for VehicleFramework to enable; factions run without vehicles until then"); + } getServer().getPluginManager().callEvent(new net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent()); } @@ -475,8 +506,6 @@ private void startServices() { FactionManager.updateAllPrestigeConverged(); net.tfminecraft.simplefactions.identity.death.CharacterDeaths.load(getDataFolder().toPath()); net.tfminecraft.simplefactions.economy.TreasuryActions.recover(getDataFolder().toPath().resolve("economy/callers")); - if (getServer().getPluginManager().isPluginEnabled("VehicleFramework")) - net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.startup(getDataFolder().toPath(), vehicleRegistry); net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.startup(getDataFolder().toPath(), vehicleFeeStore, vehicleMaintenanceStore); net.tfminecraft.simplefactions.economy.ExternalIncome.recover(getDataFolder().toPath()); realmSettlementService = new net.tfminecraft.simplefactions.economy.settlement.RealmSettlementService(getDataFolder().toPath(), db); @@ -540,6 +569,9 @@ public void onDisable() { if (identityPoll != null) identityPoll.cancel(); identityPoll = null; started = false; + LeaderStamps.enable(false); + VehicleOwnerWrites.use(null); + VehicleQuarantine.use(null); getServer().getServicesManager().unregisterAll(this); net.tfminecraft.simplefactions.identity.death.CharacterDeaths.stop(); net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.stop(); @@ -895,23 +927,119 @@ public void onPluginEnable(PluginEnableEvent event) { net.tfminecraft.simplefactions.integration.rpcharacters.chat.RpCharactersChatIntegration.register(); } + /** The VFBuilders construction freeze and registration fee, which need no vehicle stage. */ private void registerVehicleIntegrationHooks() { getServer().getPluginManager().registerEvents(new Listener() { @EventHandler public void onPluginEnable(PluginEnableEvent event) { if ("VFBuilders".equalsIgnoreCase(event.getPlugin().getName()) || "VehicleFramework".equalsIgnoreCase(event.getPlugin().getName())) { - registerVehicleIntegration(); registerConstructionFreeze(); registerRegistrationFee(); } } }, this); - registerVehicleIntegration(); registerConstructionFreeze(); registerRegistrationFee(); } + /** + * The vehicle stage, once factions have started and VehicleFramework is enabled: + * payer migration, the owner migration, the leader and upkeep reconciles, then the + * vehicle listeners, and last the ownership authority VehicleFramework waits for. + * Without VehicleFramework 2.13 in characters mode only the payer migration and the + * listeners run, with claims and restamps off. + */ + void startVehicles() { + if (vehiclesStarted || !started || !getServer().getPluginManager().isPluginEnabled("VehicleFramework")) { + return; + } + vehiclesStarted = true; + VehicleOwnerApi api = vehicleApiProbe.get(); + VehicleOwnerWrites.use(api); + boolean full = VehicleOwnerWrites.ready(); + java.nio.file.Path folder = getDataFolder().toPath(); + VehicleQuarantine quarantine = new VehicleQuarantine(folder); + VehicleQuarantine.use(quarantine); + if (api != null) VehicleOwnershipServices.registerQuarantine(getServer().getServicesManager(), this, quarantine); + try { + net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.startup(folder, vehicleRegistry); + if (full) { + VehicleOwnerMigration.Outcome outcome = new VehicleOwnerMigration(folder, api, vehicleRegistry, + vehicleIdentity, vehicleBackups.apply(vehicleDatabase())).run(); + if (outcome.ran()) { + getLogger().info("Vehicle owner migration: " + outcome.migrated() + " migrated, " + outcome.unchanged() + + " unchanged, " + outcome.quarantined() + " quarantined, " + outcome.gone() + " gone"); + } + } + quarantine.load(VehicleOwnerMigration.quarantine(folder)); + } catch (Exception error) { + failFinancialState(error); + return; + } + if (!full) { + getLogger().severe("VehicleFramework 2.13 or newer in characters mode is needed for vehicle ownership; " + + "vehicle claims and leader stamps are off, and upkeep is charged from registry rows"); + } else { + try { + LeaderStamps.reconcile(vehicleRegistry); + int filled = net.tfminecraft.simplefactions.vehicles.registry.RegistryAccounts.fill(folder, vehicleRegistry, + character -> net.tfminecraft.simplefactions.identity.Members.accountId( + net.tfminecraft.simplefactions.identity.Members.key(character)), + java.util.List.of(VehicleOwnerMigration.accounts(folder), + net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.accounts(folder))); + if (filled > 0) getLogger().info("Cached the account on " + filled + " personal vehicle rows"); + quarantine.setFindings(upkeepReconcile().run()); + } catch (Exception error) { + getLogger().log(java.util.logging.Level.SEVERE, "Vehicle reconcile failed, so vehicle listeners and the " + + "ownership authority stay off until a restart; VehicleFramework refuses player ownership changes", error); + return; + } + } + registerVehicleIntegration(full); + if (full) { + VehicleOwnershipServices.registerAuthority(getServer().getServicesManager(), this, + () -> started && LeaderStamps.enabled()); + LeaderStamps.enable(true); + getServer().getScheduler().runTaskTimer(this, this::reconcileVehicleUpkeep, + VEHICLE_RECONCILE_TICKS, VEHICLE_RECONCILE_TICKS); + } + } + + private java.nio.file.Path vehicleDatabase() { + return getServer().getPluginManager().getPlugin("VehicleFramework").getDataFolder().toPath() + .resolve("data/vehicles.db"); + } + + /** The daily upkeep reconcile; its findings replace the previous ones in the staff list. */ + void reconcileVehicleUpkeep() { + try { + VehicleQuarantine.current().setFindings(upkeepReconcile().run()); + } catch (RuntimeException error) { + getLogger().log(java.util.logging.Level.WARNING, "Daily vehicle upkeep reconcile failed", error); + } + } + + private VehicleUpkeepReconcile upkeepReconcile() { + return new VehicleUpkeepReconcile(VehicleOwnerWrites.api(), vehicleRegistry, + (vehicle, type, character, account) -> { + var payments = net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.current(); + return payments != null && payments.personal(vehicle, type, character, account); + }, + SimpleFactions::leadsAFaction, + character -> net.tfminecraft.simplefactions.identity.Members.accountId( + net.tfminecraft.simplefactions.identity.Members.key(character)), + getLogger()); + } + + static boolean leadsAFaction(java.util.UUID character) { + String key = net.tfminecraft.simplefactions.identity.Members.key(character); + for (Faction faction : FactionManager.factions) { + if (key.equals(faction.getLeader())) return true; + } + return false; + } + private void registerRegistrationFee() { if (registrationFeeRegistered || !getServer().getPluginManager().isPluginEnabled("VFBuilders") || !getServer().getPluginManager().isPluginEnabled("VehicleFramework")) { @@ -946,24 +1074,25 @@ private void registerConstructionFreeze() { net.tfminecraft.simplefactions.war.freeze.VfBuildersConstructionFreeze.register(this); } - private void registerVehicleIntegration() { - if (vehicleIntegrationRegistered) { - return; - } - if (!getServer().getPluginManager().isPluginEnabled("VehicleFramework")) { - return; - } + /** Step 5 of the vehicle stage, which runs once per start. */ + private void registerVehicleIntegration(boolean full) { getServer().getPluginManager().registerEvents(vehicleIntegrationListener, this); getServer().getPluginManager().registerEvents(vehicleRegistryClaimListener, this); getServer().getPluginManager().registerEvents(vehicleTransferListener, this); getServer().getPluginManager().registerEvents(factionVehicleReleaseListener, this); getServer().getPluginManager().registerEvents(vehicleMaintenancePayListener, this); getServer().getPluginManager().registerEvents(vehicleMaintenanceRepairListener, this); - getServer().getPluginManager().registerEvents(vehicleSpawnListener, this); getServer().getPluginManager().registerEvents(battleVehicleEligibilityListener, this); getServer().getPluginManager().registerEvents(vehicleHandoverListener, this); getServer().getPluginManager().registerEvents(vehicleReclaimFeeListener, this); - vehicleIntegrationRegistered = true; + if (full) { + getServer().getPluginManager().registerEvents(vehicleSpawnListener, this); + getServer().getPluginManager().registerEvents(new VehicleOwnershipGate(vehicleRegistry, + vehicleRegistryClaimService, vehicleFeeStore, vehicleFeeConfirmations, this::saveVehicleFees, + getLogger()), this); + } else { + getServer().getPluginManager().registerEvents(new VehicleClaimsOff(), this); + } // VehicleFramework loads its vehicles as it enables, so wait before reading owners. getServer().getScheduler().runTaskLater(this, this::recordVehicleOwners, 200L); if (getServer().getPluginManager().isPluginEnabled("VFBuilders")) { diff --git a/src/main/java/net/tfminecraft/simplefactions/events/FactionLeaderChangedEvent.java b/src/main/java/net/tfminecraft/simplefactions/events/FactionLeaderChangedEvent.java new file mode 100644 index 00000000..d17c44ec --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/events/FactionLeaderChangedEvent.java @@ -0,0 +1,61 @@ +package net.tfminecraft.simplefactions.events; + +import org.bukkit.Bukkit; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +import net.tfminecraft.simplefactions.objects.Faction; + +/** + * Fired after a faction's leader changes, from {@code Faction.setLeader} and + * {@code Faction.succeedDeadLeader}, the only two writers of the leader. Loading + * saved factions does not fire it. + */ +public class FactionLeaderChangedEvent extends Event { + private static final HandlerList HANDLERS = new HandlerList(); + + private final Faction faction; + private final String previousLeader; + private final String newLeader; + + public FactionLeaderChangedEvent(Faction faction, String previousLeader, String newLeader) { + this.faction = faction; + this.previousLeader = previousLeader; + this.newLeader = newLeader; + } + + /** + * Fires the event on the main thread of a running server. A change made elsewhere is not + * announced; the vehicle backstop and the next boot's reconcile catch its stamps. + */ + public static void fire(Faction faction, String previousLeader, String newLeader) { + if (java.util.Objects.equals(previousLeader, newLeader) || Bukkit.getServer() == null + || Bukkit.getPluginManager() == null || !Bukkit.isPrimaryThread()) { + return; + } + Bukkit.getPluginManager().callEvent(new FactionLeaderChangedEvent(faction, previousLeader, newLeader)); + } + + public Faction getFaction() { + return faction; + } + + /** The previous leader's member key, possibly null or blank. */ + public String getPreviousLeader() { + return previousLeader; + } + + /** The new leader's member key, possibly null or blank. */ + public String getNewLeader() { + return newLeader; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndex.java b/src/main/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndex.java index 1111fa81..b2fd59fe 100644 --- a/src/main/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndex.java +++ b/src/main/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndex.java @@ -1,6 +1,8 @@ package net.tfminecraft.simplefactions.identity.death; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import java.util.ArrayList; import java.util.HashMap; @@ -44,18 +46,30 @@ Optional get(UUID characterId) { /** * Journals written before the field existed use the first guild listing the * character in the rights snapshots, in their order, without civil-war mapping. + * A malformed snapshot gives no entry rather than guessing past it. */ static UUID incarnation(DeathJournal journal) { if (journal.lockGuildIncarnation != null) return journal.lockGuildIncarnation; JsonPrimitive key = new JsonPrimitive(journal.characterId.toString()); for (DeathJournal.Step step : journal.steps) { if (!step.kind.equals("rights") || !step.plan.has("guilds")) continue; - for (JsonElement guild : step.plan.getAsJsonArray("guilds")) { - if (guild.getAsJsonObject().getAsJsonArray("members").contains(key)) { - return UUID.fromString(guild.getAsJsonObject().get("incarnation").getAsString()); + if (!(step.plan.get("guilds") instanceof JsonArray guilds)) return null; + for (JsonElement guild : guilds) { + if (!(guild instanceof JsonObject object) || !(object.get("members") instanceof JsonArray members)) { + return null; } + if (members.contains(key)) return uuid(object.get("incarnation")); } } return null; } + + private static UUID uuid(JsonElement value) { + if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) return null; + try { + return UUID.fromString(primitive.getAsString()); + } catch (IllegalArgumentException invalid) { + return null; + } + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/installation/InstallationTransferService.java b/src/main/java/net/tfminecraft/simplefactions/installation/InstallationTransferService.java index 0160ab37..709f136a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/installation/InstallationTransferService.java +++ b/src/main/java/net/tfminecraft/simplefactions/installation/InstallationTransferService.java @@ -16,8 +16,6 @@ import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.vehicleframework.VehicleFramework; -import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; public final class InstallationTransferService { private InstallationTransferService() {} @@ -96,7 +94,7 @@ private static void syncBerthedOwners(Faction from, Faction to, String originalI return; } PlayerVehicleRegistry registry = SimpleFactions.getVehicleRegistry(); - InstallationVehicleOwnerSync sync = new InstallationVehicleOwnerSync(registry); + java.util.List moved = new java.util.ArrayList<>(); for (PlayerVehicleRecord record : registry.getByInstallation(from.getId(), originalId)) { // An older record has no faction id. Move it only when the new holder is the // only faction with this installation id, so another faction's vehicle stays put. @@ -113,23 +111,23 @@ private static void syncBerthedOwners(Faction from, Faction to, String originalI record.getMode(), installation.getId(), to.getId())); - try { - ActiveVehicle vehicle = VehicleFramework.getVehicleManager().get(record.getVehicleUuid()); - if (vehicle != null) { - sync.applyLeaderOwner(vehicle, to); - } - } catch (RuntimeException e) { - // Kept per vehicle so the rest still transfer. The owner is corrected on its next spawn. - SimpleFactions.getInstance().getLogger().warning( - "Could not move vehicle " + record.getVehicleUuid() + " to faction " + to.getId() - + " leader after installation transfer; it updates on next spawn: " + e); - } + moved.add(record.getVehicleUuid()); } if (!SimpleFactions.getInstance().saveVehicleRegistry()) { SimpleFactions.getInstance().getLogger().warning( "Could not save vehicle registry after transferring installation " + installation.getId() + " to faction " + to.getId()); } + // Loaded and stored vehicles alike. The rows follow the installation whatever happens here; + // a failed stamp is retried by the next boot's leader reconcile and the click backstop. + for (String vehicle : moved) { + var result = InstallationVehicleOwnerSync.stampForFaction(vehicle, to); + if (result != net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result.OK) { + SimpleFactions.getInstance().getLogger().warning( + "Could not stamp vehicle " + vehicle + " for faction " + to.getId() + + " after installation transfer: " + result); + } + } } catch (Exception failure) { SimpleFactions.getInstance().getLogger().warning( "Could not persist vehicle ownership after transferring installation " + installation.getId() diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 15ae612c..1ebd7b89 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -680,6 +680,8 @@ public static void addFaction(Faction f) { f.setCapital(main.getCapital(), true); } public static void deleteFaction(Faction f){ + // Annexation and untangle moved their vehicles already; whatever still names f loses its leader. + net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps.onFactionDeleted(f); map.enqueue("nation", f.getRGB()); for(Faction fac : factions) { fac.getDiplomacyHandler().removeRelation(f.getId()); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java index 0965f1f6..8945395f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/InstallationView.java @@ -311,7 +311,8 @@ private void handleBerthedVehicleClick( InstallationVehicleUnberthService service = SimpleFactions.getInstance().getInstallationVehicleUnberthService(); InstallationVehicleUnberthService.UnberthOutcome outcome = - service.unberth(faction, Members.key(player), installation, vehicleUuid); + service.unberth(faction, Members.session(player).map(session -> session.ref()).orElse(null), + installation, vehicleUuid); player.sendMessage(InstallationVehicleUnberthService.messageFor(outcome)); if (outcome.result() == InstallationVehicleUnberthService.UnberthResult.OK) { installationDetailView(player, faction, installationId); diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java index 67f39d21..17f116e2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java @@ -771,14 +771,23 @@ public void rememberLeaderCharacter(String name, String player) { } public void setLeader(String leader) { getOrCreateMainGuild().setLeader(leader); - this.leader = leader; + changeLeader(leader); net.tfminecraft.simplefactions.mercenary.contract.MercenaryLoyaltyWatcher.onGovernmentChanged(this); } /** Applies only the journal's captured succession; a later deliberate appointment wins. */ public void succeedDeadLeader(String dead, String successor) { if (leader != null && !leader.isBlank() && !dead.equals(leader)) return; + String previous = leader; leader = successor; getOrCreateMainGuild().setLeader(successor); + net.tfminecraft.simplefactions.events.FactionLeaderChangedEvent.fire(this, previous, successor); + } + + /** With succeedDeadLeader, the only writer of the leader after construction; vehicles restamp from its event. */ + private void changeLeader(String next) { + String previous = leader; + leader = next; + net.tfminecraft.simplefactions.events.FactionLeaderChangedEvent.fire(this, previous, next); } public void promoteToLeader(String name) { diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleGiveConsentRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleGiveConsentRequest.java index 927b8170..f528a6f5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleGiveConsentRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleGiveConsentRequest.java @@ -2,6 +2,8 @@ import java.util.UUID; +import net.tfminecraft.tlibs.character.CharacterRef; + import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; @@ -12,7 +14,10 @@ public final class VehicleGiveConsentRequest extends Request { private final UUID recipientUuid; private final UUID leaderUuid; private final String leaderName; + private final CharacterRef leaderCharacter; + private final CharacterRef recipientCharacter; + /** Both characters are captured when the gift is offered; accepting needs the same ones. */ public VehicleGiveConsentRequest( Guild sender, String factionId, @@ -20,8 +25,12 @@ public VehicleGiveConsentRequest( String vehicleTypeId, UUID recipientUuid, UUID leaderUuid, - String leaderName) { + CharacterRef leaderCharacter, + CharacterRef recipientCharacter) { super(sender); + this.leaderCharacter = leaderCharacter; + this.recipientCharacter = recipientCharacter; + String leaderName = net.tfminecraft.simplefactions.identity.Members.key(leaderCharacter); this.factionId = factionId; this.vehicleUuid = vehicleUuid; this.vehicleTypeId = vehicleTypeId; @@ -52,7 +61,16 @@ public UUID getLeaderUuid() { return leaderUuid; } + /** The leader's member key when the gift was offered. */ public String getLeaderName() { return leaderName; } + + public CharacterRef getLeaderCharacter() { + return leaderCharacter; + } + + public CharacterRef getRecipientCharacter() { + return recipientCharacter; + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java index be9cf61a..41d1c333 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java @@ -16,7 +16,6 @@ public final class VehicleHandoverRequest extends Request { private final String vehicleUuid; private final String vehicleTypeId; private final UUID ownerUuid; - private final String ownerName; /** The owner's character when the offer was made; it pays the fee even if they log off. */ private final String ownerKey; private final UUID recipientUuid; @@ -26,6 +25,8 @@ public final class VehicleHandoverRequest extends Request { private final CharacterRef ownerCharacter; private final CharacterRef recipientCharacter; private final String operationId = "sf:vehicle:handover:" + UUID.randomUUID(); + /** When the owner made the offer; the fee's intent carries it, so a retry matches. */ + private final long offeredAt = System.currentTimeMillis(); /** The sender guild is null when the owner is in no faction. */ public VehicleHandoverRequest( @@ -33,13 +34,11 @@ public VehicleHandoverRequest( String vehicleUuid, String vehicleTypeId, UUID ownerUuid, - String ownerName, UUID recipientUuid) { super(sender); this.vehicleUuid = vehicleUuid; this.vehicleTypeId = vehicleTypeId; this.ownerUuid = ownerUuid; - this.ownerName = ownerName; this.recipientUuid = recipientUuid; this.ownerCharacter = Members.session(Bukkit.getPlayer(ownerUuid)).map(value -> value.ref()).orElse(null); this.recipientCharacter = Members.session(Bukkit.getPlayer(recipientUuid)).map(value -> value.ref()).orElse(null); @@ -64,10 +63,6 @@ public UUID getOwnerUuid() { return ownerUuid; } - public String getOwnerName() { - return ownerName; - } - public String getOwnerKey() { return ownerKey; } @@ -79,6 +74,7 @@ public UUID getRecipientUuid() { public CharacterRef getOwnerCharacter() { return ownerCharacter; } public CharacterRef getRecipientCharacter() { return recipientCharacter; } public String getOperationId() { return operationId; } + public long getOfferedAt() { return offeredAt; } public boolean matchesFee(Quote quote) { return java.util.Objects.equals(feeFactionId, quote == null ? null : quote.faction().getId()) diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequest.java index 09a57bc8..d5493514 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequest.java @@ -2,6 +2,8 @@ import java.util.UUID; +import net.tfminecraft.tlibs.character.CharacterRef; + import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; @@ -14,6 +16,8 @@ public final class VehicleTransferConsentRequest extends Request { private final UUID proposerLeaderUuid; private final boolean pool; private final String destinationFactionId; + private final CharacterRef ownerCharacter; + private final CharacterRef leaderCharacter; public VehicleTransferConsentRequest( Guild sender, @@ -21,22 +25,14 @@ public VehicleTransferConsentRequest( String installationName, String vehicleUuid, String vehicleTypeId, - UUID ownerUuid, - UUID proposerLeaderUuid) { - this(sender, installationId, installationName, vehicleUuid, vehicleTypeId, - ownerUuid, proposerLeaderUuid, false); - } - - public VehicleTransferConsentRequest( - Guild sender, - String installationId, - String installationName, - String vehicleUuid, - String vehicleTypeId, - UUID ownerUuid, - UUID proposerLeaderUuid, + CharacterRef ownerCharacter, + CharacterRef leaderCharacter, boolean pool) { super(sender); + UUID ownerUuid = ownerCharacter.ownerId(); + UUID proposerLeaderUuid = leaderCharacter.ownerId(); + this.ownerCharacter = ownerCharacter; + this.leaderCharacter = leaderCharacter; this.installationId = installationId; this.installationName = installationName; this.vehicleUuid = vehicleUuid; @@ -80,4 +76,14 @@ public String getDestinationFactionId() { public boolean isPool() { return pool; } + + /** The vehicle owner's character when the request was sent. */ + public CharacterRef getOwnerCharacter() { + return ownerCharacter; + } + + /** The proposing leader's character when the request was sent. */ + public CharacterRef getLeaderCharacter() { + return leaderCharacter; + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleIntegrationListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleIntegrationListener.java index 39154ff9..ab6fb6c4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleIntegrationListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleIntegrationListener.java @@ -4,10 +4,7 @@ import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard; import net.tfminecraft.simplefactions.vehicles.berth.VehicleConstructionMessages; import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard.CanBuildResult; -import java.util.UUID; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -17,8 +14,6 @@ import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.vfbuilders.core.Blueprint; import net.tfminecraft.vfbuilders.events.BeginVehicleConstructionEvent; -import net.tfminecraft.vfbuilders.events.VehicleConstructEvent; -import net.tfminecraft.vehicleframework.cache.Cache; import net.tfminecraft.vehicleframework.data.VehicleRemovePayload; import net.tfminecraft.vehicleframework.events.VehicleRemoveEvent; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; @@ -44,25 +39,6 @@ public void onBeginVehicleConstruction(BeginVehicleConstructionEvent event) { } } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onVehicleConstruct(VehicleConstructEvent event) { - UUID constructorUuid = event.getConstructorUuid(); - ActiveVehicle vehicle = event.getVehicle(); - Blueprint blueprint = event.getBlueprint(); - - if (constructorUuid == null || vehicle == null || blueprint == null) { - SimpleFactions.getInstance().getLogger().warning( - "[SimpleFactions] VehicleConstructEvent missing constructor, vehicle, or blueprint"); - return; - } - - String ownerEntry = resolveOwnerEntry(constructorUuid, event.getConstructor()); - vehicle.getOwnerData().setOwner(ownerEntry); - if (Cache.allowWhitelist) { - vehicle.getOwnerData().setWhiteListed(true); - } - } - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onVehicleRemove(VehicleRemoveEvent event) { ActiveVehicle vehicle = event.getVehicle(); @@ -96,18 +72,6 @@ static boolean dropRecord(PlayerVehicleRegistry registry, String vehicleUuid, Ve return registry.unregister(vehicleUuid); } - private static String resolveOwnerEntry(UUID constructorUuid, Player onlineConstructor) { - if (onlineConstructor != null) { - return "player_" + onlineConstructor.getName(); - } - OfflinePlayer offline = Bukkit.getOfflinePlayer(constructorUuid); - String name = offline.getName(); - if (name != null && !name.isEmpty()) { - return "player_" + name; - } - return "player_" + constructorUuid; - } - public static String resolveVehicleTypeId(Blueprint blueprint) { Vehicle vehicle = blueprint.getVehicle(); if (vehicle != null && vehicle.getId() != null && !vehicle.getId().isEmpty()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java index 07d92901..afd0cacc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/battle/BattleVehicleEligibilityService.java @@ -6,9 +6,10 @@ import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; import java.util.Optional; +import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -125,13 +126,11 @@ public static Player resolveNotifyPlayer(ActiveVehicle vehicle, PlayerVehicleReg if (vehicle == null) { return null; } - String ownerName = VehicleOwnershipQueries.playerNameFromOwner( - vehicle.getOwnerData() == null ? null : vehicle.getOwnerData().getOwner()); - if (ownerName != null) { - Player owner = Bukkit.getPlayerExact(ownerName); - if (owner != null) { - return owner; - } + UUID ownerCharacter = OwnerTags.characterId( + vehicle.getOwnerData() == null ? null : vehicle.getOwnerData().getOwner()).orElse(null); + Player owner = ownerCharacter == null ? null : Members.online(Members.key(ownerCharacter)).orElse(null); + if (owner != null) { + return owner; } if (registry == null || vehicle.getUUID() == null) { return null; diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java index 60559f06..30525ae0 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java @@ -23,6 +23,12 @@ public void offer(Player leader, Player recipient, Faction faction, String vehic if (leader == null || recipient == null || faction == null || vehicleUuid == null) { return; } + var leaderCharacter = Members.session(leader).map(session -> session.ref()).orElse(null); + var recipientCharacter = Members.session(recipient).map(session -> session.ref()).orElse(null); + if (leaderCharacter == null || recipientCharacter == null) { + leader.sendMessage(FactionVehicleReleaseMessages.ownershipUnavailable()); + return; + } VehicleGiveConsentRequest request = new VehicleGiveConsentRequest( faction.getOrCreateMainGuild(), faction.getId(), @@ -30,7 +36,8 @@ public void offer(Player leader, Player recipient, Faction faction, String vehic vehicleTypeId, recipient.getUniqueId(), leader.getUniqueId(), - Members.key(leader)); + leaderCharacter, + recipientCharacter); RequestManager.addRequest(leader, recipient, request); // addRequest refuses when the recipient is already considering a request, even one for this vehicle. if (RequestManager.getRequest(recipient) != request) { @@ -52,6 +59,11 @@ public void acceptRequest(Player recipient) { notifyExpired(req, recipient); return; } + if (!Members.session(recipient).map(session -> session.ref()).filter(req.getRecipientCharacter()::equals) + .isPresent()) { + recipient.sendMessage("§cThis request belongs to another character."); + return; + } Faction faction = FactionManager.getByString(req.getFactionId()); if (faction == null || faction.getLeader() == null || !faction.getLeader().equals(req.getLeaderName())) { @@ -64,7 +76,7 @@ public void acceptRequest(Player recipient) { Outcome outcome = releaseService.give( faction, req.getLeaderName(), - recipient.getName(), + req.getRecipientCharacter(), req.getVehicleUuid()); if (outcome.status() != Status.OK) { String message = FactionVehicleReleaseMessages.forGive(outcome, Members.name(recipient)); @@ -76,7 +88,7 @@ public void acceptRequest(Player recipient) { } SimpleFactions plugin = SimpleFactions.getInstance(); if (plugin != null) { - plugin.recordVehicleOwner(req.getVehicleUuid(), Members.key(recipient)); + plugin.recordVehicleOwner(req.getVehicleUuid(), Members.key(req.getRecipientCharacter())); } recipient.sendMessage(FactionVehicleReleaseMessages.giveSuccessRecipient(Members.display(req.getLeaderName()))); notifyLeader(req, FactionVehicleReleaseMessages.giveSuccessLeader(Members.name(recipient))); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java index a86b0754..a0a473af 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java @@ -61,7 +61,8 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) { return; } - Outcome outcome = releaseService.take(faction, Members.key(leader), vehicle.getUUID()); + Outcome outcome = releaseService.take( + faction, Members.session(leader).map(value -> value.ref()).orElse(null), vehicle.getUUID()); SimpleFactions plugin = SimpleFactions.getInstance(); if (outcome.status() == Status.OK && plugin != null) { plugin.recordVehicleOwner(vehicle.getUUID(), Members.key(leader)); @@ -92,7 +93,8 @@ private void handleGive( } Outcome outcome = releaseService.evaluateGive( - faction, Members.key(leader), recipient.getName(), vehicleUuid); + faction, Members.key(leader), Members.session(recipient).map(value -> value.ref()).orElse(null), + vehicleUuid); if (outcome.status() != Status.OK) { String message = FactionVehicleReleaseMessages.forGive(outcome, Members.name(recipient)); if (message != null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseMessages.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseMessages.java index 58b30920..6db9aa36 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseMessages.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseMessages.java @@ -124,6 +124,7 @@ static String personalRoom(CanBuildResult slot, String vehicleTypeId, String oth + typeName(vehicleTypeId) + " vehicles (" + VehiclesConfigLoader.getPerPersonLimit(vehicleTypeId) + ")."; case UNKNOWN_TYPE -> "§cThis vehicle type is not registered for faction upkeep."; + case UNAVAILABLE -> VehicleConstructionMessages.UNAVAILABLE; }; } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java index 86706e61..3fb0d15a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java @@ -5,25 +5,21 @@ import java.time.Instant; import java.util.Optional; import java.util.UUID; -import org.bukkit.Bukkit; import java.util.function.BooleanSupplier; -import org.json.simple.JSONObject; -import org.json.simple.parser.JSONParser; import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites; +import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard.CanBuildResult; import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; -import net.tfminecraft.vehicleframework.VehicleFramework; -import net.tfminecraft.vehicleframework.data.StoredVehicleMeta; -import net.tfminecraft.vehicleframework.database.VehiclePersistence; -import net.tfminecraft.vehicleframework.database.VehicleSnapshot; -import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; public final class FactionVehicleReleaseService { private final PlayerVehicleRegistry registry; @@ -53,7 +49,7 @@ private static boolean saveRegistry() { } public interface PersonalOwner { - boolean assign(String vehicleUuid, String playerName); + boolean assign(String vehicleUuid, UUID character); } public enum Status { @@ -70,51 +66,49 @@ public enum Status { public record Outcome(Status status, CanBuildResult slotFailure, String vehicleTypeId) {} - /** {@code leader} is the leader's member key; recipients are account names, as VehicleFramework owners are. */ - public Outcome take(Faction faction, String leader, String vehicleUuid) { - return release(faction, leader, VehicleOwnershipQueries.accountNameOf(leader), vehicleUuid, null, true); + /** The leader takes the vehicle for the character they are playing now. */ + public Outcome take(Faction faction, CharacterRef leader, String vehicleUuid) { + return release(faction, Members.key(leader), leader, vehicleUuid, null, true); } public Outcome takeFromInstallation( Faction faction, - String leader, + CharacterRef leader, String installationId, String vehicleUuid) { - return release(faction, leader, VehicleOwnershipQueries.accountNameOf(leader), vehicleUuid, installationId, true); + return release(faction, Members.key(leader), leader, vehicleUuid, installationId, true); } - public Outcome give(Faction faction, String leaderName, String recipientName, String vehicleUuid) { - return release(faction, leaderName, recipientName, vehicleUuid, null, true); + /** {@code leaderKey} is the leader's member key; {@code recipient} was captured when the gift was offered. */ + public Outcome give(Faction faction, String leaderKey, CharacterRef recipient, String vehicleUuid) { + return release(faction, leaderKey, recipient, vehicleUuid, null, true); } public Outcome evaluateGive( Faction faction, - String leaderName, - String recipientName, + String leaderKey, + CharacterRef recipient, String vehicleUuid) { - return release(faction, leaderName, recipientName, vehicleUuid, null, false); + return release(faction, leaderKey, recipient, vehicleUuid, null, false); } private Outcome release( Faction faction, - String leaderName, - String recipientName, + String leaderKey, + CharacterRef recipient, String vehicleUuid, String requiredInstallationId, boolean apply) { if (faction == null - || leaderName == null - || leaderName.isBlank() + || leaderKey == null + || leaderKey.isBlank() || vehicleUuid == null || vehicleUuid.isBlank()) { return outcome(Status.NOT_FACTION_VEHICLE, null, null); } - if (faction.getLeader() == null || !faction.getLeader().equals(leaderName)) { + if (faction.getLeader() == null || !faction.getLeader().equals(leaderKey)) { return outcome(Status.NOT_LEADER, null, null); } - if (recipientName == null || recipientName.isBlank()) { - return outcome(Status.NOT_FACTION_VEHICLE, null, null); - } Optional recordOpt = registry.getByVehicleUuid(vehicleUuid); if (recordOpt.isEmpty()) { @@ -134,8 +128,11 @@ private Outcome release( faction.getInstallationHandler().getById(record.getInstallationId()), Instant.now())) { return outcome(Status.INSTALLATION_LOCKED, null, typeId); } + if (recipient == null) { + return outcome(Status.OWNERSHIP_UNAVAILABLE, null, typeId); + } - CanBuildResult slot = VehicleSlotGuard.checkCanBuild(recipientName, typeId, registry); + CanBuildResult slot = VehicleSlotGuard.checkCanBuild(recipient.ownerId(), typeId, registry); if (slot == CanBuildResult.UNKNOWN_TYPE) { return outcome(Status.UNKNOWN_TYPE, slot, typeId); } @@ -146,16 +143,15 @@ private Outcome release( if (!apply) { return outcome(Status.OK, null, typeId); } - // Record the captured recipient as payer before changing the external ownership tag. - UUID recipientCharacter = Members.session(Bukkit.getPlayerExact(recipientName)).map(session -> session.ref().characterId()).orElse(null); - if (recipientCharacter == null) return outcome(Status.OWNERSHIP_UNAVAILABLE, null, typeId); - registry.register(new PlayerVehicleRecord(recipientCharacter, vehicleUuid, typeId, OwnershipMode.PERSONAL, null)); + // Record the recipient as payer before changing VehicleFramework's owner. + registry.register(new PlayerVehicleRecord(recipient.characterId(), vehicleUuid, typeId, + OwnershipMode.PERSONAL, null, null, recipient.ownerId())); if (!registrySaver.getAsBoolean()) { // The file still holds the record, so restoring it in memory is enough. registry.register(record); return outcome(Status.SAVE_FAILED, null, typeId); } - if (!personalOwner.assign(vehicleUuid, recipientName)) { + if (!personalOwner.assign(vehicleUuid, recipient.characterId())) { registry.register(record); if (!registrySaver.getAsBoolean()) { logSevere("Vehicle " + vehicleUuid + " (" + typeId + ") was restored to faction " @@ -192,89 +188,10 @@ private static Outcome outcome(Status status, CanBuildResult slotFailure, String return new Outcome(status, slotFailure, vehicleTypeId); } - public static boolean assignFrameworkOwner(String vehicleUuid, String playerName) { - if (vehicleUuid == null || vehicleUuid.isBlank() || playerName == null || playerName.isBlank()) { - return false; - } - String entry = VehicleOwnershipQueries.ownerEntry(playerName); - ActiveVehicle loaded; - try { - loaded = VehicleFramework.getVehicleManager().get(vehicleUuid); - } catch (Throwable ignored) { - // VehicleFramework is not running in unit tests. - loaded = null; - } - if (loaded != null) { - // A loaded vehicle is the live copy, so never fall back to the stored one for it. - return assignLoaded(loaded, entry); - } - try { - if (assignStored(vehicleUuid, entry)) { - return true; - } - return storedOwnerMatches(vehicleUuid, entry); - } catch (Throwable ignored) { - return false; - } - } - - /** Sets the owner and saves it; the old owner is put back if the save fails. */ - private static boolean assignLoaded(ActiveVehicle vehicle, String ownerEntry) { - if (vehicle.getOwnerData() == null) { - return false; - } - String previous = vehicle.getOwnerData().getOwner(); - vehicle.getOwnerData().setOwner(ownerEntry); - try { - VehiclePersistence persistence = VehiclePersistence.current(); - if (persistence != null && !persistence.saveLive(vehicle)) { - vehicle.getOwnerData().setOwner(previous); - return false; - } - return true; - } catch (RuntimeException e) { - vehicle.getOwnerData().setOwner(previous); - return false; - } - } - - @SuppressWarnings("unchecked") - private static boolean assignStored(String vehicleUuid, String ownerEntry) { - VehiclePersistence persistence = VehiclePersistence.current(); - if (persistence == null) { - return false; - } - Optional found = persistence.findLive(vehicleUuid); - if (found.isEmpty()) { - return false; - } - VehicleSnapshot snapshot = found.get(); - String payloadJson = snapshot.getPayloadJson(); - if (payloadJson == null || payloadJson.isBlank()) { - return false; - } - JSONObject payload; - try { - payload = (JSONObject) new JSONParser().parse(payloadJson); - } catch (Exception e) { - return false; - } - payload.put("owner", ownerEntry); - VehicleSnapshot updated = snapshot.withPayload( - payload.toJSONString(), - snapshot.getName(), - ownerEntry, - System.currentTimeMillis()); - return persistence.repository().saveLive(updated); - } - - private static boolean storedOwnerMatches(String vehicleUuid, String ownerEntry) { - VehiclePersistence persistence = VehiclePersistence.current(); - if (persistence == null) { - return false; - } - Optional meta = persistence.readMeta(vehicleUuid); - return meta.isPresent() && ownerEntry.equalsIgnoreCase(meta.get().getOwner()); + /** Gives a released faction vehicle to the character, emptying its crew list. */ + public static boolean assignFrameworkOwner(String vehicleUuid, UUID character) { + return character != null + && VehicleOwnerWrites.write(vehicleUuid, OwnerTags.character(character), Reason.RELEASE) == Result.OK; } private static void logSevere(String message) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSync.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSync.java index 82132c5d..474f3940 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSync.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSync.java @@ -1,16 +1,16 @@ package net.tfminecraft.simplefactions.vehicles.berth; - -import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; -import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; -import java.util.Optional; - -import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +/** Keeps faction vehicles stamped for their faction's leader character. */ public final class InstallationVehicleOwnerSync { private final PlayerVehicleRegistry registry; @@ -19,91 +19,27 @@ public InstallationVehicleOwnerSync(PlayerVehicleRegistry registry) { } /** - * VehicleFramework names the leader's account. Null while that account cannot be - * read, so callers leave the owner as it is. + * A vehicle joining a faction: stamped for the faction's leader, with the old owner's + * whitelist entries dropped and the whitelist switch left as it was. */ - public static String expectedOwner(Faction faction) { - if (faction == null || faction.getLeader() == null) { - return "player_none"; - } - String account = VehicleOwnershipQueries.accountNameOf(faction.getLeader()); - return account == null ? null : "player_" + account; - } - - public void applyLeaderOwner(ActiveVehicle vehicle, Faction faction) { - if (vehicle == null || faction == null) { - return; - } - applyLeaderOwner(vehicle.getOwnerData(), faction); - } - - public void applyLeaderOwner(net.tfminecraft.vehicleframework.data.OwnerData ownerData, Faction faction) { - if (ownerData == null || faction == null) { - return; - } - String expected = expectedOwner(faction); - if (expected != null) { - ownerData.setOwner(expected); - } + public static Result stampForFaction(String vehicleUuid, Faction faction) { + return VehicleOwnerWrites.write(vehicleUuid, LeaderStamps.expectedStamp(faction), Reason.TRANSFER); } + /** On spawn, a faction vehicle with a stale stamp is restamped. */ public void syncIfBerthed(ActiveVehicle vehicle) { - if (vehicle == null || vehicle.getUUID() == null) { - return; - } - syncIfBerthed(vehicle.getUUID(), vehicle.getOwnerData()); - } - - void syncIfBerthed(String vehicleUuid, net.tfminecraft.vehicleframework.data.OwnerData ownerData) { - if (vehicleUuid == null || ownerData == null) { + if (vehicle == null || vehicle.getUUID() == null || !LeaderStamps.enabled()) { return; } - - Optional recordOpt = registry.getByVehicleUuid(vehicleUuid); - if (recordOpt.isEmpty()) { - return; - } - PlayerVehicleRecord record = recordOpt.get(); - if (record.getMode() == OwnershipMode.POOL) { - Faction faction = FactionManager.getByString(record.getFactionId()); - applyExpectedOwner(ownerData, faction); - return; - } - if (record.getMode() != OwnershipMode.INSTALLATION) { - return; - } - - String installationId = record.getInstallationId(); - if (installationId == null) { - return; - } - - // Resolves by the record's faction id, since two factions can reuse an installation id. - Faction faction = net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService.payingFaction(record); - if (faction == null) { - return; - } - - applyExpectedOwner(ownerData, faction); - } - - private static void applyExpectedOwner( - net.tfminecraft.vehicleframework.data.OwnerData ownerData, - Faction faction) { - if (faction == null) { - return; - } - String expected = expectedOwner(faction); - if (expected == null) { + PlayerVehicleRecord record = registry.getByVehicleUuid(vehicle.getUUID()).orElse(null); + String current = vehicle.getOwnerData() == null ? null : vehicle.getOwnerData().getOwner(); + if (!LeaderStamps.isStale(record, current)) { return; } - String current = ownerData.getOwner(); - if (!expected.equalsIgnoreCase(current) || isLegacyFactionOwner(current)) { - ownerData.setOwner(expected); + Result result = VehicleOwnerWrites.write(vehicle.getUUID(), LeaderStamps.expected(record), Reason.RESTAMP); + if (result != Result.OK && SimpleFactions.getInstance() != null) { + SimpleFactions.getInstance().getLogger().warning( + "Could not restamp faction vehicle " + vehicle.getUUID() + " on spawn: " + result); } } - - private static boolean isLegacyFactionOwner(String owner) { - return owner != null && owner.startsWith("faction_"); - } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleService.java index e3a24ce7..49925f67 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleService.java @@ -4,7 +4,8 @@ import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; import java.time.Instant; import java.util.Optional; import java.util.UUID; @@ -53,7 +54,7 @@ CanRegisterResult canRegister(Installation installation, VehicleBerthTarget vehi OwnerData ownerData = vehicle.getOwnerData(); String owner = ownerData == null ? null : ownerData.getOwner(); - if (!VehicleOwnershipQueries.isPlayerOwner(owner)) { + if (!OwnerTags.isCharacter(owner)) { return CanRegisterResult.NOT_IN_REGISTRY; } @@ -121,7 +122,16 @@ boolean tryRegister(Installation installation, VehicleBerthTarget vehicle, Facti registry.register(previous); return false; } - ownerSync.applyLeaderOwner(vehicle.getOwnerData(), faction); + if (InstallationVehicleOwnerSync.stampForFaction(vehicle.getVehicleUuid(), faction) != Result.OK) { + // The registry went first, so put the personal row back before reporting the failure. + registry.unregister(vehicle.getVehicleUuid()); + registry.register(previous); + if (plugin != null && !plugin.saveVehicleRegistry()) { + plugin.getLogger().severe("Vehicle " + vehicle.getVehicleUuid() + " was restored in memory after" + + " its berth stamp failed, but the registry could not be saved."); + } + return false; + } return true; } @@ -178,6 +188,7 @@ public enum TryRegisterResult { SKIP, ALLOWED, FAIL_UNKNOWN_TYPE, - FAIL_SLOT_LIMIT + FAIL_SLOT_LIMIT, + FAIL_FACTION_VEHICLE } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthService.java index 35192573..9ebd757c 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthService.java @@ -6,6 +6,7 @@ import net.tfminecraft.simplefactions.vehicles.berth.FactionVehicleReleaseService.Status; import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard.CanBuildResult; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.tlibs.character.CharacterRef; public final class InstallationVehicleUnberthService { private final FactionVehicleReleaseService releaseService; @@ -38,14 +39,15 @@ public enum UnberthResult { public record UnberthOutcome(UnberthResult result, CanBuildResult slotFailure, String vehicleTypeId) {} + /** {@code leader} is the character the leader plays now; the vehicle becomes theirs. */ public UnberthOutcome unberth( Faction faction, - String leaderName, + CharacterRef leader, Installation installation, String vehicleUuid) { String installationId = installation == null ? "" : installation.getId(); Outcome outcome = releaseService.takeFromInstallation( - faction, leaderName, installationId, vehicleUuid); + faction, leader, installationId, vehicleUuid); return new UnberthOutcome(map(outcome.status()), outcome.slotFailure(), outcome.vehicleTypeId()); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleConstructionMessages.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleConstructionMessages.java index dd004340..00a454dc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleConstructionMessages.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleConstructionMessages.java @@ -6,6 +6,8 @@ public final class VehicleConstructionMessages { private VehicleConstructionMessages() {} + public static final String UNAVAILABLE = "§cVehicle limits cannot be checked right now. Try again later."; + public static String forResult(CanBuildResult result, String vehicleTypeId) { if (result == null) { return null; @@ -16,6 +18,7 @@ public static String forResult(CanBuildResult result, String vehicleTypeId) { case TOTAL_LIMIT -> totalLimit(); case PER_TYPE_LIMIT -> perTypeLimit(vehicleTypeId); case UNKNOWN_TYPE -> "§cThis vehicle type is not registered for faction upkeep."; + case UNAVAILABLE -> UNAVAILABLE; }; } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleSlotGuard.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleSlotGuard.java index 1e81b7b7..a32c4dee 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleSlotGuard.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleSlotGuard.java @@ -1,15 +1,22 @@ package net.tfminecraft.simplefactions.vehicles.berth; - -import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import java.util.ArrayList; import java.util.List; +import java.util.UUID; import org.bukkit.entity.Player; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; -import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +/** + * Personal vehicle limits are per account, counted from personal registry rows. A row + * whose account cannot be worked out counts against the account being checked, and a + * lookup error refuses. + */ public final class VehicleSlotGuard { private VehicleSlotGuard() {} @@ -18,14 +25,13 @@ public static CanBuildResult checkCanBuild(Player player, String vehicleTypeId) return CanBuildResult.UNKNOWN_TYPE; } return checkCanBuild( - player.getName(), + player.getUniqueId(), vehicleTypeId, net.tfminecraft.simplefactions.SimpleFactions.getVehicleRegistry()); } - public static CanBuildResult checkCanBuild( - String playerName, String vehicleTypeId, PlayerVehicleRegistry registry) { - if (playerName == null || playerName.isBlank() || registry == null) { + public static CanBuildResult checkCanBuild(UUID account, String vehicleTypeId, PlayerVehicleRegistry registry) { + if (account == null || registry == null) { return CanBuildResult.UNKNOWN_TYPE; } if (vehicleTypeId == null || vehicleTypeId.isEmpty()) { @@ -35,11 +41,15 @@ public static CanBuildResult checkCanBuild( return CanBuildResult.UNKNOWN_TYPE; } - List personal = - VehicleOwnershipQueries.personalVehicles(playerName, registry); + List personal; + try { + personal = personalTypes(account, registry); + } catch (RuntimeException | LinkageError error) { + return CanBuildResult.UNAVAILABLE; + } - if (VehicleOwnershipQueries.countOfType(personal, vehicleTypeId) - >= VehiclesConfigLoader.getPerPersonLimit(vehicleTypeId)) { + long ofType = personal.stream().filter(vehicleTypeId::equalsIgnoreCase).count(); + if (ofType >= VehiclesConfigLoader.getPerPersonLimit(vehicleTypeId)) { return CanBuildResult.PER_TYPE_LIMIT; } @@ -52,17 +62,38 @@ public static CanBuildResult checkCanBuild( return CanBuildResult.OK; } - if (VehicleOwnershipQueries.countExcludingIgnoreLimit(personal) >= totalLimit) { + long counted = personal.stream().filter(type -> !VehiclesConfigLoader.ignoresPersonalSlotLimit(type)).count(); + if (counted >= totalLimit) { return CanBuildResult.TOTAL_LIMIT; } return CanBuildResult.OK; } + /** Types of the personal vehicles counted against this account. */ + static List personalTypes(UUID account, PlayerVehicleRegistry registry) { + List types = new ArrayList<>(); + for (PlayerVehicleRecord record : registry.getAll()) { + if (record.getMode() != OwnershipMode.PERSONAL) continue; + UUID owner = accountOf(record); + if (owner == null || owner.equals(account)) types.add(record.getVehicleTypeId()); + } + return types; + } + + /** The row's cached account, else the account that plays its character; null when unknown. */ + public static UUID accountOf(PlayerVehicleRecord record) { + if (record.getAccountUuid() != null) return record.getAccountUuid(); + if (record.getPlayerUuid() == null) return null; + return Members.accountId(Members.key(record.getPlayerUuid())).orElse(null); + } + public enum CanBuildResult { OK, TOTAL_LIMIT, PER_TYPE_LIMIT, - UNKNOWN_TYPE + UNKNOWN_TYPE, + /** The count could not be read, so nothing new is allowed. */ + UNAVAILABLE } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java index a5d4bb36..93190ec4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentService.java @@ -9,7 +9,8 @@ import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService.CanAddResult; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; +import net.tfminecraft.tlibs.character.CharacterRef; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -56,9 +57,11 @@ public boolean trySendConsentRequest(Player leader, Player owner, Faction factio || installation == null || vehicle == null) { return false; } + var characters = characters(leader, owner); + if (characters == null) return false; VehicleTransferConsentRequest request = new VehicleTransferConsentRequest( faction.getOrCreateMainGuild(), installation.getId(), installation.getName(), - vehicle.getUUID(), vehicle.getId(), owner.getUniqueId(), leader.getUniqueId()); + vehicle.getUUID(), vehicle.getId(), characters.owner(), characters.leader(), false); RequestManager.addRequest(leader, owner, request); if (RequestManager.getRequest(owner) != request) return false; owner.sendMessage(VehicleTransferMessages.consentPrompt( @@ -75,9 +78,11 @@ public boolean trySendPoolConsentRequest(Player leader, Player owner, Faction fa if (leader == null || owner == null || faction == null || vehicle == null) { return false; } + var characters = characters(leader, owner); + if (characters == null) return false; VehicleTransferConsentRequest request = new VehicleTransferConsentRequest( faction.getOrCreateMainGuild(), null, "faction vehicle pool", vehicle.getUUID(), - vehicle.getId(), owner.getUniqueId(), leader.getUniqueId(), true); + vehicle.getId(), characters.owner(), characters.leader(), true); RequestManager.addRequest(leader, owner, request); if (RequestManager.getRequest(owner) != request) return false; owner.sendMessage(VehicleTransferMessages.poolConsentPrompt(Members.name(leader), vehicle.getId())); @@ -94,6 +99,10 @@ public void acceptRequest(Player owner) { owner.sendMessage("§cYou cannot accept this request."); return; } + if (!Members.session(owner).map(session -> session.ref()).filter(req.getOwnerCharacter()::equals).isPresent()) { + owner.sendMessage("§cThis request belongs to another character."); + return; + } if (req.timedOut()) { notifyExpired(req, owner); @@ -139,7 +148,7 @@ public void acceptRequest(Player owner) { } if (!installationVehicleService.tryRegister( - installation, vehicle, faction, VehicleOwnershipQueries.characterId(owner))) { + installation, vehicle, faction, req.getOwnerCharacter().characterId())) { String message = VehicleTransferMessages.saveFailed(); owner.sendMessage(message); notifyProposer(req, message); @@ -169,7 +178,7 @@ private void acceptPoolRequest(Player owner, VehicleTransferConsentRequest req, return; } - if (!poolService.tryRegister(faction, vehicle, VehicleOwnershipQueries.characterId(owner))) { + if (!poolService.tryRegister(faction, vehicle, req.getOwnerCharacter().characterId())) { String message = VehicleTransferMessages.saveFailed(); owner.sendMessage(message); notifyProposer(req, message); @@ -183,8 +192,8 @@ private void acceptPoolRequest(Player owner, VehicleTransferConsentRequest req, /** Refuses the request if the vehicle was sold or re-claimed after the request was sent. */ private boolean changedHands(Player owner, VehicleTransferConsentRequest req) { - String current = VehicleOwnershipQueries.playerNameFromOwner(currentOwnerEntry(req.getVehicleUuid())); - if (current == null || current.equalsIgnoreCase(owner.getName())) { + String current = currentOwnerEntry(req.getVehicleUuid()); + if (current == null || OwnerTags.names(current, req.getOwnerCharacter().characterId())) { // An unloaded or unowned vehicle is reported by the validation that follows. return false; } @@ -271,12 +280,26 @@ ActiveVehicle resolveVehicle(String vehicleUuid) { private static Faction resolveProposerFaction(VehicleTransferConsentRequest req) { Player proposer = Bukkit.getPlayer(req.getProposerLeaderUuid()); Faction faction = FactionManager.getByString(req.getDestinationFactionId()); - if (proposer != null && faction != null && Members.is(proposer, faction.getLeader())) { + String leader = Members.key(req.getLeaderCharacter()); + if (proposer != null && faction != null && leader.equals(faction.getLeader()) && Members.is(proposer, leader)) { return faction; } return null; } + /** The leader's and the owner's current characters, or null (and the leader is told) without both. */ + private static Parties characters(Player leader, Player owner) { + var leaderCharacter = Members.session(leader).map(session -> session.ref()).orElse(null); + var ownerCharacter = Members.session(owner).map(session -> session.ref()).orElse(null); + if (leaderCharacter == null || ownerCharacter == null) { + leader.sendMessage(VehicleTransferMessages.ownerOffline()); + return null; + } + return new Parties(leaderCharacter, ownerCharacter); + } + + private record Parties(CharacterRef leader, CharacterRef owner) {} + private static void notifyProposer(VehicleTransferConsentRequest req, String message) { Player proposer = Bukkit.getPlayer(req.getProposerLeaderUuid()); if (proposer != null && proposer.isOnline()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java index e0b63003..d1d279ae 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferListener.java @@ -10,7 +10,9 @@ import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService.CanAddResult; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; -import org.bukkit.Bukkit; +import java.util.UUID; + +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; @@ -79,10 +81,9 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) { return; } - String ownerName = VehicleOwnershipQueries.playerNameFromOwner( - vehicle.getOwnerData() == null ? null : vehicle.getOwnerData().getOwner()); - if (ownerName != null && !ownerName.equalsIgnoreCase(leader.getName())) { - handleOtherOwnerTransfer(leader, faction, installation, vehicle, ownerName, event); + UUID owner = otherOwner(leader, vehicle); + if (owner != null) { + handleOtherOwnerTransfer(leader, faction, installation, vehicle, owner, event); return; } @@ -113,9 +114,9 @@ private void handleOtherOwnerTransfer( Faction faction, Installation installation, ActiveVehicle vehicle, - String ownerName, + UUID ownerCharacter, VehiclePreInteractEvent event) { - Player owner = Bukkit.getPlayerExact(ownerName); + Player owner = Members.online(Members.key(ownerCharacter)).orElse(null); if (owner == null || !owner.isOnline()) { leader.sendMessage(VehicleTransferMessages.ownerOffline()); event.setCancelled(true); @@ -161,10 +162,9 @@ private void handlePoolTransfer( return; } - String ownerName = VehicleOwnershipQueries.playerNameFromOwner( - vehicle.getOwnerData() == null ? null : vehicle.getOwnerData().getOwner()); - if (ownerName != null && !ownerName.equalsIgnoreCase(leader.getName())) { - handleOtherOwnerPoolTransfer(leader, faction, vehicle, ownerName, event); + UUID owner = otherOwner(leader, vehicle); + if (owner != null) { + handleOtherOwnerPoolTransfer(leader, faction, vehicle, owner, event); return; } @@ -192,9 +192,9 @@ private void handleOtherOwnerPoolTransfer( Player leader, Faction faction, ActiveVehicle vehicle, - String ownerName, + UUID ownerCharacter, VehiclePreInteractEvent event) { - Player owner = Bukkit.getPlayerExact(ownerName); + Player owner = Members.online(Members.key(ownerCharacter)).orElse(null); if (owner == null || !owner.isOnline()) { leader.sendMessage(VehicleTransferMessages.ownerOffline()); event.setCancelled(true); @@ -229,4 +229,11 @@ private void handleOtherOwnerPoolTransfer( } event.setCancelled(true); } + + /** The character owning the vehicle when it is not the leader's current one, else null. */ + private static UUID otherOwner(Player leader, ActiveVehicle vehicle) { + String tag = vehicle.getOwnerData() == null ? null : vehicle.getOwnerData().getOwner(); + UUID owner = OwnerTags.characterId(tag).orElse(null); + return owner == null || owner.equals(VehicleOwnershipQueries.characterId(leader)) ? null : owner; + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java index 87769e3a..6ccb60f5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java @@ -51,6 +51,18 @@ public static String claimPaid(Quote quote) { return "§aPaid a transfer fee of " + amount(quote.amount()) + "§a."; } + public static String claimRefunded() { + return "§aThe claim did not go through, so the transfer fee was refunded."; + } + + public static String claimRefundedPart(double amount) { + return "§aThe claim did not go through, so " + amount(amount) + " §aof the transfer fee was refunded to your bank."; + } + + public static String claimRefundShort(double amount) { + return "§c" + amount(amount) + " §cof your transfer fee could not be refunded. Ask staff to look into it."; + } + public static String handoverConfirm(Quote quote, String recipient) { return "§6Handing this vehicle to §e" + recipient + " §6costs you " + charge(quote) + "§6. Right-click it again to confirm."; diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java index 75cb25ec..cf0ce6b4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java @@ -74,15 +74,28 @@ public void recover() throws IOException { public boolean ready() { return ready; } public boolean blockedVehicle(String vehicle) { return receipt("claim:" + vehicle) != null || receipt("handover:" + vehicle) != null; } public void fail(Throwable error) { ready = false; EconomyRuntime.failClosed(error); } - public boolean personal(String vehicle, String type, UUID character) { + public boolean personal(String vehicle, String type, UUID character, UUID account) { if (!ready) return false; try { - VehicleRegistryPersistence.personal(folder, SimpleFactions.getVehicleRegistry(), vehicle, type, character); + VehicleRegistryPersistence.personal(folder, SimpleFactions.getVehicleRegistry(), vehicle, type, character, account); + return true; + } catch (IOException | RuntimeException error) { fail(error); return false; } + } + + /** Durably sets a vehicle's row: the one it had before a failed owner change, or none (null). */ + public boolean replaceRow(String vehicle, net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord previous) { + try { + VehicleRegistryPersistence.replace(folder, SimpleFactions.getVehicleRegistry(), vehicle, previous); return true; } catch (IOException | RuntimeException error) { fail(error); return false; } } public Result charge(String scope, String operationId, CharacterRef payer, VehicleFeeService.Quote quote) { + return charge(scope, operationId, payer, quote, System.currentTimeMillis()); + } + + /** {@code at} is part of the replayable intent, so a retried operation must pass the same time. */ + public Result charge(String scope, String operationId, CharacterRef payer, VehicleFeeService.Quote quote, long at) { if (!ready) return Result.UNAVAILABLE; if (store.receipt(scope) != null) return Result.CONFLICT; if (payer == null || quote == null || quote.faction().getBank() == null) return Result.REFUSED; @@ -94,6 +107,8 @@ public Result charge(String scope, String operationId, CharacterRef payer, Vehic JsonObject changes = new JsonObject(); changes.addProperty("action", "charge"); changes.addProperty("scope", scope); + // When it was paid, so a rollback can list fees paid after the vehicle owner migration. + changes.addProperty("at", at); changes.add("receipt", JsonUtil.GSON.toJsonTree(receipt)); bookkeeping(changes, receipt, guild, cents); return execute(new OperationJournal.Intent(operationId, "vehicle:" + quote.kind().name(), diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java index c26f82ca..efa9b116 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java @@ -1,90 +1,25 @@ package net.tfminecraft.simplefactions.vehicles.fees; -import org.bukkit.entity.Player; -import java.util.UUID; -import net.tfminecraft.denareconomy.api.Result; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; -import net.tfminecraft.simplefactions.government.proposal.FeeKind; -import net.tfminecraft.simplefactions.identity.Members; -import net.tfminecraft.simplefactions.utils.Permissions; import net.tfminecraft.simplefactions.vehicles.VehicleRemovals; -import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; -import net.tfminecraft.vehicleframework.events.VehicleOwnerClaimedEvent; import net.tfminecraft.vehicleframework.events.VehicleRemoveEvent; -import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; /** - * Releasing a vehicle and letting someone else claim it is a transfer, so the claimant pays - * the transfer fee the last owner's faction would have charged the last owner. Reclaiming - * your own vehicle, such as after staff respawn it, is free, and so are admin takeovers. + * Forgets a vehicle's last owner once it is gone for good. Claim fees are charged by + * {@link net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate}. */ public final class VehicleReclaimFeeListener implements Listener { private final VehicleFeeStore store; - private final VehicleFeeConfirmations confirmations; private final Runnable saver; - public VehicleReclaimFeeListener(VehicleFeeStore store, VehicleFeeConfirmations confirmations, Runnable saver) { + public VehicleReclaimFeeListener(VehicleFeeStore store, Runnable saver) { this.store = store; - this.confirmations = confirmations; this.saver = saver; } - // After the slot-limit check (HIGH), so nobody pays for a claim that is then refused. - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) - public void onVehicleOwnerClaimed(VehicleOwnerClaimedEvent event) { - Player player = event.getPlayer(); - ActiveVehicle vehicle = event.getVehicle(); - if (player == null || vehicle == null || vehicle.getUUID() == null) { - return; - } - var session = Members.session(player).orElse(null); - var payments = VehiclePayments.current(); - String scope = "claim:" + vehicle.getUUID(); - if (session == null || payments == null || !payments.ready() || payments.blockedVehicle(vehicle.getUUID())) { - event.setCancelled(true); player.sendMessage("§cVehicle payment needs a living character or staff recovery."); return; - } - String lastOwner = store.getLastOwner(vehicle.getUUID()); - if (lastOwner == null || Members.is(player, lastOwner) || Permissions.isAdmin(player)) { - return; - } - Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, lastOwner, vehicle.getId()); - if (quote == null) { - return; - } - long now = System.currentTimeMillis(); - String target = scope + ":" + session.ref().characterId() + ":" + quote.faction().getOrCreateMainGuild().getIncarnationId(); - if (!confirmations.confirm(player.getUniqueId(), target, quote.amount(), now)) { - event.setCancelled(true); - confirmations.ask(player.getUniqueId(), target, quote.amount(), now); - player.sendMessage(VehicleFeeMessages.claimConfirm(quote, Members.display(lastOwner))); - return; - } - if (payments.charge(scope, "sf:vehicle:claim:" + UUID.randomUUID(), session.ref(), quote) != Result.APPLIED) { - event.setCancelled(true); - player.sendMessage(VehicleFeeMessages.claimUnaffordable(quote)); - return; - } - player.sendMessage(VehicleFeeMessages.claimPaid(quote)); - } - - @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) - public void onClaimed(VehicleOwnerClaimedEvent event) { - if (event.getPlayer() == null || event.getVehicle() == null) { - return; - } - String scope = "claim:" + event.getVehicle().getUUID(); - var payments = VehiclePayments.current(); - var receipt = payments == null ? null : payments.receipt(scope); - String owner = receipt == null ? Members.session(event.getPlayer()).map(session -> session.ref().characterId().toString()).orElse(null) : receipt.payer().characterId().toString(); - if (owner == null || payments == null || !payments.personal(event.getVehicle().getUUID(), event.getVehicle().getId(), UUID.fromString(owner))) return; - String member = Members.key(UUID.fromString(owner)); - if (receipt != null) payments.complete(scope, member); - else { store.setLastOwner(event.getVehicle().getUUID(), member); saver.run(); } - } - @EventHandler(priority = EventPriority.MONITOR) public void onVehicleRemove(VehicleRemoveEvent event) { if (event.getVehicle() == null || !VehicleRemovals.isGoneForGood(event.getPayload())) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java index 3c68430a..cc528078 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java @@ -12,11 +12,17 @@ import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.government.proposal.FeeKind; import net.tfminecraft.simplefactions.identity.Members; -import net.tfminecraft.denareconomy.api.Result; import net.tfminecraft.simplefactions.utils.Formatter; import net.tfminecraft.simplefactions.vehicles.VehicleIntegrationListener; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore.PaidBuild; +import net.tfminecraft.simplefactions.vehicles.owners.ConstructionStamp; +import net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleQuarantine; import net.tfminecraft.vfbuilders.core.ActiveStation; import net.tfminecraft.vfbuilders.core.Blueprint; import net.tfminecraft.vfbuilders.events.BeginVehicleConstructionEvent; @@ -29,6 +35,7 @@ * Needs VFBuilders 2.1.0 or later for the confirm click and refunds. */ public final class VehicleRegistrationFeeListener implements Listener { + public static final String OWNERSHIP_STARTING = "§cVehicle ownership is not running yet, so nothing can be built."; private final VehicleFeeStore store; private final VehicleFeeConfirmations confirmations; private final Runnable saver; @@ -49,6 +56,8 @@ public void onBeginVehicleConstruction(BeginVehicleConstructionEvent event) { } var session = Members.session(player).orElse(null); if (session == null) { event.setCancelled(true); player.sendMessage("§cSelect a living character first."); return; } + // A finished vehicle is stamped for its builder, which needs the vehicle stage up in characters mode. + if (!LeaderStamps.enabled()) { event.setCancelled(true); player.sendMessage(OWNERSHIP_STARTING); return; } String vehicleTypeId = VehicleIntegrationListener.resolveVehicleTypeId(blueprint); String stationKey = stationKey(event.getStation()); String scope = "build:" + stationKey; @@ -73,7 +82,7 @@ public void onBeginVehicleConstruction(BeginVehicleConstructionEvent event) { player.sendMessage(VehicleFeeMessages.registrationConfirm(quote, vehicleTypeId)); return; } - if (payments.charge(scope, "sf:vehicle:build:" + UUID.randomUUID(), session.ref(), quote) != Result.APPLIED) { + if (payments.charge(scope, "sf:vehicle:build:" + UUID.randomUUID(), session.ref(), quote) != net.tfminecraft.denareconomy.api.Result.APPLIED) { event.setCancelled(true); player.sendMessage(VehicleFeeMessages.registrationUnaffordable(quote)); return; @@ -91,7 +100,7 @@ public void onConstructionCancel(VehicleConstructionCancelEvent event) { return; } var refund = payments.refund(scope); - if (refund.result() != Result.APPLIED) return; + if (refund.result() != net.tfminecraft.denareconomy.api.Result.APPLIED) return; double refunded = refund.amount(); double shortfall = Formatter.formatDouble(net.tfminecraft.simplefactions.economy.Money.denars(build.cents()) - refunded); if (shortfall > 0.0 && SimpleFactions.getInstance() != null) { @@ -117,11 +126,45 @@ public void onVehicleConstruct(VehicleConstructEvent event) { var paid = payments == null ? null : payments.receipt(scope); if (event.getVehicle() == null || event.getVehicle().getUUID() == null || payments == null) return; String owner = paid != null ? paid.payer().characterId().toString() : store.buildOwner(stationKey(event.getStation())); - if (owner == null || !payments.personal(event.getVehicle().getUUID(), event.getVehicle().getId(), UUID.fromString(owner))) return; - store.setLastOwner(event.getVehicle().getUUID(), Members.key(UUID.fromString(owner))); + if (owner == null) return; + UUID payer = UUID.fromString(owner); + UUID account = paid != null ? paid.payer().ownerId() : Members.accountId(Members.key(payer)).orElse(null); + String vehicle = event.getVehicle().getUUID(); + // The payer row goes first and is taken back if VehicleFramework's owner cannot be set. + if (!payments.personal(vehicle, event.getVehicle().getId(), payer, account)) return; + UUID constructor = ConstructionStamp.constructor(event); + boolean agree = payer.equals(constructor); + String stamp = agree ? OwnerTags.character(payer) : OwnerTags.NO_CHARACTER; + Result stamped = VehicleOwnerWrites.write(vehicle, stamp, Reason.CONSTRUCT); + if (stamped != Result.OK) { + payments.replaceRow(vehicle, null); + // Nobody may claim it while the paid build is unresolved. + quarantine(vehicle, OwnerTags.NONE, "construction owner could not be written (" + stamped + + "); build receipt " + scope + " stays open"); + return; + } + if (!agree) { + quarantine(vehicle, OwnerTags.NO_CHARACTER, + "registration fee payer " + payer + " is not the constructor " + constructor); + } + store.setLastOwner(vehicle, Members.key(payer)); payments.complete(scope, null); } + private static void quarantine(String vehicle, String owner, String reason) { + var quarantine = VehicleQuarantine.current(); + try { + if (quarantine != null) quarantine.add(new VehicleQuarantine.Entry(vehicle, owner, reason)); + } catch (java.io.IOException error) { + log("Could not list quarantined vehicle " + vehicle + ": " + error); + } + log("Vehicle " + vehicle + " is held for staff: " + reason); + } + + private static void log(String message) { + if (SimpleFactions.getInstance() != null) SimpleFactions.getInstance().getLogger().warning(message); + } + static String stationKey(ActiveStation station) { Location loc = station == null ? null : station.getLocation(); if (loc == null || loc.getWorld() == null) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java index 5e8ad662..cf0a5822 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java @@ -50,7 +50,8 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) { sessions.clear(owner.getUniqueId()); return; } - Outcome outcome = service.evaluate(owner.getName(), recipient.getName(), vehicle.getUUID()); + Outcome outcome = service.evaluate( + Members.session(owner).map(value -> value.ref()).orElse(null), recipient.getUniqueId(), vehicle.getUUID()); if (outcome.status() != Status.OK) { owner.sendMessage(VehicleHandoverMessages.forOutcome(outcome, Members.name(recipient))); if (outcome.status() != Status.NOT_OWNER) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java index 32e5a3f1..952ae4bb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java @@ -1,7 +1,6 @@ package net.tfminecraft.simplefactions.vehicles.handover; import net.tfminecraft.simplefactions.identity.Members; -import net.tfminecraft.denareconomy.api.Result; import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments; import java.util.Objects; @@ -18,7 +17,6 @@ import net.tfminecraft.simplefactions.managers.RequestManager; import net.tfminecraft.simplefactions.objects.request.VehicleHandoverRequest; import net.tfminecraft.simplefactions.vehicles.berth.FactionCampaignBattleLock; -import net.tfminecraft.simplefactions.vehicles.berth.FactionVehicleReleaseService; import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard; import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard.CanBuildResult; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeMessages; @@ -26,8 +24,12 @@ import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; -import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerMeta; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites; +import net.tfminecraft.tlibs.character.CharacterRef; /** * Hands a personal vehicle from one player to another. The owner pays their faction's @@ -48,53 +50,56 @@ public record Outcome(Status status, CanBuildResult slotFailure, String vehicleT private final PlayerVehicleRegistry registry; private final VehicleFeeStore store; private final Runnable saver; - private final BiPredicate ownerAssigner; + private final BiPredicate ownerAssigner; public VehicleHandoverService(PlayerVehicleRegistry registry, VehicleFeeStore store, Runnable saver) { - this(registry, store, saver, FactionVehicleReleaseService::assignFrameworkOwner); + this(registry, store, saver, VehicleHandoverService::assignFrameworkOwner); } VehicleHandoverService( PlayerVehicleRegistry registry, VehicleFeeStore store, Runnable saver, - BiPredicate ownerAssigner) { + BiPredicate ownerAssigner) { this.registry = registry; this.store = store; this.saver = saver; this.ownerAssigner = ownerAssigner; } - /** Whether the owner may hand this vehicle to the recipient right now; both are account names, as VehicleFramework stores them. */ - public Outcome evaluate(String ownerName, String recipientName, String vehicleUuid) { - String typeId = personalVehicleType(ownerName, vehicleUuid); + /** Gives the vehicle to the recipient's character, emptying its crew list. */ + static boolean assignFrameworkOwner(String vehicleUuid, UUID character) { + return VehicleOwnerWrites.write(vehicleUuid, OwnerTags.character(character), Reason.HANDOVER) == Result.OK; + } + + /** Whether the owner's character may hand this vehicle to the recipient's account right now. */ + public Outcome evaluate(CharacterRef owner, UUID recipientAccount, String vehicleUuid) { + String typeId = owner == null ? null : personalVehicleType(owner.characterId(), vehicleUuid); if (typeId == null) { return new Outcome(Status.NOT_OWNER, null, null); } if (!VehiclesConfigLoader.isKnownType(typeId)) { return new Outcome(Status.UNKNOWN_TYPE, null, typeId); } - if (FactionCampaignBattleLock.blocks(FactionManager.getByMember(VehicleOwnershipQueries.ownerKey(ownerName)))) { + if (FactionCampaignBattleLock.blocks(FactionManager.getByMember(Members.key(owner)))) { return new Outcome(Status.IN_BATTLE, null, typeId); } - CanBuildResult slot = VehicleSlotGuard.checkCanBuild(recipientName, typeId, registry); + CanBuildResult slot = VehicleSlotGuard.checkCanBuild(recipientAccount, typeId, registry); if (slot != CanBuildResult.OK) { return new Outcome(Status.NO_ROOM, slot, typeId); } return new Outcome(Status.OK, null, typeId); } - /** The type of the vehicle if it is one of the owner's personal (not faction) vehicles. */ - private String personalVehicleType(String ownerName, String vehicleUuid) { - if (ownerName == null || vehicleUuid == null || registry.isFactionOwned(vehicleUuid)) { + /** The type of the vehicle if this character owns it personally (not as a faction vehicle). */ + private String personalVehicleType(UUID owner, String vehicleUuid) { + if (vehicleUuid == null || registry.isFactionOwned(vehicleUuid)) { return null; } - for (OwnedVehicleSummary vehicle : VehicleOwnershipQueries.personalVehicles(ownerName, registry)) { - if (vehicleUuid.equals(vehicle.getUuid())) { - return vehicle.getTypeId(); - } - } - return null; + return VehicleOwnerWrites.read(vehicleUuid) + .filter(meta -> OwnerTags.names(meta.currentOwner(), owner)) + .map(OwnerMeta::typeId) + .orElse(null); } public void offer(Player owner, Player recipient, String vehicleUuid, String vehicleTypeId) { @@ -107,7 +112,6 @@ public void offer(Player owner, Player recipient, String vehicleUuid, String veh vehicleUuid, vehicleTypeId, owner.getUniqueId(), - owner.getName(), recipient.getUniqueId()); RequestManager.addRequest(owner, recipient, request); // addRequest refuses when the recipient is already considering another request. @@ -139,7 +143,7 @@ public void acceptRequest(Player recipient) { if (payments == null || !payments.ready() || payments.blockedVehicle(req.getVehicleUuid())) { tellBoth(req, recipient, "§cVehicle payment needs staff review or recovery."); return; } - Outcome outcome = evaluate(req.getOwnerName(), recipient.getName(), req.getVehicleUuid()); + Outcome outcome = evaluate(req.getOwnerCharacter(), recipient.getUniqueId(), req.getVehicleUuid()); if (outcome.status() != Status.OK) { tellBoth(req, recipient, VehicleHandoverMessages.forOutcome(outcome, Members.name(recipient))); return; @@ -150,17 +154,27 @@ public void acceptRequest(Player recipient) { tellBoth(req, recipient, VehicleHandoverMessages.feeChanged()); return; } - if (quote != null && payments.charge(scope, req.getOperationId(), req.getOwnerCharacter(), quote) != Result.APPLIED) { + if (quote != null && payments.charge(scope, req.getOperationId(), req.getOwnerCharacter(), quote, req.getOfferedAt()) + != net.tfminecraft.denareconomy.api.Result.APPLIED) { tellBoth(req, recipient, VehicleFeeMessages.handoverUnaffordable(quote)); return; } - if (!ownerAssigner.test(req.getVehicleUuid(), recipient.getName())) { + // The recipient becomes the payer before VehicleFramework's owner changes, and stops being it if that fails. + var previous = registry.getByVehicleUuid(req.getVehicleUuid()).orElse(null); + var recipientCharacter = req.getRecipientCharacter(); + if (!payments.personal(req.getVehicleUuid(), outcome.vehicleTypeId(), recipientCharacter.characterId(), + recipientCharacter.ownerId())) { + if (quote != null) payments.refund(scope); + tellBoth(req, recipient, VehicleHandoverMessages.ownershipUnavailable()); + return; + } + if (!ownerAssigner.test(req.getVehicleUuid(), recipientCharacter.characterId())) { + payments.replaceRow(req.getVehicleUuid(), previous); if (quote != null) { payments.refund(scope); } tellBoth(req, recipient, VehicleHandoverMessages.ownershipUnavailable()); return; } - if (!payments.personal(req.getVehicleUuid(), outcome.vehicleTypeId(), req.getRecipientCharacter().characterId())) return; if (quote != null) payments.complete(scope, Members.key(req.getRecipientCharacter())); else { store.setLastOwner(req.getVehicleUuid(), Members.key(req.getRecipientCharacter())); saver.run(); } recipient.sendMessage(VehicleHandoverMessages.successRecipient(Members.display(ownerKey))); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java index 38a4cc98..49396f0d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlement.java @@ -43,30 +43,30 @@ static void calculate(Map balances, Map legs, RealmS Map unpaid = new LinkedHashMap<>(maintenance.snapshot()); JsonArray notices = new JsonArray(); for (var vehicle : VehicleOwnershipQueries.allPersonalVehicles(registry)) { - long upkeep = Money.cents(VehiclesConfigLoader.getUpkeep(vehicle.getTypeId())); + long upkeep = Money.cents(VehiclesConfigLoader.getUpkeep(vehicle.typeId())); if (upkeep <= 0) { - unpaid.remove(vehicle.getUuid()); + unpaid.remove(vehicle.vehicle()); continue; } - var record = registry.getByVehicleUuid(vehicle.getUuid()).orElse(null); + var record = registry.getByVehicleUuid(vehicle.vehicle()).orElse(null); String payer = record == null || record.getPlayerUuid() == null ? null : record.getPlayerUuid().toString(); var payments = VehiclePayments.current(); - if (payments != null && payments.blockedVehicle(vehicle.getUuid())) { - markUnpaid(unpaid, notices, vehicle.getUuid(), vehicle.getTypeId(), payer, null, now); + if (payments != null && payments.blockedVehicle(vehicle.vehicle())) { + markUnpaid(unpaid, notices, vehicle.vehicle(), vehicle.typeId(), payer, null, now); continue; } var character = Members.info(payer).filter(CharacterInfo::alive); if (character.isEmpty()) { - markUnpaid(unpaid, notices, vehicle.getUuid(), vehicle.getTypeId(), payer, null, now); + markUnpaid(unpaid, notices, vehicle.vehicle(), vehicle.typeId(), payer, null, now); continue; } var ref = character.get().ref(); - var tax = VehicleFeeService.quote(FeeKind.VEHICLE_TAX, payer, vehicle.getTypeId()); + var tax = VehicleFeeService.quote(FeeKind.VEHICLE_TAX, payer, vehicle.typeId()); long fee = tax == null ? 0 : Money.cents(tax.amount()); long total = Math.addExact(upkeep, fee); String account = Denars.bank(ref); if ((tax != null && SettlementGuilds.capital(tax.faction()).getBank() == null) || balance(balances, legs, account) < total) { - markUnpaid(unpaid, notices, vehicle.getUuid(), vehicle.getTypeId(), payer, null, now); + markUnpaid(unpaid, notices, vehicle.vehicle(), vehicle.typeId(), payer, null, now); continue; } transfer(balances, legs, account, Denars.MINT, upkeep); @@ -79,7 +79,7 @@ var record = registry.getByVehicleUuid(vehicle.getUuid()).orElse(null); saved.vehicleFeeIncome = Money.denars(Math.addExact(Money.cents(saved.vehicleFeeIncome), fee)); income(changes, ref.characterId(), PlayerCashflow.VEHICLE_TAX, -fee); } - unpaid.remove(vehicle.getUuid()); + unpaid.remove(vehicle.vehicle()); } for (var record : registry.getAll()) { if (record.getMode() == OwnershipMode.PERSONAL) continue; diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java index 6b299729..9d24d9b5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java @@ -13,7 +13,6 @@ import net.tfminecraft.simplefactions.government.proposal.FeeKind; import net.tfminecraft.simplefactions.utils.Formatter; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService; -import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; public final class VehicleUpkeepProjection { private VehicleUpkeepProjection() {} @@ -44,12 +43,12 @@ private static double projected(UUID character, PlayerVehicleRegistry registry, if (character == null || registry == null) return 0; double total = 0; for (var vehicle : VehicleOwnershipQueries.allPersonalVehicles(registry)) { - var row = registry.getByVehicleUuid(vehicle.getUuid()).orElse(null); + var row = registry.getByVehicleUuid(vehicle.vehicle()).orElse(null); if (row == null || !character.equals(row.getPlayerUuid())) continue; if (tax) { - var quote = VehicleFeeService.quote(FeeKind.VEHICLE_TAX, Members.key(character), vehicle.getTypeId()); + var quote = VehicleFeeService.quote(FeeKind.VEHICLE_TAX, Members.key(character), vehicle.typeId()); if (quote != null) total += quote.amount(); - } else total += VehiclesConfigLoader.getUpkeep(vehicle.getTypeId()); + } else total += VehiclesConfigLoader.getUpkeep(vehicle.typeId()); } return Formatter.formatDouble(total); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStamp.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStamp.java new file mode 100644 index 00000000..12e374ba --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStamp.java @@ -0,0 +1,47 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.UUID; + +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vfbuilders.events.VehicleConstructEvent; + +/** Who built a vehicle, as a character, for checking the registration-fee payer before stamping. */ +public final class ConstructionStamp { + private static Boolean characterApi; + + private ConstructionStamp() {} + + /** Whether VFBuilders 2.2.0 or later reports the constructor's character (rule 11 probe, cached). */ + static boolean characterApi() { + if (characterApi == null) characterApi = hasCharacter(VehicleConstructEvent.class); + return characterApi; + } + + static boolean hasCharacter(Class event) { + try { + event.getMethod("getConstructorCharacterId"); + return true; + } catch (ReflectiveOperationException | LinkageError missing) { + return false; + } + } + + static void resetProbe() { + characterApi = null; + } + + /** + * The constructor's character: VFBuilders' capture when it has one, else the + * account's reviewed heir (stations from before 2.2.0, builds started without a + * current character). Null when neither is known. + */ + public static UUID constructor(VehicleConstructEvent event) { + UUID character = characterApi() ? event.getConstructorCharacterId() : null; + if (character != null) return character; + UUID account = event.getConstructorUuid(); + if (account == null) return null; + return Characters.legacyHeir(account).filter(ref -> account.equals(ref.ownerId())) + .map(CharacterRef::characterId).orElse(null); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStamps.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStamps.java new file mode 100644 index 00000000..d1907ddb --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStamps.java @@ -0,0 +1,140 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; + +/** + * Faction vehicles carry their faction leader's character as owner, which gives the + * leader the ownership menu and seats and nothing else. Without a leader character + * (no leader, a staff dummy, a deleted faction) the stamp is {@code char_none}. + */ +public final class LeaderStamps { + private static volatile boolean enabled; + private static final Set scheduled = new HashSet<>(); + + private LeaderStamps() {} + + /** Turned on once the vehicle stage has finished; before that the boot reconcile covers changes. */ + public static void enable(boolean on) { + enabled = on; + if (!on) scheduled.clear(); + } + + public static boolean enabled() { + return enabled; + } + + public static String expectedStamp(Faction faction) { + UUID leader = faction == null ? null : Members.characterId(faction.getLeader()); + return OwnerTags.character(leader); + } + + /** The stamp a faction row should carry: its paying faction's leader, else {@code char_none}. */ + public static String expected(PlayerVehicleRecord record) { + return expectedStamp(FactionVehiclePoolService.payingFaction(record)); + } + + public static boolean isFactionRow(PlayerVehicleRecord record) { + return record != null + && (record.getMode() == OwnershipMode.INSTALLATION || record.getMode() == OwnershipMode.POOL); + } + + /** Writes the expected stamp when the vehicle carries anything else. */ + public static Result restamp(PlayerVehicleRecord record) { + return stamp(record.getVehicleUuid(), expected(record)); + } + + static Result stamp(String vehicle, String expected) { + // A vehicle held for staff (for example taken over while the migration was interrupted) is theirs. + if (VehicleQuarantine.held(vehicle)) return Result.OK; + String current = VehicleOwnerWrites.currentOwner(vehicle); + if (current == null) return Result.NOT_FOUND; + if (current.equals(expected)) return Result.OK; + return VehicleOwnerWrites.write(vehicle, expected, Reason.RESTAMP); + } + + /** {@link net.tfminecraft.simplefactions.events.FactionLeaderChangedEvent}: restamps the faction's vehicles. */ + public static void onLeaderChanged(Faction faction, PlayerVehicleRegistry registry) { + if (!enabled || faction == null || registry == null) return; + for (PlayerVehicleRecord record : rowsOf(faction, registry)) report(record.getVehicleUuid(), restamp(record)); + } + + /** Called once from {@code FactionManager.deleteFaction}: the faction's remaining rows lose their leader. */ + public static void onFactionDeleted(Faction faction) { + if (!enabled || faction == null || SimpleFactions.getInstance() == null) return; + for (PlayerVehicleRecord record : rowsOf(faction, SimpleFactions.getVehicleRegistry())) { + report(record.getVehicleUuid(), stamp(record.getVehicleUuid(), OwnerTags.NO_CHARACTER)); + } + } + + /** The registry rows a faction pays for, plus rows that still name it. */ + static List rowsOf(Faction faction, PlayerVehicleRegistry registry) { + List rows = new ArrayList<>(); + for (PlayerVehicleRecord record : registry.getAll()) { + if (!isFactionRow(record)) continue; + if (faction.getId().equalsIgnoreCase(record.getFactionId()) + || (record.getFactionId() == null && FactionVehiclePoolService.payingFaction(record) == faction)) { + rows.add(record); + } + } + return rows; + } + + /** + * Startup step 3: every faction row gets its expected stamp. A failed write aborts the + * vehicle stage; rows whose vehicle is gone are left to the upkeep reconcile's list. + */ + public static void reconcile(PlayerVehicleRegistry registry) throws IOException { + for (PlayerVehicleRecord record : registry.getAll()) { + if (!isFactionRow(record)) continue; + Result result = restamp(record); + if (result != Result.OK && result != Result.NOT_FOUND) { + throw new IOException("Could not restamp faction vehicle " + record.getVehicleUuid() + ": " + result); + } + } + } + + /** Backstop: whether a faction vehicle carries a stale stamp. Personal and unregistered vehicles never do. */ + public static boolean isStale(PlayerVehicleRecord record, String currentOwner) { + return isFactionRow(record) && !expected(record).equals(currentOwner) + && !VehicleQuarantine.held(record.getVehicleUuid()); + } + + /** Restamps on the next tick, once per vehicle however often it is clicked. */ + public static void scheduleRestamp(PlayerVehicleRecord record) { + SimpleFactions plugin = SimpleFactions.getInstance(); + if (!enabled || plugin == null || !scheduled.add(record.getVehicleUuid())) return; + Bukkit.getScheduler().runTask(plugin, () -> { + scheduled.remove(record.getVehicleUuid()); + if (!enabled) return; + PlayerVehicleRecord now = SimpleFactions.getVehicleRegistry() + .getByVehicleUuid(record.getVehicleUuid()).orElse(null); + if (isFactionRow(now)) report(now.getVehicleUuid(), restamp(now)); + }); + } + + private static void report(String vehicle, Result result) { + if (result == Result.OK) return; + SimpleFactions plugin = SimpleFactions.getInstance(); + if (plugin != null) { + plugin.getLogger().warning("Could not restamp faction vehicle " + vehicle + " (" + result + + "); the next boot or click tries again"); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerFields.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerFields.java new file mode 100644 index 00000000..1e2e4d64 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerFields.java @@ -0,0 +1,27 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** One copy of a vehicle's ownership fields, as VehicleFramework stores them. */ +public record OwnerFields(String owner, List whitelist, boolean whitelisted, boolean ticketsEnabled, + String ticketId) { + + public OwnerFields { + owner = owner == null || owner.isBlank() ? OwnerTags.NONE : owner; + List entries = new ArrayList<>(); + if (whitelist != null) whitelist.stream().filter(Objects::nonNull).forEach(entries::add); + whitelist = List.copyOf(entries); + } + + /** The fields a migration write changes: owner, whitelist entries and the whitelist flag. */ + public boolean sameOwnership(OwnerFields other) { + return other != null && owner.equals(other.owner) && whitelist.equals(other.whitelist) + && whitelisted == other.whitelisted; + } + + public OwnerFields withOwnership(String newOwner, List newWhitelist) { + return new OwnerFields(newOwner, newWhitelist, whitelisted, ticketsEnabled, ticketId); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerMeta.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerMeta.java new file mode 100644 index 00000000..fb4b4e5c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerMeta.java @@ -0,0 +1,31 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.Optional; + +/** + * A vehicle's stored and live ownership fields, kept apart. + * + * @param vehicle the vehicle ID in VehicleFramework's lower-case form + * @param storedColumn the SQLite owner column, or null without a stored row + */ +public record OwnerMeta(String vehicle, String typeId, String storedColumn, Optional stored, + Optional live) { + + public OwnerMeta { + stored = stored == null ? Optional.empty() : stored; + live = live == null ? Optional.empty() : live; + } + + public boolean hasStoredRow() { + return storedColumn != null; + } + + /** The live fields when loaded, otherwise the stored ones. */ + public Optional current() { + return live.isPresent() ? live : stored; + } + + public String currentOwner() { + return current().map(OwnerFields::owner).orElse(null); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerTags.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerTags.java new file mode 100644 index 00000000..a3cb1525 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerTags.java @@ -0,0 +1,74 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +import net.tfminecraft.tlibs.character.CharacterRef; + +/** + * VehicleFramework owner values, kept free of VehicleFramework types so an older + * VehicleFramework never has to load them. + */ +public final class OwnerTags { + 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 OwnerTags() {} + + public static String character(UUID characterId) { + return characterId == null ? NO_CHARACTER : CHARACTER_PREFIX + characterId; + } + + /** The character of a {@code char_} value; empty for anything else, {@code char_none} included. */ + public static Optional characterId(String owner) { + if (owner == null || !owner.regionMatches(true, 0, CHARACTER_PREFIX, 0, CHARACTER_PREFIX.length())) { + return Optional.empty(); + } + String id = owner.substring(CHARACTER_PREFIX.length()); + if (id.length() != 36) return Optional.empty(); + try { + UUID parsed = UUID.fromString(id); + return parsed.toString().equals(id.toLowerCase(Locale.ROOT)) ? Optional.of(parsed) : Optional.empty(); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + + public static boolean isUnowned(String owner) { + return owner == null || owner.isBlank() || owner.equalsIgnoreCase(NONE); + } + + public static boolean isCharacter(String owner) { + return characterId(owner).isPresent(); + } + + /** Whether the value names this character. */ + public static boolean names(String owner, UUID characterId) { + return characterId != null && characterId.equals(characterId(owner).orElse(null)); + } + + /** The account name of a legacy {@code player_} value, or null ({@code player_none} included). */ + public static String legacyName(String owner) { + if (owner == null || owner.length() <= LEGACY_PREFIX.length() + || !owner.regionMatches(true, 0, LEGACY_PREFIX, 0, LEGACY_PREFIX.length()) + || owner.equalsIgnoreCase(LEGACY_PREFIX + NONE)) { + return null; + } + return owner.substring(LEGACY_PREFIX.length()); + } + + /** VehicleFramework's stored form of a character whitelist entry. */ + public static String whitelistEntry(CharacterRef character, String name) { + return character(character.characterId()) + ";" + character.ownerId() + ";" + (name == null ? "" : name); + } + + /** Whether a raw whitelist entry is already in character form. */ + public static boolean isCharacterEntry(String raw) { + if (raw == null) return false; + int end = raw.indexOf(';'); + return end > 0 && isCharacter(raw.substring(0, end)); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleClaimsOff.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleClaimsOff.java new file mode 100644 index 00000000..91ceaed2 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleClaimsOff.java @@ -0,0 +1,24 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import net.tfminecraft.simplefactions.utils.Permissions; +import net.tfminecraft.vehicleframework.events.VehicleOwnerClaimedEvent; + +/** + * Without VehicleFramework 2.13 in characters mode, SimpleFactions cannot record a + * character payer for a claim, so non-staff claims are refused. The claim event exists + * in every VehicleFramework version, so this listener loads on older ones too. + */ +public final class VehicleClaimsOff implements Listener { + public static final String MESSAGE = "§cClaiming vehicles is off until VehicleFramework is updated."; + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onClaim(VehicleOwnerClaimedEvent event) { + if (event.getPlayer() == null || Permissions.isAdmin(event.getPlayer())) return; + event.setCancelled(true); + event.getPlayer().sendMessage(MESSAGE); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerApi.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerApi.java new file mode 100644 index 00000000..241d319a --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerApi.java @@ -0,0 +1,24 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.List; +import java.util.Optional; + +/** VehicleFramework's ownership API in SimpleFactions' own types. Main thread only. */ +public interface VehicleOwnerApi { + /** System write reasons, matching VehicleFramework's. */ + enum Reason { MIGRATE, RESTAMP, RELEASE, HANDOVER, CONSTRUCT, TRANSFER } + + /** {@code UNAVAILABLE} means SimpleFactions could not reach a usable VehicleFramework. */ + enum Result { OK, NOT_FOUND, SAVE_FAILED, REFUSED_MODE, UNAVAILABLE } + + /** Whether VehicleFramework runs in characters mode. */ + boolean characters(); + + /** Every stored row except tombstones, plus loaded vehicles. Throws when SQLite cannot be read. */ + List list(); + + Optional read(String vehicle); + + /** {@code whitelist} is only accepted with {@link Reason#MIGRATE}; null keeps the entries. */ + Result write(String vehicle, String owner, List whitelist, Reason reason); +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java new file mode 100644 index 00000000..078384ef --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java @@ -0,0 +1,521 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; + +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; + +/** + * The one-time move of VehicleFramework owners from account names to characters, + * journalled in {@code vehicles/owner-migration.json}. The plan records every vehicle's + * original and decided fields before anything is written, so a crash resumes from the + * plan and a decision is never re-derived from a value that may already be rewritten. + * Once {@code completed} it never runs again and its backups are never retaken. + */ +public final class VehicleOwnerMigration { + public static final String PLAN = "vehicles/owner-migration.json"; + public static final String DATABASE_BACKUP = "vehicles/vehicles-6a-premigration.db"; + public static final String REGISTRY_BACKUP = "vehicles/vehicles_registry-6a-premigration.json"; + static final int SAVE_EVERY = 25; + + /** Status of one plan entry. */ + public enum Status { PENDING, DONE, QUARANTINED, GONE } + + /** Name and character lookups, from the live providers in production. */ + public interface Identity { + /** The account whose current Minecraft name is this one; null when the name does not round-trip. */ + UUID roundTrip(String name); + + Optional accountOf(UUID character); + + /** The account's reviewed legacy heir. */ + Optional heir(UUID account); + + /** The character's real name, or null. */ + String nameOf(UUID character); + } + + /** VehicleFramework's database copy and check. */ + public interface Backups { + void vacuumInto(Path destination) throws IOException; + + boolean quickCheck(Path database) throws IOException; + } + + public record Outcome(boolean ran, int migrated, int unchanged, int quarantined, int gone) {} + + static final class Fields { + String owner; + List whitelist; + boolean whitelisted; + boolean ticketsEnabled; + String ticketId; + + static Fields of(OwnerFields fields) { + Fields out = new Fields(); + out.owner = fields.owner(); + out.whitelist = new ArrayList<>(fields.whitelist()); + out.whitelisted = fields.whitelisted(); + out.ticketsEnabled = fields.ticketsEnabled(); + out.ticketId = fields.ticketId(); + return out; + } + + OwnerFields value() { + return new OwnerFields(owner, whitelist, whitelisted, ticketsEnabled, ticketId); + } + } + + static final class Entry { + String vehicle; + String type; + String mode; + Fields original; + Fields planned; + Status status = Status.PENDING; + String reason; + /** The account the legacy owner name round-tripped to, when it did. */ + UUID account; + } + + static final class Plan { + int version = 1; + long createdAt; + boolean backedUp; + String databaseBackup; + String registryBackup; + boolean completed; + long completedAt; + List entries = new ArrayList<>(); + List quarantine = new ArrayList<>(); + } + + private final Path folder; + private final VehicleOwnerApi api; + private final PlayerVehicleRegistry registry; + private final Identity identity; + private final Backups backups; + private final Path registryFile; + + public VehicleOwnerMigration(Path folder, VehicleOwnerApi api, PlayerVehicleRegistry registry, Identity identity, + Backups backups) { + this.folder = folder; + this.api = api; + this.registry = registry; + this.identity = identity; + this.backups = backups; + this.registryFile = folder.resolve("Cache/vehicles_registry.json"); + } + + /** Accounts the plan matched legacy owner names to, by vehicle; empty without a plan. */ + public static java.util.Map accounts(Path folder) throws IOException { + Path file = folder.resolve(PLAN); + java.util.Map out = new java.util.HashMap<>(); + if (Files.exists(file)) { + for (Entry entry : read(file).entries) if (entry.account != null) out.put(entry.vehicle, entry.account); + } + return out; + } + + /** The quarantine section of an existing plan, for staff listings; empty without a plan. */ + public static List quarantine(Path folder) throws IOException { + Path file = folder.resolve(PLAN); + if (!Files.exists(file)) return List.of(); + Plan plan = read(file); + return List.copyOf(plan.quarantine); + } + + public Outcome run() throws IOException { + Path file = folder.resolve(PLAN); + Plan plan; + if (Files.exists(file)) { + plan = read(file); + if (plan.completed) return new Outcome(false, 0, 0, 0, 0); + } else { + plan = plan(); + DurableJson.write(file, plan); + } + if (!plan.backedUp) backUp(file, plan); + int migrated = apply(file, plan); + verify(file, plan); + plan.completed = true; + plan.completedAt = System.currentTimeMillis(); + DurableJson.write(file, plan); + int unchanged = (int) plan.entries.stream() + .filter(entry -> entry.status == Status.DONE && entry.original.value().equals(entry.planned.value())) + .count(); + return new Outcome(true, migrated, unchanged, count(plan, Status.QUARANTINED), count(plan, Status.GONE)); + } + + private static Plan read(Path file) throws IOException { + Plan plan = DurableJson.read(file, Plan.class); + if (plan.entries == null || plan.quarantine == null) throw new IOException("Invalid vehicle owner plan: " + file); + for (Entry entry : plan.entries) { + if (entry == null || entry.vehicle == null || entry.status == null + || (entry.status != Status.QUARANTINED && (entry.original == null || entry.planned == null))) { + throw new IOException("Invalid vehicle owner plan entry in " + file); + } + } + return plan; + } + + private static int count(Plan plan, Status status) { + return (int) plan.entries.stream().filter(entry -> entry.status == status).count(); + } + + // Step 1: decide every vehicle once. + + Plan plan() throws IOException { + if (!api.characters()) throw new IOException("VehicleFramework is not in characters mode"); + Plan plan = new Plan(); + plan.createdAt = System.currentTimeMillis(); + for (OwnerMeta meta : api.list()) { + Entry entry = decide(meta); + plan.entries.add(entry); + if (entry.status == Status.QUARANTINED) { + plan.quarantine.add(new VehicleQuarantine.Entry(entry.vehicle, + entry.original == null ? null : entry.original.owner, entry.reason)); + } + } + return plan; + } + + Entry decide(OwnerMeta meta) { + Entry entry = new Entry(); + entry.vehicle = meta.vehicle(); + entry.type = meta.typeId(); + PlayerVehicleRecord record = registry.getByVehicleUuid(meta.vehicle()).orElse(null); + entry.mode = record == null ? null : record.getMode().name(); + OwnerFields current = meta.current().orElse(null); + if (current == null) return quarantine(entry, "its ownership fields cannot be read"); + entry.original = Fields.of(current); + String owner = current.owner(); + String next; + if (LeaderStamps.isFactionRow(record)) { + next = LeaderStamps.expected(record); + } else if (OwnerTags.isUnowned(owner) || OwnerTags.isCharacter(owner) || OwnerTags.NO_CHARACTER.equals(owner)) { + entry.planned = entry.original; + return entry; + } else if (record != null) { + entry.account = account(OwnerTags.legacyName(owner)); + String why = personalProblem(entry.account, record); + if (why != null) return quarantine(entry, why); + next = OwnerTags.character(record.getPlayerUuid()); + } else { + UUID account = account(OwnerTags.legacyName(owner)); + entry.account = account; + CharacterRef heir = account == null ? null : heir(account); + if (heir == null) return quarantine(entry, account == null + ? "owner name does not match a known account" : "owner account has no reviewed heir"); + next = OwnerTags.character(heir.characterId()); + } + entry.planned = Fields.of(current.withOwnership(next, seedWhitelist(current.whitelist()))); + return entry; + } + + /** Why a personal row cannot seed the owner, or null when its character belongs to the tag's account. */ + private String personalProblem(UUID account, PlayerVehicleRecord record) { + if (account == null) return "owner name does not match a known account"; + if (record.getPlayerUuid() == null) return "personal registry row has no character"; + if (!account.equals(identity.accountOf(record.getPlayerUuid()).orElse(null))) { + return "personal registry character belongs to another account"; + } + return null; + } + + /** A {@code player_} construction fallback names its account directly; a name must round-trip. */ + private UUID account(String name) { + if (name == null || name.isBlank()) return null; + try { + UUID direct = UUID.fromString(name); + if (direct.toString().equalsIgnoreCase(name)) return direct; + } catch (IllegalArgumentException notUuid) { + // A Minecraft name. + } + return identity.roundTrip(name); + } + + private CharacterRef heir(UUID account) { + return identity.heir(account).filter(ref -> account.equals(ref.ownerId())).orElse(null); + } + + /** Legacy entries move to the named account's heir when the name round-trips; the rest stay as they are. */ + List seedWhitelist(List entries) { + Set out = new LinkedHashSet<>(); + for (String raw : entries) { + if (OwnerTags.isCharacterEntry(raw)) { + out.add(raw); + continue; + } + String name = OwnerTags.legacyName(raw) != null ? OwnerTags.legacyName(raw) : raw; + UUID account = name.startsWith(OwnerTags.LEGACY_PREFIX) ? null : account(name); + CharacterRef heir = account == null ? null : heir(account); + if (heir == null) { + out.add(raw); + continue; + } + String characterName = identity.nameOf(heir.characterId()); + out.add(OwnerTags.whitelistEntry(heir, characterName == null ? name : characterName)); + } + return new ArrayList<>(out); + } + + private static Entry quarantine(Entry entry, String reason) { + entry.status = Status.QUARANTINED; + entry.reason = reason; + return entry; + } + + // Step 2: fixed-name backups outside VehicleFramework's rotation. + + private void backUp(Path file, Plan plan) throws IOException { + Path database = folder.resolve(DATABASE_BACKUP); + Files.createDirectories(database.getParent()); + Files.deleteIfExists(database); + backups.vacuumInto(database); + if (!backups.quickCheck(database)) throw new IOException("Vehicle database backup failed its quick check"); + Path registryCopy = folder.resolve(REGISTRY_BACKUP); + copyDurably(Files.exists(registryFile) ? Files.readAllBytes(registryFile) : "[]".getBytes(), registryCopy); + plan.databaseBackup = database.toString(); + plan.registryBackup = registryCopy.toString(); + plan.backedUp = true; + DurableJson.write(file, plan); + } + + static void copyDurably(byte[] bytes, Path destination) throws IOException { + Path directory = destination.toAbsolutePath().getParent(); + Files.createDirectories(directory); + Path pending = Files.createTempFile(directory, destination.getFileName() + ".", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(pending, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + Files.move(pending, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } finally { + Files.deleteIfExists(pending); + } + } + + // Step 3: apply conditionally. + + private int apply(Path file, Plan plan) throws IOException { + int migrated = 0; + int dirty = 0; + for (Entry entry : plan.entries) { + if (entry.status != Status.PENDING) continue; + OwnerFields current = api.read(entry.vehicle).flatMap(OwnerMeta::current).orElse(null); + OwnerFields original = entry.original.value(); + OwnerFields planned = entry.planned.value(); + if (current == null) { + entry.status = Status.GONE; + } else if (current.equals(planned)) { + entry.status = Status.DONE; + } else if (current.equals(original)) { + Result result = api.write(entry.vehicle, planned.owner(), planned.whitelist(), Reason.MIGRATE); + if (result == Result.NOT_FOUND) { + entry.status = Status.GONE; + } else if (result != Result.OK) { + DurableJson.write(file, plan); + throw new IOException("Could not migrate the owner of vehicle " + entry.vehicle + ": " + result); + } else { + entry.status = Status.DONE; + migrated++; + } + } else { + changedSincePlanned(plan, entry, current); + } + if (++dirty % SAVE_EVERY == 0) DurableJson.write(file, plan); + } + DurableJson.write(file, plan); + return migrated; + } + + private static void changedSincePlanned(Plan plan, Entry entry, OwnerFields current) { + quarantine(entry, "owner changed after the migration was planned"); + plan.quarantine.add(new VehicleQuarantine.Entry(entry.vehicle, current.owner(), entry.reason)); + } + + // Step 4: verify stored and live copies separately. + + private void verify(Path file, Plan plan) throws IOException { + List torn = new ArrayList<>(); + boolean changed = false; + for (Entry entry : plan.entries) { + if (entry.status != Status.DONE) continue; + OwnerMeta meta = api.read(entry.vehicle).orElse(null); + if (meta == null) { + entry.status = Status.GONE; + changed = true; + continue; + } + OwnerFields planned = entry.planned.value(); + List copies = new ArrayList<>(); + meta.live().ifPresent(copies::add); + if (meta.hasStoredRow()) { + OwnerFields stored = meta.stored().orElse(null); + if (stored == null || !stored.owner().equals(meta.storedColumn())) { + torn.add(entry.vehicle); + continue; + } + copies.add(stored); + } + if (copies.isEmpty() || copies.size() == 2 && !copies.get(0).sameOwnership(copies.get(1))) { + // Neither copy can be read back, or the two disagree. + torn.add(entry.vehicle); + } else if (!copies.get(0).sameOwnership(planned)) { + // Both copies agree on something else: staff changed it since; list it, never overwrite. + changedSincePlanned(plan, entry, copies.get(0)); + changed = true; + } + } + if (changed) DurableJson.write(file, plan); + if (!torn.isEmpty()) { + throw new IOException("Stored and live vehicle owners disagree after migration: " + torn); + } + } + + // Production lookups. + + /** + * Live providers. A legacy owner name counts only while it is still its account's latest + * name: Bukkit's name for the account when the server still knows one, otherwise the name + * DenarEconomy remembered last for it (its durable {@code player-names.json}, which never + * expires as the user cache does). + */ + public static final class LiveIdentity implements Identity { + static final Path REMEMBERED_NAMES = Path.of("plugins/DenarEconomy/Data/player-names.json"); + private final Path rememberedNames; + private java.util.Map latestNames; + + public LiveIdentity() { + this(REMEMBERED_NAMES); + } + + /** Reads DenarEconomy's name index from this file instead of the server's. */ + public LiveIdentity(Path rememberedNames) { + this.rememberedNames = rememberedNames; + } + + @Override + public UUID roundTrip(String name) { + UUID account = VehicleOwnershipQueries.resolvePlayerUuid(name); + if (account == null) return null; + String current = Bukkit.getOfflinePlayer(account).getName(); + if (current == null) current = latestRemembered().get(account); + return name.equalsIgnoreCase(current) ? account : null; + } + + /** Each account's most recently first-seen name in DenarEconomy's index; read once. */ + public java.util.Map latestRemembered() { + if (latestNames == null) latestNames = readLatest(rememberedNames); + return latestNames; + } + + static java.util.Map readLatest(Path file) { + java.util.Map latest = new java.util.HashMap<>(); + if (!Files.isRegularFile(file)) return latest; + try { + com.google.gson.JsonObject names = com.google.gson.JsonParser.parseString(Files.readString(file)) + .getAsJsonObject(); + // DenarEconomy appends a name the first time it sees it, so the last entry is the newest. + for (var entry : names.entrySet()) { + try { + latest.put(UUID.fromString(entry.getValue().getAsString()), entry.getKey()); + } catch (IllegalArgumentException | UnsupportedOperationException | IllegalStateException bad) { + // A damaged row names nobody. + } + } + } catch (IOException | RuntimeException unreadable) { + // Without the index only Bukkit's names count. + latest.clear(); + } + return latest; + } + + @Override + public Optional accountOf(UUID character) { + return Members.accountId(Members.key(character)); + } + + @Override + public Optional heir(UUID account) { + return Characters.legacyHeir(account); + } + + @Override + public String nameOf(UUID character) { + return Members.info(Members.key(character)).map(CharacterInfo::name).map(VehicleOwnerMigration::plain) + .orElse(null); + } + } + + static String plain(String name) { + String stripped = org.bukkit.ChatColor.stripColor(name); + return stripped == null || stripped.isBlank() ? null : stripped.strip(); + } + + /** {@code VACUUM INTO} and {@code quick_check} through SQLite JDBC, beside VehicleFramework's own connection. */ + public static final class SqliteBackups implements Backups { + private final Path database; + + public SqliteBackups(Path database) { + this.database = database; + } + + @Override + public void vacuumInto(Path destination) throws IOException { + try (Connection connection = connect(database); Statement statement = connection.createStatement()) { + statement.execute("PRAGMA busy_timeout=5000"); + statement.execute("VACUUM INTO '" + destination.toAbsolutePath().toString().replace("'", "''") + "'"); + } catch (SQLException error) { + throw new IOException("Could not copy " + database, error); + } + } + + @Override + public boolean quickCheck(Path copy) throws IOException { + try (Connection connection = connect(copy); Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery("PRAGMA quick_check")) { + return result.next() && "ok".equalsIgnoreCase(result.getString(1)) && !result.next(); + } catch (SQLException error) { + throw new IOException("Could not check " + copy, error); + } + } + + private static Connection connect(Path file) throws SQLException { + return DriverManager.getConnection("jdbc:sqlite:" + file.toAbsolutePath()); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java new file mode 100644 index 00000000..c0e249cc --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java @@ -0,0 +1,230 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration.Entry; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration.Plan; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration.Status; + +/** + * Stage 6a rollback for vehicles, run from the server folder with the server stopped: + *
java -cp <SimpleFactions jar>:<gson jar>:<sqlite-jdbc jar>
+ *     net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerRollback <server folder> [--apply]
+ * It restores only the owner fields (the SQLite column and the payload's {@code owner}, + * {@code whitelisted} and {@code whitelist}) of rows that still hold exactly what the + * migration wrote; anything changed since is listed and left alone. It also lists the + * claim, build and handover fees paid after the migration, for refunds. Without + * {@code --apply} nothing is written. + */ +public final class VehicleOwnerRollback { + static final String PAYMENTS = "vehicle-payments"; + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + private static final Gson PRETTY = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); + + /** A fee paid after the migration. */ + public record Refund(String receipt, String scope, String payerCharacter, String payerAccount, String factionId, + long cents, long at) {} + + public record Report(List restored, List changed, List gone, List refunds) {} + + private VehicleOwnerRollback() {} + + public static void main(String[] args) throws IOException { + if (args.length < 1 || args.length > 2 || (args.length == 2 && !args[1].equals("--apply"))) { + throw new IllegalArgumentException("Usage: VehicleOwnerRollback [--apply]"); + } + String missing = missingLibraries(JsonArray.class, "org.sqlite.JDBC"); + if (missing != null) throw new IllegalStateException(missing); + run(Path.of(args[0]), args.length == 2, System.out); + } + + /** + * Why the classpath cannot run the tool, or null. Gson must be 2.10 or newer (records, + * {@code JsonParser.parseString}); a server's {@code libraries/} may also hold older ones. + */ + static String missingLibraries(Class jsonArray, String sqliteDriver) { + try { + jsonArray.getMethod("asList"); + } catch (NoSuchMethodException old) { + return "Gson 2.10 or newer is needed on the classpath (found an older one); use the newest" + + " libraries/com/google/code/gson/gson//gson-.jar"; + } + try { + Class.forName(sqliteDriver); + } catch (ClassNotFoundException | LinkageError absent) { + return "sqlite-jdbc is needed on the classpath:" + + " libraries/org/xerial/sqlite-jdbc//sqlite-jdbc-.jar"; + } + return null; + } + + public static Report run(Path server, boolean apply, PrintStream out) throws IOException { + Path factions = server.resolve("plugins/SimpleFactions"); + Path planFile = factions.resolve(VehicleOwnerMigration.PLAN); + Plan plan = DurableJson.read(planFile, Plan.class); + Report report; + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + + server.resolve("plugins/VehicleFramework/data/vehicles.db").toAbsolutePath())) { + // Fees count from when the plan was made, so an unfinished migration lists them too. + report = restore(connection, plan, apply, refunds(factions.resolve(PAYMENTS), plan.createdAt)); + } catch (SQLException error) { + throw new IOException("Could not roll back vehicle owners", error); + } + Path refundFile = factions.resolve("vehicles/refunds-" + System.currentTimeMillis() + ".json"); + if (apply) { + DurableJson.write(refundFile, report.refunds()); + // A re-upgrade must plan afresh; the old plan is kept beside it for the record. + Files.move(planFile, planFile.resolveSibling(planFile.getFileName() + ".rolled-back-" + + System.currentTimeMillis()), StandardCopyOption.ATOMIC_MOVE); + } + out.println((apply ? "Restored " : "Would restore ") + report.restored().size() + " vehicle owners"); + report.changed().forEach(vehicle -> out.println("Changed since the migration, left alone: " + vehicle)); + report.gone().forEach(vehicle -> out.println("No longer stored: " + vehicle)); + out.println(report.refunds().size() + " fees paid after the migration" + + (apply ? ", listed in " + refundFile : ":")); + if (!apply) report.refunds().forEach(refund -> out.println(PRETTY.toJson(refund))); + return report; + } + + static Report restore(Connection connection, Plan plan, boolean apply, List refunds) throws SQLException { + List restored = new ArrayList<>(); + List changed = new ArrayList<>(); + List gone = new ArrayList<>(); + connection.setAutoCommit(false); + try { + for (Entry entry : plan.entries) { + // Pending entries may have been written before a crash; the row check keeps the rest safe. + if (entry.status == Status.QUARANTINED || entry.planned == null + || entry.original.value().sameOwnership(entry.planned.value())) continue; + Row row = row(connection, entry.vehicle); + if (row == null) { + gone.add(entry.vehicle); + } else if (!row.holds(entry.planned)) { + changed.add(entry.vehicle); + } else { + write(connection, entry, row.payload()); + restored.add(entry.vehicle); + } + } + if (apply) connection.commit(); + else connection.rollback(); + } catch (SQLException | RuntimeException error) { + connection.rollback(); + throw error; + } + return new Report(restored, changed, gone, refunds); + } + + /** A stored row's owner column and payload. */ + private record Row(String column, JsonObject payload) { + /** Whether the column and payload still hold exactly the migration's values. */ + boolean holds(VehicleOwnerMigration.Fields planned) { + List whitelist = new ArrayList<>(); + if (payload.get("whitelist") instanceof JsonArray entries) { + for (JsonElement entry : entries) whitelist.add(entry.getAsString()); + } + return planned.owner.equals(column) + && planned.owner.equals(text(payload, "owner")) + && planned.whitelist.equals(whitelist) + && planned.whitelisted == (payload.has("whitelisted") && payload.get("whitelisted").getAsBoolean()); + } + } + + private static Row row(Connection connection, String vehicle) throws SQLException { + try (PreparedStatement select = connection.prepareStatement( + "SELECT owner, payload_json FROM vehicles WHERE uuid = ? AND deleted = 0")) { + select.setString(1, vehicle); + try (ResultSet row = select.executeQuery()) { + if (!row.next()) return null; + return new Row(row.getString(1), JsonParser.parseString(row.getString(2)).getAsJsonObject()); + } + } + } + + private static void write(Connection connection, Entry entry, JsonObject payload) throws SQLException { + payload.addProperty("owner", entry.original.owner); + payload.addProperty("whitelisted", entry.original.whitelisted); + JsonArray whitelist = new JsonArray(); + entry.original.whitelist.forEach(whitelist::add); + payload.add("whitelist", whitelist); + try (PreparedStatement update = connection.prepareStatement("UPDATE vehicles SET owner = ?, payload_json = ?," + + " revision = revision + 1, updated_at = ? WHERE uuid = ? AND deleted = 0")) { + update.setString(1, entry.original.owner); + update.setString(2, GSON.toJson(payload)); + update.setLong(3, System.currentTimeMillis()); + update.setString(4, entry.vehicle); + update.executeUpdate(); + } + } + + /** + * Claim, build and handover fees whose charge completed at or after {@code since} and + * was not refunded. Older journal entries without a time use the file's time. + */ + static List refunds(Path journal, long since) throws IOException { + List saved = new ArrayList<>(); + List times = new ArrayList<>(); + if (Files.isDirectory(journal)) { + try (Stream files = Files.list(journal)) { + for (Path file : files.filter(path -> path.toString().endsWith(".json")).sorted().toList()) { + saved.add(JsonParser.parseString(Files.readString(file)).getAsJsonObject()); + times.add(Files.getLastModifiedTime(file).toMillis()); + } + } + } + Set refunded = new HashSet<>(); + for (JsonObject value : saved) { + JsonObject changes = value.getAsJsonObject("intent").getAsJsonObject("changes"); + if (applied(value) && "refund".equals(text(changes, "action"))) { + refunded.add(changes.getAsJsonObject("receipt").get("id").getAsString()); + } + } + List out = new ArrayList<>(); + for (int i = 0; i < saved.size(); i++) { + JsonObject changes = saved.get(i).getAsJsonObject("intent").getAsJsonObject("changes"); + String scope = text(changes, "scope"); + if (!applied(saved.get(i)) || !"charge".equals(text(changes, "action")) || scope == null + || !(scope.startsWith("claim:") || scope.startsWith("build:") || scope.startsWith("handover:"))) { + continue; + } + long at = changes.has("at") ? changes.get("at").getAsLong() : times.get(i); + JsonObject receipt = changes.getAsJsonObject("receipt"); + String id = receipt.get("id").getAsString(); + if (at < since || refunded.contains(id)) continue; + JsonObject payer = receipt.getAsJsonObject("payer"); + out.add(new Refund(id, scope, text(payer, "characterId"), text(payer, "ownerId"), text(receipt, "factionId"), + receipt.get("cents").getAsLong(), at)); + } + return out; + } + + private static boolean applied(JsonObject saved) { + return "APPLIED".equals(text(saved, "completed")); + } + + private static String text(JsonObject object, String key) { + return object != null && object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : null; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWrites.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWrites.java new file mode 100644 index 00000000..d4382c3f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWrites.java @@ -0,0 +1,97 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Level; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; + +/** + * Every owner value SimpleFactions writes goes through here, and so through + * VehicleFramework's {@code setOwnership}. Without a usable VehicleFramework + * every write answers {@link Result#UNAVAILABLE}, so callers fail closed. + */ +public final class VehicleOwnerWrites { + private static volatile VehicleOwnerApi api; + + private VehicleOwnerWrites() {} + + /** The VehicleFramework 2.13 API, or null for an older VehicleFramework (rule 11 probe). */ + public static VehicleOwnerApi probe() { + return probe("net.tfminecraft.vehicleframework.api.VehicleFrameworkApi"); + } + + static VehicleOwnerApi probe(String apiClass) { + try { + Class type = Class.forName(apiClass); + type.getMethod("ownershipIdentity"); + type.getMethod("listOwnershipMeta"); + type.getMethod("readOwnershipMeta", UUID.class); + type.getMethod("setOwnership", UUID.class, + Class.forName("net.tfminecraft.vehicleframework.api.OwnerChange"), + Class.forName("net.tfminecraft.vehicleframework.api.SystemReason")); + return new VfOwnerApi(); + } catch (ReflectiveOperationException | LinkageError missing) { + return null; + } + } + + public static void use(VehicleOwnerApi next) { + api = next; + } + + public static VehicleOwnerApi api() { + return api; + } + + /** Whether owner writes can succeed: the API is present and in characters mode. */ + public static boolean ready() { + VehicleOwnerApi current = api; + if (current == null) return false; + try { + return current.characters(); + } catch (RuntimeException | LinkageError error) { + return false; + } + } + + public static Result write(String vehicle, String owner, Reason reason) { + return write(vehicle, owner, null, reason); + } + + public static Result write(String vehicle, String owner, List whitelist, Reason reason) { + VehicleOwnerApi current = api; + if (current == null) return Result.UNAVAILABLE; + try { + return current.write(vehicle, owner, whitelist, reason); + } catch (RuntimeException | LinkageError error) { + log("Could not set the owner of vehicle " + vehicle + " to " + owner + " (" + reason + ")", error); + return Result.SAVE_FAILED; + } + } + + /** The vehicle's ownership fields; empty when it is gone or cannot be read. */ + public static Optional read(String vehicle) { + VehicleOwnerApi current = api; + if (current == null || vehicle == null) return Optional.empty(); + try { + return current.read(vehicle); + } catch (RuntimeException | LinkageError error) { + log("Could not read the owner of vehicle " + vehicle, error); + return Optional.empty(); + } + } + + /** The current owner value, or null when the vehicle is gone or cannot be read. */ + public static String currentOwner(String vehicle) { + return read(vehicle).map(OwnerMeta::currentOwner).orElse(null); + } + + static void log(String message, Throwable error) { + SimpleFactions plugin = SimpleFactions.getInstance(); + if (plugin != null) plugin.getLogger().log(Level.WARNING, message, error); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java new file mode 100644 index 00000000..cf547e99 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java @@ -0,0 +1,286 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.economy.Money; +import net.tfminecraft.simplefactions.events.FactionLeaderChangedEvent; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.utils.Formatter; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeConfirmations; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeMessages; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore; +import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimListener; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimService; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.vehicleframework.api.OwnershipReason; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangeEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipEditCheckEvent; +import net.tfminecraft.vehicleframework.events.VehiclePreInteractEvent; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +/** + * SimpleFactions' side of VehicleFramework's player ownership changes. Vetoes come first + * (faction vehicles, slot limits, then the claim fee), and the payer registry changes only + * after VehicleFramework reports the write, once per operation. Bukkit only logs listener + * exceptions, so every veto handler refuses on its own errors. + */ +public final class VehicleOwnershipGate implements Listener { + public static final String PAYMENT_UNAVAILABLE = "§cVehicle payment needs a living character or staff recovery."; + public static final String FAILED = "§cVehicle ownership could not be checked, so nothing changed."; + public static final String HELD = "§cThis vehicle is held for staff; ask them to assign its owner."; + static final int REMEMBERED_OPERATIONS = 1024; + + private final PlayerVehicleRegistry registry; + private final VehicleRegistryClaimService claims; + private final VehicleFeeStore store; + private final VehicleFeeConfirmations confirmations; + private final Runnable saver; + private final Logger logger; + /** Claim fees charged under an operation, refunded if VehicleFramework reports the write failed. */ + private final Map charges = new HashMap<>(); + private final Set recorded = new LinkedHashSet<>(); + + public VehicleOwnershipGate(PlayerVehicleRegistry registry, VehicleRegistryClaimService claims, + VehicleFeeStore store, VehicleFeeConfirmations confirmations, Runnable saver, Logger logger) { + this.registry = registry; + this.claims = claims; + this.store = store; + this.confirmations = confirmations; + this.saver = saver; + this.logger = logger; + } + + /** Faction vehicles are changed only through SimpleFactions; claims need a payer and a free slot. */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onChange(VehicleOwnershipChangeEvent event) { + try { + if (event.isStaff()) return; + ActiveVehicle vehicle = event.getVehicle(); + if (VehicleQuarantine.held(vehicle.getUUID())) { + refuse(event, event.getPlayer(), HELD); + return; + } + if (LeaderStamps.isFactionRow(row(vehicle))) { + refuse(event, event.getPlayer(), VehicleRegistryClaimListener.FACTION_VEHICLE); + return; + } + if (event.getReason() != OwnershipReason.CLAIM) return; + VehiclePayments payments = VehiclePayments.current(); + if (event.getActor().isEmpty() || payments == null || !payments.ready() + || payments.blockedVehicle(vehicle.getUUID())) { + refuse(event, event.getPlayer(), PAYMENT_UNAVAILABLE); + return; + } + VehicleRegistryClaimListener.handleClaimResult(event, event.getPlayer(), vehicle.getId(), + claims.tryRegisterOnClaim(event.getPlayer(), vehicle)); + } catch (RuntimeException | LinkageError error) { + fail(event, event.getPlayer(), error); + } + } + + /** + * Claiming a vehicle someone else last owned is a transfer: the first click shows the fee, + * the second pays it under VehicleFramework's operation ID. Own vehicles and staff are free. + */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onClaimFee(VehicleOwnershipChangeEvent event) { + try { + if (event.getReason() != OwnershipReason.CLAIM || event.isStaff()) return; + Player player = event.getPlayer(); + ActiveVehicle vehicle = event.getVehicle(); + CharacterRef actor = event.getActor().orElseThrow(); + String lastOwner = store.getLastOwner(vehicle.getUUID()); + if (lastOwner == null || lastOwner.equals(Members.key(actor))) return; + Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, lastOwner, vehicle.getId()); + if (quote == null) return; + String scope = "claim:" + vehicle.getUUID(); + long now = System.currentTimeMillis(); + String target = scope + ":" + actor.characterId() + ":" + + quote.faction().getOrCreateMainGuild().getIncarnationId(); + if (!confirmations.confirm(player.getUniqueId(), target, quote.amount(), now)) { + confirmations.ask(player.getUniqueId(), target, quote.amount(), now); + refuse(event, player, VehicleFeeMessages.claimConfirm(quote, Members.display(lastOwner))); + return; + } + Result paid = VehiclePayments.current() + .charge(scope, "sf:vehicle:claim:" + event.getOperationId(), actor, quote); + if (paid != Result.APPLIED) { + refuse(event, player, VehicleFeeMessages.claimUnaffordable(quote)); + return; + } + charges.put(event.getOperationId(), scope); + player.sendMessage(VehicleFeeMessages.claimPaid(quote)); + } catch (RuntimeException | LinkageError error) { + fail(event, event.getPlayer(), error); + } + } + + /** Records a claim or takeover as the new owner's payer row, drops it on a clear, and refunds failed claims. */ + @EventHandler(priority = EventPriority.MONITOR) + public void onChanged(VehicleOwnershipChangedEvent event) { + if (event.getReason().isSystem() || !remember(event.getOperationId())) return; + String scope = charges.remove(event.getOperationId()); + try { + VehiclePayments payments = VehiclePayments.current(); + if (!event.isSuccess()) { + if (scope != null) refund(payments, scope, event); + return; + } + ActiveVehicle vehicle = event.getVehicle().orElseThrow(); + switch (event.getReason()) { + case CLAIM, TAKEOVER -> recordOwner(event, vehicle, scope, payments); + case CLEAR -> forgetOwner(event, vehicle, payments); + default -> { + // Whitelist and ticket changes do not touch the registry. + } + } + } catch (RuntimeException | LinkageError error) { + logger.log(Level.SEVERE, "Could not record ownership change " + event.getOperationId() + + "; the upkeep reconcile lists the vehicle", error); + } + } + + private void recordOwner(VehicleOwnershipChangedEvent event, ActiveVehicle vehicle, String scope, + VehiclePayments payments) { + CharacterRef owner = event.getTarget().or(event::getActor).orElse(null); + if (owner == null || payments == null + || !payments.personal(vehicle.getUUID(), vehicle.getId(), owner.characterId(), owner.ownerId())) { + logger.warning("Vehicle " + vehicle.getUUID() + " changed owner but its payer row could not be" + + " written; the upkeep reconcile adds it"); + return; + } + String member = Members.key(owner); + if (scope != null) { + payments.complete(scope, member); + } else { + store.setLastOwner(vehicle.getUUID(), member); + saver.run(); + } + audit(event, vehicle); + } + + private void forgetOwner(VehicleOwnershipChangedEvent event, ActiveVehicle vehicle, VehiclePayments payments) { + if (row(vehicle) != null && (payments == null || !payments.replaceRow(vehicle.getUUID(), null))) { + logger.warning("Vehicle " + vehicle.getUUID() + " was cleared but its payer row could not be removed"); + } + // The last owner stays, so a friend claiming the released vehicle pays the transfer fee. + audit(event, vehicle); + } + + /** The guild bank may hold less than the fee by now; a short refund is logged and the player told. */ + private void refund(VehiclePayments payments, String scope, VehicleOwnershipChangedEvent event) { + var receipt = payments == null ? null : payments.receipt(scope); + var refund = receipt == null ? null : payments.refund(scope); + if (refund == null || refund.result() != Result.APPLIED) { + logger.severe("Claim fee " + scope + " was charged but the claim failed, and the refund did not go" + + " through; staff must recover it"); + return; + } + double refunded = refund.amount(); + double shortfall = Formatter.formatDouble(Money.denars(receipt.cents()) - refunded); + if (shortfall > 0.0) { + logger.warning("Transfer fee refund short by " + shortfall + " for " + receipt.payer().characterId() + + " from faction " + receipt.factionId()); + } + event.getPlayer().ifPresent(player -> { + if (shortfall <= 0.0) { + player.sendMessage(VehicleFeeMessages.claimRefunded()); + return; + } + if (refunded > 0.0) player.sendMessage(VehicleFeeMessages.claimRefundedPart(refunded)); + player.sendMessage(VehicleFeeMessages.claimRefundShort(shortfall)); + }); + } + + private void audit(VehicleOwnershipChangedEvent event, ActiveVehicle vehicle) { + if (!event.isStaff()) return; + logger.info("[vehicle audit] " + event.getPlayer().map(Player::getName).orElse("console") + " " + + event.getReason() + " " + vehicle.getUUID() + " (" + vehicle.getId() + "): " + + event.getPrevious().owner() + " -> " + event.getCurrent().owner()); + } + + /** Edit check: a faction vehicle's leader may open its menu but use no control, and a stale stamp hides it. */ + @EventHandler + public void onEditCheck(VehicleOwnershipEditCheckEvent event) { + try { + if (event.isStaff()) return; + PlayerVehicleRecord record = row(event.getVehicle()); + if (!LeaderStamps.isFactionRow(record)) return; + if (LeaderStamps.isStale(record, event.getVehicle().getOwnerData().getOwner())) { + event.deny(); + LeaderStamps.scheduleRestamp(record); + } else if (!event.isMenu()) { + event.deny(); + } + } catch (RuntimeException | LinkageError error) { + event.deny(); + } + } + + /** Backstop before every other vehicle click handler: a stale faction stamp is refused and fixed. */ + @EventHandler(priority = EventPriority.LOWEST) + public void onPreInteract(VehiclePreInteractEvent event) { + try { + ActiveVehicle vehicle = event.getVehicle(); + if (vehicle == null || vehicle.getOwnerData() == null) return; + PlayerVehicleRecord record = row(vehicle); + if (LeaderStamps.isStale(record, vehicle.getOwnerData().getOwner())) { + event.setCancelled(true); + LeaderStamps.scheduleRestamp(record); + } + } catch (RuntimeException | LinkageError error) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onLeaderChanged(FactionLeaderChangedEvent event) { + LeaderStamps.onLeaderChanged(event.getFaction(), registry); + } + + private PlayerVehicleRecord row(ActiveVehicle vehicle) { + return registry.getByVehicleUuid(vehicle.getUUID()).orElse(null); + } + + private boolean remember(UUID operation) { + if (!recorded.add(operation)) return false; + if (recorded.size() > REMEMBERED_OPERATIONS) { + Iterator oldest = recorded.iterator(); + oldest.next(); + oldest.remove(); + } + return true; + } + + private static void refuse(Cancellable event, Player player, String message) { + event.setCancelled(true); + if (player != null && message != null) player.sendMessage(message); + } + + private void fail(Cancellable event, Player player, Throwable error) { + event.setCancelled(true); + logger.log(Level.SEVERE, "Vehicle ownership check failed; the change was refused", error); + if (player != null) player.sendMessage(FAILED); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServices.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServices.java new file mode 100644 index 00000000..520a0b6f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServices.java @@ -0,0 +1,46 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.BooleanSupplier; + +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.plugin.ServicesManager; + +import net.tfminecraft.vehicleframework.api.QuarantinedVehicle; +import net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority; +import net.tfminecraft.vehicleframework.api.VehicleQuarantineSource; + +/** The two VehicleFramework services SimpleFactions provides; only loaded once the API probe passed. */ +public final class VehicleOwnershipServices { + private VehicleOwnershipServices() {} + + /** Lists held-back vehicles for {@code /vf admin quarantine}, even when the vehicle stage stopped early. */ + public static void registerQuarantine(ServicesManager services, Plugin plugin, VehicleQuarantine quarantine) { + services.register(VehicleQuarantineSource.class, () -> quarantined(quarantine), plugin, ServicePriority.Normal); + } + + /** Registered last in the vehicle stage; VehicleFramework refuses non-staff changes until then. */ + public static void registerAuthority(ServicesManager services, Plugin plugin, BooleanSupplier ready) { + services.register(VehicleOwnershipAuthority.class, new VehicleOwnershipAuthority() { + @Override + public boolean ready() { + return ready.getAsBoolean(); + } + }, plugin, ServicePriority.Normal); + } + + static List quarantined(VehicleQuarantine quarantine) { + List out = new ArrayList<>(); + for (VehicleQuarantine.Entry entry : quarantine.list()) { + try { + out.add(new QuarantinedVehicle(UUID.fromString(entry.vehicle()), entry.owner(), entry.reason())); + } catch (IllegalArgumentException notUuid) { + // VehicleFramework IDs are UUIDs; a stray registry key cannot be shown by ID. + } + } + return out; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantine.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantine.java new file mode 100644 index 00000000..53d702a3 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantine.java @@ -0,0 +1,108 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import net.tfminecraft.simplefactions.database.DurableJson; + +/** + * Vehicles held back for staff: the owner migration's quarantine, vehicles stamped + * {@code char_none} at construction because payer and constructor disagreed, and what + * the reconciles found. Shown by {@code /vf admin quarantine}. + */ +public final class VehicleQuarantine { + /** One listed vehicle; {@code owner} is its owner value when it was listed. */ + public record Entry(String vehicle, String owner, String reason) {} + + private static final class Stored { + List entries = new ArrayList<>(); + } + + private static volatile VehicleQuarantine active; + + private final Path runtimeFile; + private List migration = List.of(); + private List runtime = List.of(); + private List findings = List.of(); + + public VehicleQuarantine(Path folder) { + this.runtimeFile = folder.resolve("vehicles/quarantine.json"); + } + + public static void use(VehicleQuarantine quarantine) { + active = quarantine; + } + + /** The running server's list, or null before the vehicle stage. */ + public static VehicleQuarantine current() { + return active; + } + + public void load(List migrationEntries) throws IOException { + migration = List.copyOf(migrationEntries); + if (Files.exists(runtimeFile)) { + Stored stored = DurableJson.read(runtimeFile, Stored.class); + runtime = stored.entries == null ? List.of() : List.copyOf(stored.entries); + } + } + + /** Lists a vehicle for staff and saves the list. */ + public void add(Entry entry) throws IOException { + List next = new ArrayList<>(runtime); + next.add(entry); + Stored stored = new Stored(); + stored.entries = next; + DurableJson.write(runtimeFile, stored); + runtime = List.copyOf(next); + } + + public void setFindings(List next) { + findings = List.copyOf(next); + } + + public List findings() { + return findings; + } + + /** + * Held-back vehicles that still carry the owner they were listed with (staff have not + * since given them an owner), then the reconcile findings. + */ + public List list() { + List out = new ArrayList<>(); + for (Entry entry : concat(migration, runtime)) { + if (stillHeld(entry)) out.add(entry); + } + out.addAll(findings); + return out; + } + + /** + * Whether the running list holds this vehicle back: SimpleFactions then leaves its owner + * to staff, and players cannot change it. + */ + public static boolean held(String vehicle) { + VehicleQuarantine quarantine = active; + if (quarantine == null || vehicle == null) return false; + for (Entry entry : concat(quarantine.migration, quarantine.runtime)) { + if (vehicle.equals(entry.vehicle()) && stillHeld(entry)) return true; + } + return false; + } + + /** Listed until staff give it an owner; one whose owner could not be read stays listed. */ + private static boolean stillHeld(Entry entry) { + if (entry.owner() == null) return true; + return Objects.equals(VehicleOwnerWrites.currentOwner(entry.vehicle()), entry.owner()); + } + + private static List concat(List first, List second) { + List all = new ArrayList<>(first); + all.addAll(second); + return all; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcile.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcile.java new file mode 100644 index 00000000..2ec089ee --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcile.java @@ -0,0 +1,100 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.logging.Logger; + +import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; + +/** + * Keeps VehicleFramework's owners and the payer registry in step, at boot and daily. A + * character-owned vehicle without a row gets a personal row, so its owner pays upkeep; + * everything else that disagrees is listed for staff and left alone. + */ +public final class VehicleUpkeepReconcile { + /** Writes a personal row; false when it could not be saved. */ + public interface Rows { + boolean personal(String vehicle, String type, UUID character, UUID account); + } + + private final VehicleOwnerApi api; + private final PlayerVehicleRegistry registry; + private final Rows rows; + private final Predicate isLeader; + private final Function> accountOf; + private final Logger logger; + + public VehicleUpkeepReconcile(VehicleOwnerApi api, PlayerVehicleRegistry registry, Rows rows, + Predicate isLeader, Function> accountOf, Logger logger) { + this.api = api; + this.registry = registry; + this.rows = rows; + this.isLeader = isLeader; + this.accountOf = accountOf; + this.logger = logger; + } + + /** Runs once; returns what staff should look at. Throws when VehicleFramework cannot be read. */ + public List run() { + List findings = new ArrayList<>(); + Set seen = new HashSet<>(); + for (OwnerMeta meta : api.list()) { + seen.add(meta.vehicle()); + String owner = meta.currentOwner(); + PlayerVehicleRecord record = registry.getByVehicleUuid(meta.vehicle()).orElse(null); + UUID character = OwnerTags.characterId(owner).orElse(null); + if (record == null) { + if (character != null) unregistered(meta, owner, character, findings); + } else if (record.getMode() == OwnershipMode.PERSONAL) { + if (!OwnerTags.names(owner, record.getPlayerUuid())) { + findings.add(finding(meta.vehicle(), owner, "personal registry row names character " + + record.getPlayerUuid() + ", the vehicle names another owner")); + } + } else if (!LeaderStamps.expected(record).equals(owner)) { + findings.add(finding(meta.vehicle(), owner, + "faction vehicle should carry " + LeaderStamps.expected(record))); + } + } + for (PlayerVehicleRecord record : registry.getAll()) { + if (!seen.contains(record.getVehicleUuid())) { + findings.add(finding(record.getVehicleUuid(), null, "registry row has no vehicle")); + } else if (record.getMode() == OwnershipMode.PERSONAL && VehicleSlotGuard.accountOf(record) == null) { + findings.add(finding(record.getVehicleUuid(), null, + "personal row's account is unknown, so it counts against every claimer")); + } + } + return findings; + } + + private void unregistered(OwnerMeta meta, String owner, UUID character, List findings) { + if (isLeader.test(character)) { + findings.add(finding(meta.vehicle(), owner, "owner is a faction leader but the vehicle has no registry row")); + return; + } + UUID account = accountOf.apply(character).orElse(null); + if (account == null) { + findings.add(finding(meta.vehicle(), owner, "owner character is unknown and the vehicle has no registry row")); + return; + } + if (rows.personal(meta.vehicle(), meta.typeId(), character, account)) { + logger.info("Vehicle " + meta.vehicle() + " (" + meta.typeId() + ") had no payer row; " + + "its owner character " + character + " now pays its upkeep"); + } else { + findings.add(finding(meta.vehicle(), owner, "could not save a payer row for its owner")); + } + } + + private VehicleQuarantine.Entry finding(String vehicle, String owner, String reason) { + logger.warning("Vehicle " + vehicle + ": " + reason); + return new VehicleQuarantine.Entry(vehicle, owner, "reconcile: " + reason); + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VfOwnerApi.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VfOwnerApi.java new file mode 100644 index 00000000..7b1874b4 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VfOwnerApi.java @@ -0,0 +1,59 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +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; + +/** The only class that calls VehicleFrameworkApi; loaded once the probe has found it. */ +final class VfOwnerApi implements VehicleOwnerApi { + + @Override + public boolean characters() { + return VehicleFrameworkApi.ownershipIdentity() == OwnershipIdentity.CHARACTERS; + } + + @Override + public List list() { + return VehicleFrameworkApi.listOwnershipMeta().stream().map(VfOwnerApi::meta).toList(); + } + + @Override + public Optional read(String vehicle) { + UUID id = uuid(vehicle); + return id == null ? Optional.empty() : VehicleFrameworkApi.readOwnershipMeta(id).map(VfOwnerApi::meta); + } + + @Override + public Result write(String vehicle, String owner, List whitelist, Reason reason) { + UUID id = uuid(vehicle); + if (id == null) return Result.NOT_FOUND; + OwnerChange change = whitelist == null + ? new OwnerChange(owner, null) : OwnerChange.seeded(owner, whitelist); + return Result.valueOf(VehicleFrameworkApi.setOwnership(id, change, SystemReason.valueOf(reason.name())).name()); + } + + static OwnerMeta meta(OwnershipMeta meta) { + return new OwnerMeta(meta.vehicleId().toString(), meta.typeId(), meta.storedOwnerColumn(), + meta.stored().map(VfOwnerApi::fields), meta.live().map(VfOwnerApi::fields)); + } + + static OwnerFields fields(OwnershipValues values) { + return new OwnerFields(values.owner(), values.whitelist(), values.whitelisted(), values.ticketsEnabled(), + values.ticketId()); + } + + private static UUID uuid(String vehicle) { + try { + return vehicle == null ? null : UUID.fromString(vehicle); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolService.java index 9c440620..04ed8acb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolService.java @@ -14,7 +14,8 @@ import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; import net.tfminecraft.vehicleframework.data.OwnerData; import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; @@ -133,7 +134,7 @@ public CanAddResult canAdd(Faction faction, PoolTarget vehicle) { OwnerData ownerData = vehicle.getOwnerData(); String owner = ownerData == null ? null : ownerData.getOwner(); - if (!VehicleOwnershipQueries.isPlayerOwner(owner)) { + if (!OwnerTags.isCharacter(owner)) { return CanAddResult.NOT_OWNED; } @@ -181,7 +182,16 @@ public boolean tryRegister(Faction faction, PoolTarget vehicle, UUID originalOwn registry.register(previous); return false; } - ownerSync.applyLeaderOwner(vehicle.getOwnerData(), faction); + if (InstallationVehicleOwnerSync.stampForFaction(vehicle.getVehicleUuid(), faction) != Result.OK) { + // The registry went first, so put the personal row back before reporting the failure. + registry.unregister(vehicle.getVehicleUuid()); + registry.register(previous); + if (plugin != null && !plugin.saveVehicleRegistry()) { + plugin.getLogger().severe("Vehicle " + vehicle.getVehicleUuid() + " was restored in memory after" + + " its pool stamp failed, but the registry could not be saved."); + } + return false; + } return true; } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRecord.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRecord.java index 9b097657..2ac9fd5f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRecord.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRecord.java @@ -9,6 +9,8 @@ public final class PlayerVehicleRecord { private final OwnershipMode mode; private final String installationId; private final String factionId; + /** The account that played {@code playerUuid} when the row was written; null for older rows. */ + private final UUID accountUuid; public PlayerVehicleRecord( UUID playerUuid, @@ -26,12 +28,24 @@ public PlayerVehicleRecord( OwnershipMode mode, String installationId, String factionId) { + this(playerUuid, vehicleUuid, vehicleTypeId, mode, installationId, factionId, null); + } + + public PlayerVehicleRecord( + UUID playerUuid, + String vehicleUuid, + String vehicleTypeId, + OwnershipMode mode, + String installationId, + String factionId, + UUID accountUuid) { this.playerUuid = playerUuid; this.vehicleUuid = vehicleUuid; this.vehicleTypeId = vehicleTypeId; this.mode = mode; this.installationId = installationId; this.factionId = factionId; + this.accountUuid = accountUuid; } public UUID getPlayerUuid() { @@ -57,4 +71,12 @@ public String getInstallationId() { public String getFactionId() { return factionId; } + + public UUID getAccountUuid() { + return accountUuid; + } + + public PlayerVehicleRecord withAccount(UUID account) { + return new PlayerVehicleRecord(playerUuid, vehicleUuid, vehicleTypeId, mode, installationId, factionId, account); + } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccounts.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccounts.java new file mode 100644 index 00000000..7c95d535 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccounts.java @@ -0,0 +1,51 @@ +package net.tfminecraft.simplefactions.vehicles.registry; + +import java.io.IOException; +import java.nio.file.Path; +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; + +/** + * Personal rows from before the account was cached get it filled in, so slot limits + * keep counting them per account: from the character's account, else from the account + * the owner migrations matched the vehicle's old owner name to. Rows nothing resolves + * keep no account and count against every claimer. + */ +public final class RegistryAccounts { + private RegistryAccounts() {} + + /** Fills missing accounts and saves the registry when anything changed; returns how many were filled. */ + public static int fill(Path folder, PlayerVehicleRegistry registry, Function> accountOf, + List> matched) throws IOException { + List records = new ArrayList<>(); + int filled = 0; + for (PlayerVehicleRecord record : registry.getAll()) { + UUID account = record.getMode() == OwnershipMode.PERSONAL && record.getAccountUuid() == null + ? account(record, accountOf, matched) : null; + if (account != null) filled++; + records.add(account == null ? record : record.withAccount(account)); + } + if (filled > 0) { + VehicleRegistryPersistence.write(folder.resolve("Cache/vehicles_registry.json"), records); + records.forEach(registry::register); + } + return filled; + } + + private static UUID account(PlayerVehicleRecord record, Function> accountOf, + List> matched) { + if (record.getPlayerUuid() != null) { + Optional owner = accountOf.apply(record.getPlayerUuid()); + if (owner.isPresent()) return owner.get(); + } + for (Map source : matched) { + UUID account = source.get(record.getVehicleUuid()); + if (account != null) return account; + } + return null; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java index 93b4d609..1bac696e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueries.java @@ -2,189 +2,66 @@ import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.UUID; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import net.tfminecraft.denareconomy.accounts.OfflineModifier; import net.tfminecraft.simplefactions.identity.Members; -import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; -import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; -import net.tfminecraft.vehicleframework.VehicleFramework; -import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerMeta; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites; public final class VehicleOwnershipQueries { - public interface OwnedInventory { - List listByOwner(String ownerEntry); - - List listAllPlayerOwned(); - } - - private static OwnedInventory source = new VfOwnedInventory(); + /** A personal vehicle: owned by one character, or still by a legacy account name, and not a faction's. */ + public record PersonalVehicle(String vehicle, String typeId) {} private VehicleOwnershipQueries() {} - public static void setSourceForTests(OwnedInventory inventory) { - source = inventory == null ? new VfOwnedInventory() : inventory; - } - - public static String ownerEntry(String playerName) { - if (playerName == null || playerName.isBlank()) { - return "player_none"; - } - return "player_" + playerName; - } - - 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; - } - - public static String playerNameFromOwner(String owner) { - if (!isPlayerOwner(owner)) { - return null; - } - return owner.substring(7); - } - - public static List personalVehicles( - String playerName, PlayerVehicleRegistry registry) { - if (playerName == null || playerName.isBlank()) { - return List.of(); - } - return excludeBerthed(source.listByOwner(ownerEntry(playerName)), registry); - } - - public static List allPersonalVehicles(PlayerVehicleRegistry registry) { - return excludeBerthed(source.listAllPlayerOwned(), registry); - } - - public static int countOfType(List personal, String vehicleTypeId) { - if (personal == null || vehicleTypeId == null || vehicleTypeId.isEmpty()) { - return 0; - } - String normalized = vehicleTypeId.toLowerCase(Locale.ROOT); - int count = 0; - for (OwnedVehicleSummary vehicle : personal) { - if (vehicle.getTypeId() != null && normalized.equalsIgnoreCase(vehicle.getTypeId())) { - count++; + /** + * Every personal vehicle, from VehicleFramework's owner values. Without a readable + * VehicleFramework the personal registry rows stand in, so upkeep is still charged. + */ + public static List allPersonalVehicles(PlayerVehicleRegistry registry) { + VehicleOwnerApi api = VehicleOwnerWrites.api(); + if (api != null) { + try { + List out = new ArrayList<>(); + for (OwnerMeta meta : api.list()) { + String owner = meta.currentOwner(); + if (!OwnerTags.isCharacter(owner) && OwnerTags.legacyName(owner) == null) continue; + if (registry.isFactionOwned(meta.vehicle())) continue; + out.add(new PersonalVehicle(meta.vehicle(), meta.typeId())); + } + return out; + } catch (RuntimeException | LinkageError error) { + logFallback(error); } } - return count; - } - - public static int countExcludingIgnoreLimit(List personal) { - if (personal == null) { - return 0; - } - int count = 0; - for (OwnedVehicleSummary vehicle : personal) { - if (!VehiclesConfigLoader.ignoresPersonalSlotLimit(vehicle.getTypeId())) { - count++; + List rows = new ArrayList<>(); + for (PlayerVehicleRecord record : registry.getAll()) { + if (record.getMode() == OwnershipMode.PERSONAL) { + rows.add(new PersonalVehicle(record.getVehicleUuid(), record.getVehicleTypeId())); } } - return count; + return rows; } + /** The account a legacy owner name belongs to, from DenarEconomy's remembered names. */ public static UUID resolvePlayerUuid(String playerName) { return OfflineModifier.playerId(playerName); } - /** - * The member key standing for a VehicleFramework owner tag, which still - * names an account (stage 6 moves vehicles to characters): the character - * the account is playing now, else its reviewed legacy heir, the same rule - * the migration used for account-owned rows. Null when neither is known. - */ - public static String ownerKey(String accountName) { - if (accountName == null || accountName.isBlank() || Bukkit.getServer() == null) { - return null; - } - Player online = Bukkit.getPlayerExact(accountName); - if (online != null) { - return Members.key(online); - } - if (!Bukkit.isPrimaryThread() || !Characters.available()) { - return null; - } - UUID account = OfflineModifier.playerId(accountName); - if (account == null) { - return null; - } - return Characters.legacyHeir(account) - .filter(heir -> account.equals(heir.ownerId())) - .map(Members::key) - .orElse(null); - } - - /** The character a new registry row or fee is recorded for; the account without a character. */ + /** The character a new registry row or fee is recorded for; null without a current session. */ public static UUID characterId(Player player) { return Members.session(player).map(session -> session.ref().characterId()).orElse(null); } - /** The account name VehicleFramework uses for a member's vehicles; a non-key is already a name. */ - public static String accountNameOf(String member) { - if (member == null || member.isBlank()) { - return null; - } - return Members.characterId(member) == null ? member : Members.accountName(member); - } - - public static String resolvePlayerName(UUID playerUuid) { - if (playerUuid == null || Bukkit.getServer() == null) { - return null; - } - Player online = Bukkit.getPlayer(playerUuid); - if (online != null) { - return online.getName(); - } - OfflinePlayer offline = Bukkit.getOfflinePlayer(playerUuid); - return offline.getName(); - } - - private static List excludeBerthed( - List owned, PlayerVehicleRegistry registry) { - if (owned == null || owned.isEmpty()) { - return List.of(); - } - List out = new ArrayList<>(); - for (OwnedVehicleSummary vehicle : owned) { - if (vehicle == null || vehicle.getUuid() == null) { - continue; - } - if (registry != null && registry.isFactionOwned(vehicle.getUuid())) { - continue; - } - out.add(vehicle); - } - return out; - } - - private static final class VfOwnedInventory implements OwnedInventory { - @Override - public List listByOwner(String ownerEntry) { - try { - return VehicleFramework.getVehicleManager().listOwnedVehicles(ownerEntry); - } catch (Throwable ignored) { - return List.of(); - } - } - - @Override - public List listAllPlayerOwned() { - try { - return VehicleFramework.getVehicleManager().listAllPlayerOwnedVehicles(); - } catch (Throwable ignored) { - return List.of(); - } + private static void logFallback(Throwable error) { + var plugin = net.tfminecraft.simplefactions.SimpleFactions.getInstance(); + if (plugin != null) { + plugin.getLogger().warning("Could not list vehicle owners; using personal registry rows instead: " + error); } } } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigration.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigration.java index 5327cc51..81e22975 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigration.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigration.java @@ -9,6 +9,7 @@ import java.util.UUID; import java.util.function.Function; import net.tfminecraft.simplefactions.database.DurableJson; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags; import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.character.Characters; import net.tfminecraft.vehicleframework.VehicleFramework; @@ -23,6 +24,18 @@ private static final class Plan { } private VehiclePayerMigration() {} + /** The accounts the stage-5 payer plan resolved owner names to, by vehicle; empty without a plan. */ + public static java.util.Map accounts(Path folder) throws IOException { + Path file = folder.resolve("identity/vehicle-payers.json"); + java.util.Map out = new java.util.HashMap<>(); + if (!Files.exists(file)) return out; + Plan plan = DurableJson.read(file, Plan.class); + if (plan.rows != null) { + for (Row row : plan.rows) if (row != null && row.vehicle() != null && row.account() != null) out.put(row.vehicle(), row.account()); + } + return out; + } + public static void startup(Path folder, PlayerVehicleRegistry registry) throws IOException { Path file = folder.resolve("identity/vehicle-payers.json"); if (Files.exists(file)) { replay(folder, file, registry, DurableJson.read(file, Plan.class)); return; } @@ -42,8 +55,10 @@ static void migrate(Path folder, PlayerVehicleRegistry registry, List { + event.setCancelled(true); + player.sendMessage(FACTION_VEHICLE); + } case SKIP, ALLOWED -> { // allow VF to continue } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryClaimService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryClaimService.java index 372a0220..e6272cf1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryClaimService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryClaimService.java @@ -35,6 +35,11 @@ public static ClaimRegisterResult failUnknownType() { return new ClaimRegisterResult(TryRegisterResult.FAIL_UNKNOWN_TYPE, CanBuildResult.UNKNOWN_TYPE); } + /** Faction vehicles are never claimed; staff take them over instead. */ + public static ClaimRegisterResult factionVehicle() { + return new ClaimRegisterResult(TryRegisterResult.FAIL_FACTION_VEHICLE, null); + } + public static ClaimRegisterResult failSlotLimit(CanBuildResult buildFailure) { return new ClaimRegisterResult(TryRegisterResult.FAIL_SLOT_LIMIT, buildFailure); } @@ -58,7 +63,7 @@ ClaimRegisterResult tryRegisterOnClaim(Player player, VehicleClaimTarget vehicle } if (registry.isFactionOwned(vehicleUuid)) { - return ClaimRegisterResult.skip(); + return ClaimRegisterResult.factionVehicle(); } String vehicleTypeId = vehicle.getVehicleTypeId(); @@ -68,7 +73,7 @@ ClaimRegisterResult tryRegisterOnClaim(Player player, VehicleClaimTarget vehicle } CanBuildResult slotResult = VehicleSlotGuard.checkCanBuild( - player.getName(), vehicleTypeId, registry); + player.getUniqueId(), vehicleTypeId, registry); if (slotResult != CanBuildResult.OK) { return ClaimRegisterResult.failSlotLimit(slotResult); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryPersistence.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryPersistence.java index 7876a662..61b24e77 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryPersistence.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryPersistence.java @@ -66,14 +66,25 @@ public static void write(Path file, java.util.Collection re DurableJson.write(file, data); } - /** A confirmed personal owner becomes the payer; save before replacing the live row. */ - public static void personal(Path folder, PlayerVehicleRegistry registry, String vehicle, String type, UUID character) throws IOException { + /** + * A confirmed personal owner becomes the payer; save before replacing the live row. + * {@code account} is the account playing the character now, kept for slot limits. + */ + public static void personal(Path folder, PlayerVehicleRegistry registry, String vehicle, String type, UUID character, + UUID account) throws IOException { if (character == null) throw new IOException("Missing personal vehicle payer"); - PlayerVehicleRecord record = new PlayerVehicleRecord(character, vehicle, type, OwnershipMode.PERSONAL, null); + replace(folder, registry, vehicle, + new PlayerVehicleRecord(character, vehicle, type, OwnershipMode.PERSONAL, null, null, account)); + } + + /** Durably puts back a vehicle's earlier row, or removes it when there was none. */ + public static void replace(Path folder, PlayerVehicleRegistry registry, String vehicle, PlayerVehicleRecord record) + throws IOException { List records = new ArrayList<>(registry.getAll()); records.removeIf(value -> value.getVehicleUuid().equals(vehicle)); - records.add(record); + if (record != null) records.add(record); write(folder.resolve("Cache/vehicles_registry.json"), records); + registry.unregister(vehicle); registry.register(record); } @@ -84,6 +95,7 @@ static final class VehicleRecordData { String mode; String installationId; String factionId; + String accountUuid; static VehicleRecordData from(PlayerVehicleRecord record) { VehicleRecordData data = new VehicleRecordData(); @@ -93,6 +105,7 @@ static VehicleRecordData from(PlayerVehicleRecord record) { data.mode = record.getMode().name(); data.installationId = record.getInstallationId(); data.factionId = record.getFactionId(); + data.accountUuid = record.getAccountUuid() == null ? null : record.getAccountUuid().toString(); return data; } @@ -108,7 +121,8 @@ PlayerVehicleRecord toRecord() { vehicleTypeId, ownershipMode, installationId, - factionId); + factionId, + accountUuid == null ? null : UUID.fromString(accountUuid)); } catch (IllegalArgumentException e) { return null; } diff --git a/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java index 5dd0d850..499deb15 100644 --- a/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java +++ b/src/main/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreeze.java @@ -27,8 +27,24 @@ public static void register(Plugin plugin) { @Override public String freezeReason(UUID constructorUuid) { + return reason(constructorKey(constructorUuid)); + } + + /** + * VFBuilders 2.2.0 and later pass the constructor's character; a project from before + * then, or started without a current character, has none and falls back to the account. + */ + @Override + public String freezeReason(UUID constructorUuid, UUID constructorCharacterId) { + if (constructorCharacterId == null) { + return freezeReason(constructorUuid); + } + return reason(Members.key(constructorCharacterId)); + } + + private static String reason(String key) { Instant now = Instant.now(); - Instant until = PreparationFreeze.frozenUntil(constructorKey(constructorUuid), now); + Instant until = PreparationFreeze.frozenUntil(key, now); if (until == null) { return null; } diff --git a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java index ba47dc85..6133212e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/PluginLifecycleCoverageTest.java @@ -1270,7 +1270,8 @@ void waitingForIdentityLoadsTicksAndSavesNothingAndCommandsSaySo() throws Except assertFalse(net.tfminecraft.simplefactions.api.SimpleFactionsApi.ready()); assertFalse(plugin.isStarted()); verify(logger).severe(contains("Waiting for character identity")); - assertEquals(1, listeners.size(), "only the identity watcher listens"); + assertEquals( + 2, listeners.size(), "only the identity watcher and the VehicleFramework enable trigger listen"); assertEquals(1, domain.ui.repeatingTasks.size(), "only the identity poll ticks"); verify(dependency("db", Database.class), never()).loadFactions(); verify(dependency("factionManager", FactionManager.class), never()).prepare(); @@ -1524,4 +1525,319 @@ public PluginClassLoaderGroup getGroup() { @Override public void close() {} } + + // Vehicle stage + + private final List vehicleBackupsTaken = new ArrayList<>(); + + private net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi vehicleFramework( + boolean enabled, boolean probed) throws Exception { + var api = new net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi(); + field( + "vehicleApiProbe", + (java.util.function.Supplier) + () -> probed ? api : null); + field( + "vehicleBackups", + (java.util.function.Function< + Path, net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration.Backups>) + database -> + new net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration.Backups() { + @Override + public void vacuumInto(Path destination) throws IOException { + vehicleBackupsTaken.add(database + " -> " + destination.getFileName()); + Files.writeString(destination, "copy"); + } + + @Override + public boolean quickCheck(Path copy) { + return true; + } + }); + field( + "vehicleIdentity", + mock(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration.Identity.class)); + Plugin vf = mock(Plugin.class); + when(vf.getDataFolder()).thenReturn(disk.root.resolve("vf").toFile()); + when(plugins.getPlugin("VehicleFramework")).thenReturn(vf); + when(plugins.isPluginEnabled("VehicleFramework")).thenReturn(enabled); + return api; + } + + private boolean listening(Class type) { + return listeners.stream().anyMatch(type::isInstance); + } + + private org.bukkit.plugin.ServicesManager services() { + return domain.ui.server.getServicesManager(); + } + + @Test + void vehiclesStartRightAfterFactionsWhenVehicleFrameworkIsAlreadyEnabled() throws Exception { + var api = vehicleFramework(true, true); + UUID leader = UUID.randomUUID(); + Faction home = mock(Faction.class); + when(home.getId()).thenReturn("home"); + when(home.getLeader()).thenReturn(leader.toString()); + scoped(FactionManager.class).when(() -> FactionManager.getByString("home")).thenReturn(home); + FactionManager.factions.add(home); + SimpleFactions.getVehicleRegistry() + .register( + new PlayerVehicleRecord( + UUID.randomUUID(), "gun", "cannon", OwnershipMode.POOL, null, "home")); + api.stored("gun", "cannon", "player_OldLeader"); + + plugin.onEnable(); + + assertTrue(plugin.isStarted()); + assertEquals("char_" + leader, api.owner("gun")); + assertEquals( + List.of( + disk.root.resolve("vf/data/vehicles.db") + " -> vehicles-6a-premigration.db"), + vehicleBackupsTaken); + assertTrue( + Files.readString(disk.root.resolve("vehicles/owner-migration.json")) + .contains("\"completed\": true")); + assertTrue(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate.class)); + assertTrue(listeners.contains(dependency("vehicleSpawnListener", net.tfminecraft.simplefactions.vehicles.VehicleSpawnListener.class))); + assertFalse(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleClaimsOff.class)); + var authority = + services().load(net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority.class); + assertTrue(authority.ready()); + assertEquals( + 1, + services() + .getRegistrations(net.tfminecraft.vehicleframework.api.VehicleQuarantineSource.class) + .size()); + assertTrue(net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps.enabled()); + verify(logger).info(contains("Vehicle owner migration: 1 migrated, 0 unchanged, 0 quarantined, 0 gone")); + verify(logger, never()).info(contains("Vehicle ownership waits for VehicleFramework")); + + // The daily reconcile replaces the staff findings, and a failure there is only logged. + var quarantine = net.tfminecraft.simplefactions.vehicles.owners.VehicleQuarantine.current(); + api.stored("stray", "cart", "char_" + leader); + var characters = net.tfminecraft.simplefactions.guild.TestCharacters.install(); + UUID account = UUID.randomUUID(); + UUID kira = + net.tfminecraft.simplefactions.identity.Members.characterId( + characters.character(account, "Kira")); + api.stored("kiras", "cart", "char_" + kira); + plugin.reconcileVehicleUpkeep(); + PlayerVehicleRecord created = + SimpleFactions.getVehicleRegistry().getByVehicleUuid("kiras").orElseThrow(); + assertEquals(kira, created.getPlayerUuid()); + assertEquals(account, created.getAccountUuid()); + assertEquals( + "reconcile: owner is a faction leader but the vehicle has no registry row", + quarantine.findings().getFirst().reason()); + api.failure = new IllegalStateException("SQLite closed"); + plugin.reconcileVehicleUpkeep(); + verify(logger).log(eq(java.util.logging.Level.WARNING), contains("Daily vehicle upkeep reconcile"), any(IllegalStateException.class)); + assertEquals(1, quarantine.findings().size()); + + // VehicleFramework enabling again later changes nothing. + int registered = listeners.size(); + enableEvent("VehicleFramework"); + assertEquals(registered, listeners.size()); + + plugin.failFinancialState(new IOException("money")); + assertNull( + services().load(net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority.class)); + assertFalse(authority.ready()); + assertFalse(net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps.enabled()); + } + + @Test + void vehiclesStartWhenVehicleFrameworkEnablesAfterFactions() throws Exception { + vehicleFramework(false, true); + plugin.onEnable(); + assertTrue(plugin.isStarted()); + assertFalse(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate.class)); + verify(logger).info(contains("Vehicle ownership waits for VehicleFramework")); + + enableEvent("Unrelated"); + assertFalse(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate.class)); + when(plugins.isPluginEnabled("VehicleFramework")).thenReturn(true); + enableEvent("VehicleFramework"); + enableEvent("VehicleFramework"); + + assertEquals( + 1, + listeners.stream() + .filter(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate.class::isInstance) + .count()); + assertNotNull( + services().load(net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority.class)); + } + + @Test + void vehicleFrameworkEnablingInsideTheEnableWindowWaitsForFactionsToStart() throws Exception { + vehicleFramework(true, true); + identityReady(false); + plugin.onEnable(); + enableEvent("VehicleFramework"); + assertFalse(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate.class)); + + identityReady(true); + domain.ui.repeatingTasks.getFirst().run(); + + assertTrue(plugin.isStarted()); + assertTrue(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate.class)); + } + + @Test + void anOlderOrNamesModeVehicleFrameworkRunsVehiclesWithClaimsAndStampsOff() throws Exception { + vehicleFramework(true, false); + plugin.onEnable(); + + assertTrue(plugin.isStarted()); + assertTrue(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleClaimsOff.class)); + assertFalse(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate.class)); + assertFalse(listeners.contains(dependency("vehicleSpawnListener", net.tfminecraft.simplefactions.vehicles.VehicleSpawnListener.class))); + assertNull( + services().load(net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority.class)); + assertNull( + services().load(net.tfminecraft.vehicleframework.api.VehicleQuarantineSource.class)); + verify(logger).severe(contains("vehicle claims and leader stamps are off")); + scoped(net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.class) + .verify( + () -> + net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.startup( + any(), any())); + assertFalse(Files.exists(disk.root.resolve("vehicles/owner-migration.json"))); + } + + @Test + void namesModeStillListsHeldBackVehiclesForStaff() throws Exception { + var api = vehicleFramework(true, true); + api.characters = false; + plugin.onEnable(); + assertTrue(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleClaimsOff.class)); + assertNotNull( + services().load(net.tfminecraft.vehicleframework.api.VehicleQuarantineSource.class)); + assertNull( + services().load(net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority.class)); + } + + @Test + void aFailedPayerOrOwnerMigrationFailsTheFinancialStateBeforeAnyListener() throws Exception { + var api = vehicleFramework(true, true); + SimpleFactions.getVehicleRegistry() + .register( + new PlayerVehicleRecord( + UUID.randomUUID(), "gun", "cannon", OwnershipMode.POOL, null, "home")); + api.stored("gun", "cannon", "player_OldLeader"); + api.forced = net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result.SAVE_FAILED; + + plugin.onEnable(); + + assertFalse(plugin.isStarted()); + verify(plugins).disablePlugin(plugin); + assertFalse(listening(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate.class)); + assertFalse(listening(VehicleIntegrationListener.class)); + assertNull( + services().load(net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority.class)); + } + + @Test + void aFailedPayerMigrationFailsTheFinancialState() throws Exception { + vehicleFramework(true, true); + scoped(net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.class) + .when( + () -> + net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.startup( + any(), any())) + .thenThrow(new IOException("payer plan unreadable")); + + plugin.onEnable(); + + assertFalse(plugin.isStarted()); + verify(plugins).disablePlugin(plugin); + assertFalse(listening(VehicleIntegrationListener.class)); + } + + @Test + void aFailedReconcileStopsBeforeTheListenersAndTheAuthority() throws Exception { + var api = vehicleFramework(true, true); + disk.write("vehicles/owner-migration.json", "{\"entries\":[],\"quarantine\":[],\"completed\":true}"); + api.failure = new IllegalStateException("SQLite closed"); + + plugin.onEnable(); + + assertTrue(plugin.isStarted(), "Factions keep running"); + verify(logger) + .log( + eq(java.util.logging.Level.SEVERE), + contains("Vehicle reconcile failed"), + any(IllegalStateException.class)); + assertFalse(listening(VehicleIntegrationListener.class)); + assertNull( + services().load(net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority.class)); + assertNotNull( + services().load(net.tfminecraft.vehicleframework.api.VehicleQuarantineSource.class), + "Staff can still list held-back vehicles"); + assertTrue(vehicleBackupsTaken.isEmpty(), "A completed plan never backs up again"); + } + + @Test + void accountsAreCachedOnOlderPersonalRowsAtTheVehicleStage() throws Exception { + vehicleFramework(true, true); + UUID account = UUID.randomUUID(); + disk.write( + "identity/vehicle-payers.json", + "{\"rows\":[{\"vehicle\":\"cart\",\"type\":\"cart\",\"originalOwner\":\"player_Old\",\"account\":\"" + + account + + "\"}],\"completed\":true}"); + SimpleFactions.getVehicleRegistry() + .register(new PlayerVehicleRecord(null, "cart", "cart", OwnershipMode.PERSONAL, null)); + var characters = net.tfminecraft.simplefactions.guild.TestCharacters.install(); + UUID kiraAccount = UUID.randomUUID(); + UUID kira = + net.tfminecraft.simplefactions.identity.Members.characterId( + characters.character(kiraAccount, "Kira")); + SimpleFactions.getVehicleRegistry() + .register(new PlayerVehicleRecord(kira, "wagon", "cart", OwnershipMode.PERSONAL, null)); + scoped(net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.class) + .when( + () -> + net.tfminecraft.simplefactions.vehicles.registry.VehiclePayerMigration.accounts( + any())) + .thenCallRealMethod(); + + plugin.onEnable(); + + assertEquals( + account, + SimpleFactions.getVehicleRegistry().getByVehicleUuid("cart").orElseThrow().getAccountUuid()); + assertEquals( + kiraAccount, + SimpleFactions.getVehicleRegistry().getByVehicleUuid("wagon").orElseThrow().getAccountUuid()); + verify(logger).info(contains("Cached the account on 2 personal vehicle rows")); + } + + @Test + void disablingForgetsTheVehicleFrameworkLink() throws Exception { + vehicleFramework(true, true); + plugin.onEnable(); + assertNotNull(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites.api()); + plugin.onDisable(); + assertNull(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites.api()); + assertNull(net.tfminecraft.simplefactions.vehicles.owners.VehicleQuarantine.current()); + assertFalse(net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps.enabled()); + } + + @Test + void onlyCurrentFactionLeadersCountAsLeaders() { + UUID leader = UUID.randomUUID(); + Faction faction = mock(Faction.class); + when(faction.getLeader()).thenReturn(leader.toString()); + List previous = FactionManager.factions; + try { + FactionManager.factions = new ArrayList<>(List.of(faction)); + assertTrue(SimpleFactions.leadsAFaction(leader)); + assertFalse(SimpleFactions.leadsAFaction(UUID.randomUUID())); + } finally { + FactionManager.factions = previous; + } + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndexTest.java b/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndexTest.java index 90460143..e150698c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndexTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/identity/death/LockGuildIndexTest.java @@ -3,7 +3,10 @@ import static org.junit.jupiter.api.Assertions.*; import com.google.gson.JsonArray; +import com.google.gson.JsonNull; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -91,4 +94,42 @@ static JsonObject realm(JsonObject... guilds) { index.capture(journal(3)); assertTrue(index.get(character).isEmpty(), "a guildless capture deletes the entry"); } + + @Test void malformedOldSnapshotsGiveNoEntryWhileWellFormedSiblingsStillIndex() { + List malformed = new ArrayList<>(); + JsonObject nullGuilds = realm(); + nullGuilds.add("guilds", JsonNull.INSTANCE); + malformed.add(nullGuilds); + JsonObject objectGuilds = realm(); + objectGuilds.add("guilds", new JsonObject()); + malformed.add(objectGuilds); + JsonObject stringGuild = realm(guild(UUID.randomUUID(), key)); + stringGuild.getAsJsonArray("guilds").set(0, new JsonPrimitive("guild")); + malformed.add(stringGuild); + JsonObject nullMembers = guild(UUID.randomUUID(), key); + nullMembers.add("members", JsonNull.INSTANCE); + malformed.add(realm(nullMembers, guild(UUID.randomUUID(), key))); + JsonObject missingMembers = guild(UUID.randomUUID(), key); + missingMembers.remove("members"); + malformed.add(realm(missingMembers)); + JsonObject missingIncarnation = guild(UUID.randomUUID(), key); + missingIncarnation.remove("incarnation"); + malformed.add(realm(missingIncarnation, guild(UUID.randomUUID(), key))); + for (Object bad : new Object[] {JsonNull.INSTANCE, "", " ", "not-a-uuid", 7}) { + JsonObject guild = guild(UUID.randomUUID(), key); + if (bad instanceof String text) guild.addProperty("incarnation", text); + else if (bad instanceof Integer number) guild.addProperty("incarnation", number); + else guild.add("incarnation", JsonNull.INSTANCE); + malformed.add(realm(guild)); + } + UUID sibling = UUID.randomUUID(), siblingGuild = UUID.randomUUID(); + for (JsonObject snapshot : malformed) { + DeathJournal broken = journal(1, snapshot); + DeathJournal wellFormed = journal(1, realm(guild(siblingGuild, sibling.toString()))); + wellFormed.characterId = sibling; + assertDoesNotThrow(() -> index.rebuild(List.of(broken, wellFormed)), snapshot::toString); + assertTrue(index.get(character).isEmpty(), snapshot::toString); + assertEquals(Optional.of(siblingGuild), index.get(sibling), snapshot::toString); + } + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/installation/InstallationProtectionCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/installation/InstallationProtectionCoverageTest.java index 72e2aa0a..60c35c8d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/installation/InstallationProtectionCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/installation/InstallationProtectionCoverageTest.java @@ -480,7 +480,7 @@ void activeRaidStillBlocksBerthingWhenANeutralFactionReusesTheSourceId() { externalVehicle("personal-ship", new Location(fixture.ui.world, 8, 64, 0)); when(vehicle.getId()).thenReturn("ironclad"); OwnerData owner = new OwnerData(); - owner.setOwner("player_Visitor"); + owner.setOwner("char_" + java.util.UUID.randomUUID()); when(vehicle.getOwnerData()).thenReturn(owner); InstallationVehicleService service = new InstallationVehicleService(registry, new InstallationVehicleOwnerSync(registry)); @@ -519,11 +519,14 @@ void releaseValidationLocksOnlyTheActualRaidingFactionsVehicle() { assertEquals( FactionVehicleReleaseService.Status.INSTALLATION_LOCKED, releases - .evaluateGive(attacker, attacker.getLeader(), player.getName(), "raiding") + .evaluateGive(attacker, attacker.getLeader(), + new net.tfminecraft.tlibs.character.CharacterRef(player.getUniqueId(), java.util.UUID.randomUUID()), "raiding") .status()); assertEquals( FactionVehicleReleaseService.Status.OK, - releases.evaluateGive(neutral, neutral.getLeader(), player.getName(), "neutral").status()); + releases.evaluateGive(neutral, neutral.getLeader(), + new net.tfminecraft.tlibs.character.CharacterRef(player.getUniqueId(), java.util.UUID.randomUUID()), "neutral") + .status()); assertEquals( attacker.getId(), registry.getByVehicleUuid("raiding").orElseThrow().getFactionId()); assertEquals( diff --git a/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationLifecycleCoverageTest.java index b1b75f6f..54abbcb4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/installation/handler/InstallationLifecycleCoverageTest.java @@ -140,11 +140,10 @@ void transferPreservesBothSameNamedInstallationsAndTheirOwnVehicles() { OwnershipMode.INSTALLATION, "harbor", destination.getId())); - OwnerData incomingOwner = new OwnerData(); - incomingOwner.setOwner("player_Leader"); - ActiveVehicle incomingVehicle = mock(ActiveVehicle.class); - when(incomingVehicle.getOwnerData()).thenReturn(incomingOwner); - when(vehicles.get("incoming")).thenReturn(incomingVehicle); + var api = + net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi.install() + .stored("incoming", "ironclad", "char_" + UUID.randomUUID()) + .stored("existing", "ironclad", "char_" + UUID.randomUUID()); InstallationTransferService.transfer(home, destination, 42); @@ -168,7 +167,13 @@ void transferPreservesBothSameNamedInstallationsAndTheirOwnVehicles() { registry.getByVehicleUuid("incoming").orElseThrow().getInstallationId()); assertEquals("destination", registry.getByVehicleUuid("incoming").orElseThrow().getFactionId()); assertEquals("harbor", registry.getByVehicleUuid("existing").orElseThrow().getInstallationId()); - assertEquals("player_Recipient", incomingOwner.getOwner()); + // The vehicles move with the installation: stamped for the new holder's leader, stored or not. + assertEquals( + List.of( + "incoming " + + net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps.expectedStamp(destination) + + " TRANSFER"), + api.writes); } @Test @@ -633,19 +638,16 @@ void externalVehicleFailureDefersOnlyThatOwnerSyncWhileAllRegistryEntriesTransfe registry.register( new PlayerVehicleRecord( UUID.randomUUID(), id, "ironclad", OwnershipMode.INSTALLATION, "port", home.getId())); - when(vehicles.get("unavailable")).thenThrow(new IllegalStateException("external failure")); - OwnerData owner = new OwnerData(); - owner.setOwner("player_Leader"); - ActiveVehicle loaded = mock(ActiveVehicle.class); - when(loaded.getOwnerData()).thenReturn(owner); - when(vehicles.get("loaded")).thenReturn(loaded); + var api = + net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi.install() + .live("loaded", "ironclad", "char_" + UUID.randomUUID()); Logger logger = mock(Logger.class); when(fixture.ui.plugin.getLogger()).thenReturn(logger); InstallationTransferService.transfer(home, destination, 42); for (String id : List.of("unavailable", "loaded")) assertEquals(destination.getId(), registry.getByVehicleUuid(id).orElseThrow().getFactionId()); - assertEquals("player_Recipient", owner.getOwner()); - verify(logger).warning(contains("unavailable")); + assertEquals("char_none", api.owner("loaded")); + verify(logger).warning(contains("Could not stamp vehicle unavailable for faction destination")); verify(fixture.ui.plugin).saveVehicleRegistry(); } @@ -663,11 +665,9 @@ void failedRegistrySaveReportsTheTransferredInstallationWithoutLosingLiveOwnersh OwnershipMode.INSTALLATION, "port", home.getId())); - OwnerData owner = new OwnerData(); - owner.setOwner("player_Leader"); - ActiveVehicle loaded = mock(ActiveVehicle.class); - when(loaded.getOwnerData()).thenReturn(owner); - when(vehicles.get("ship")).thenReturn(loaded); + var api = + net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi.install() + .live("ship", "ironclad", "char_" + UUID.randomUUID()); Logger logger = mock(Logger.class); when(fixture.ui.plugin.getLogger()).thenReturn(logger); if (exceptional) @@ -681,7 +681,8 @@ void failedRegistrySaveReportsTheTransferredInstallationWithoutLosingLiveOwnersh assertTrue(home.getInstallationHandler().getAll().isEmpty()); assertEquals( destination.getId(), registry.getByVehicleUuid("ship").orElseThrow().getFactionId()); - assertEquals("player_Recipient", owner.getOwner()); + // A failed save still stamps the vehicles; a save that throws stops before them. + assertEquals(exceptional ? List.of() : List.of("ship char_none TRANSFER"), api.writes); verify(fixture.ui.plugin).saveVehicleRegistry(); verify(logger) .warning( diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/RequestLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/RequestLifecycleCoverageTest.java index 88411433..9cdd329d 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/RequestLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/RequestLifecycleCoverageTest.java @@ -60,7 +60,6 @@ void acceptingAnExpiredVehicleOfferNeverInvokesTheTransfer() throws Exception { UUID.randomUUID().toString(), "wagon", alice.getUniqueId(), - "Alice", bob.getUniqueId()); expire(request); RequestManager.addRequest(alice, bob, request); @@ -82,7 +81,6 @@ void scheduledExpiryNotifiesTheCorrectVehicleServicesAndKeepsLiveRequests() thro UUID.randomUUID().toString(), "wagon", alice.getUniqueId(), - "Alice", bob.getUniqueId()); var gift = new VehicleGiveConsentRequest( @@ -92,7 +90,8 @@ void scheduledExpiryNotifiesTheCorrectVehicleServicesAndKeepsLiveRequests() thro "wagon", alice.getUniqueId(), bob.getUniqueId(), - "Bob"); + new net.tfminecraft.tlibs.character.CharacterRef(bob.getUniqueId(), UUID.randomUUID()), + new net.tfminecraft.tlibs.character.CharacterRef(alice.getUniqueId(), UUID.randomUUID())); expire(handover); expire(gift); RequestManager.addRequest(alice, bob, handover); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/RequestManagerDeclineTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/RequestManagerDeclineTest.java index 40ba1b96..eddeb64a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/RequestManagerDeclineTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/RequestManagerDeclineTest.java @@ -199,7 +199,9 @@ void timeout_vehicleConsent_stillNotifies() throws Exception { Player owner = player("Owner"); Guild guild = mock(Guild.class); VehicleTransferConsentRequest request = new VehicleTransferConsentRequest( - guild, "fort", "Fort", UUID.randomUUID().toString(), "wagon", UUID.randomUUID(), UUID.randomUUID()); + guild, "fort", "Fort", UUID.randomUUID().toString(), "wagon", + new net.tfminecraft.tlibs.character.CharacterRef(owner.getUniqueId() == null ? UUID.randomUUID() : owner.getUniqueId(), UUID.randomUUID()), + new net.tfminecraft.tlibs.character.CharacterRef(UUID.randomUUID(), UUID.randomUUID()), false); forceTimeout(request); RequestManager.addRequest(owner, owner, request); SimpleFactions plugin = mock(SimpleFactions.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InstallationMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InstallationMenusCoverageTest.java index 1f4dd11a..94d2df54 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InstallationMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/InstallationMenusCoverageTest.java @@ -83,7 +83,7 @@ void setup() { scope(VehicleFramework.class).when(VehicleFramework::getVehicleManager).thenReturn(vehicles); unberth = mock(InstallationVehicleUnberthService.class); when(fixture.ui.plugin.getInstallationVehicleUnberthService()).thenReturn(unberth); - when(unberth.unberth(any(), anyString(), any(), anyString())) + when(unberth.unberth(any(), any(), any(), anyString())) .thenReturn( new InstallationVehicleUnberthService.UnberthOutcome( InstallationVehicleUnberthService.UnberthResult.NOT_BERTHED, null, null)); @@ -109,6 +109,10 @@ void close() { } } + private net.tfminecraft.tlibs.character.CharacterRef leaderRef() { + return net.tfminecraft.simplefactions.identity.Members.session(leader).orElseThrow().ref(); + } + @Test void deconstructButtonOpensTheConfirmationInsteadOfTreatingItsIdAsAVehicle() { Installation fort = installation("fort", InstallationKind.FORT); @@ -398,7 +402,7 @@ void takingABerthedVehicleReflectsTheTransferResultAndConservesTheRegistry( PlayerVehicleRecord original = berth("tank-1", "heavy_tank", fort); InstallationVehicleUnberthService.UnberthOutcome outcome = new InstallationVehicleUnberthService.UnberthOutcome(result, null, "heavy_tank"); - when(unberth.unberth(faction, "Leader", fort, "tank-1")) + when(unberth.unberth(faction, leaderRef(), fort, "tank-1")) .thenAnswer( call -> { if (result == InstallationVehicleUnberthService.UnberthResult.OK) @@ -414,7 +418,7 @@ void takingABerthedVehicleReflectsTheTransferResultAndConservesTheRegistry( view.installationDetailView(leader, faction, "fort"); Inventory old = top(); view.click(fixture.ui.click(leader, 0), old, leader); - verify(unberth).unberth(faction, "Leader", fort, "tank-1"); + verify(unberth).unberth(faction, leaderRef(), fort, "tank-1"); verify(leader).sendMessage(InstallationVehicleUnberthService.messageFor(outcome)); assertEquals(1, registry.getAll().size()); if (result == InstallationVehicleUnberthService.UnberthResult.OK) { diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequestTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequestTest.java index aa5faae1..40bc59b3 100644 --- a/src/test/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequestTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/objects/request/VehicleTransferConsentRequestTest.java @@ -45,8 +45,9 @@ void isNotExpiredImmediatelyAfterCreation() { "Harbour", "vehicle-1", "ironclad", - UUID.randomUUID(), - UUID.randomUUID()); + new net.tfminecraft.tlibs.character.CharacterRef(UUID.randomUUID(), UUID.randomUUID()), + new net.tfminecraft.tlibs.character.CharacterRef(UUID.randomUUID(), UUID.randomUUID()), + false); assertFalse(request.timedOut()); } diff --git a/src/test/java/net/tfminecraft/simplefactions/testsupport/VehicleOwnersReset.java b/src/test/java/net/tfminecraft/simplefactions/testsupport/VehicleOwnersReset.java new file mode 100644 index 00000000..696a5493 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/testsupport/VehicleOwnersReset.java @@ -0,0 +1,18 @@ +package net.tfminecraft.simplefactions.testsupport; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleQuarantine; + +/** Vehicle ownership keeps its VehicleFramework link in statics; no test may leave one behind. */ +public final class VehicleOwnersReset implements AfterEachCallback { + @Override + public void afterEach(ExtensionContext context) { + VehicleOwnerWrites.use(null); + LeaderStamps.enable(false); + VehicleQuarantine.use(null); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveServiceTest.java index a6da8dd4..f3965fea 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveServiceTest.java @@ -29,7 +29,7 @@ import net.tfminecraft.simplefactions.managers.RequestManager; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.objects.request.VehicleGiveConsentRequest; -import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; @@ -67,7 +67,7 @@ void setUp() throws IOException { aircraft: {} """); VehiclesConfigLoader.load(vehiclesYaml.toFile()); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory()); + FakeOwnerApi.install(); previousFactions = FactionManager.factions; FactionManager.factions = new ArrayList<>(); @@ -75,7 +75,7 @@ void setUp() throws IOException { registry = new PlayerVehicleRegistry(); assigned = new ArrayList<>(); FactionVehicleReleaseService releaseService = new FactionVehicleReleaseService(registry, (uuid, name) -> { - assigned.add(uuid + ":" + name); + assigned.add(uuid + ":" + net.tfminecraft.simplefactions.identity.Members.key(name)); return true; }); giveService = new FactionVehicleGiveService(releaseService); @@ -96,7 +96,6 @@ void setUp() throws IOException { void tearDown() throws IOException { RequestManager.remove(recipient); FactionManager.factions = previousFactions; - VehicleOwnershipQueries.setSourceForTests(null); if (tempDir != null) { Files.walk(tempDir) .sorted(java.util.Comparator.reverseOrder()) @@ -137,7 +136,7 @@ void offer_isNotAnnouncedWhenTheRecipientAlreadyHasARequest() { List leaderMessages = messagesOf(leader); List recipientMessages = messagesOf(recipient); VehicleGiveConsentRequest pending = new VehicleGiveConsentRequest( - null, "red", "car-1", "coal_car", recipient.getUniqueId(), leaderUuid, "Leader"); + null, "red", "car-1", "coal_car", recipient.getUniqueId(), leaderUuid, ref(leaderUuid, "Leader"), ref(recipientUuid, "Bob")); RequestManager.addRequest(leader, recipient, pending); giveService.offer(leader, recipient, faction, "car-1", "coal_car"); @@ -153,7 +152,7 @@ void accept_refusesAnExpiredRequest() { UUID.randomUUID(), "car-1", "coal_car", OwnershipMode.POOL, null, "red")); List recipientMessages = messagesOf(recipient); VehicleGiveConsentRequest expired = org.mockito.Mockito.spy(new VehicleGiveConsentRequest( - null, "red", "car-1", "coal_car", recipient.getUniqueId(), leaderUuid, "Leader")); + null, "red", "car-1", "coal_car", recipient.getUniqueId(), leaderUuid, ref(leaderUuid, "Leader"), ref(recipientUuid, "Bob"))); org.mockito.Mockito.doReturn(true).when(expired).timedOut(); try (MockedStatic bukkit = mockStatic(Bukkit.class); @@ -176,8 +175,8 @@ void accept_refusesAnExpiredRequest() { void accept_refusesWhenTheRecipientHasNoRoom() { registry.register(new PlayerVehicleRecord( UUID.randomUUID(), "car-1", "coal_car", OwnershipMode.POOL, null, "red")); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("owned", "coal_car", "player_Bob")); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "owned", "coal_car", OwnershipMode.PERSONAL, null, + null, recipientUuid)); List recipientMessages = messagesOf(recipient); try (MockedStatic bukkit = mockStatic(Bukkit.class); @@ -196,6 +195,39 @@ void accept_refusesWhenTheRecipientHasNoRoom() { } } + private static net.tfminecraft.tlibs.character.CharacterRef ref(UUID account, String name) { + return new net.tfminecraft.tlibs.character.CharacterRef(account, + net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf(name)); + } + + @Test + void accept_refusesWhenTheRecipientNowPlaysAnotherCharacter() { + registry.register(new PlayerVehicleRecord( + UUID.randomUUID(), "car-1", "coal_car", OwnershipMode.POOL, null, "red")); + List recipientMessages = messagesOf(recipient); + VehicleGiveConsentRequest offered = new VehicleGiveConsentRequest( + null, "red", "car-1", "coal_car", recipientUuid, leaderUuid, ref(leaderUuid, "Leader"), + ref(recipientUuid, "Bob's other character")); + RequestManager.addRequest(leader, recipient, offered); + + giveService.acceptRequest(recipient); + + assertTrue(assigned.isEmpty()); + assertEquals(List.of("§cThis request belongs to another character."), recipientMessages); + assertEquals(ref(leaderUuid, "Leader"), offered.getLeaderCharacter()); + } + + @Test + void offer_needsBothPlayersToHaveACharacter() { + Player nobody = player(UUID.randomUUID(), null); + List leaderMessages = messagesOf(leader); + + giveService.offer(leader, nobody, faction, "car-1", "coal_car"); + + assertFalse(RequestManager.hasRequest(nobody)); + assertEquals(List.of(FactionVehicleReleaseMessages.ownershipUnavailable()), leaderMessages); + } + private static Player player(UUID uuid, String name) { Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(uuid); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseServiceTest.java index c0e6d967..e011b976 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseServiceTest.java @@ -27,11 +27,13 @@ import net.tfminecraft.simplefactions.vehicles.berth.FactionVehicleReleaseService.Outcome; import net.tfminecraft.simplefactions.vehicles.berth.FactionVehicleReleaseService.Status; import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard.CanBuildResult; -import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.testsupport.NameKeyedMembers; +import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; import net.tfminecraft.simplefactions.war.battle.engine.core.BattleManager; import net.tfminecraft.simplefactions.war.core.War; @@ -40,6 +42,8 @@ import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; class FactionVehicleReleaseServiceTest { + private static final CharacterRef LEADER = new CharacterRef(UUID.randomUUID(), NameKeyedMembers.characterOf("Leader")); + private static final CharacterRef BOB = new CharacterRef(UUID.randomUUID(), NameKeyedMembers.characterOf("Bob")); private Path tempDir; private org.mockito.MockedStatic online; private PlayerVehicleRegistry registry; @@ -83,12 +87,12 @@ void setUp() throws IOException { aircraft: {} """); VehiclesConfigLoader.load(vehiclesYaml.toFile()); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory()); + FakeOwnerApi.install(); registry = new PlayerVehicleRegistry(); assigned = new ArrayList<>(); service = new FactionVehicleReleaseService(registry, (uuid, name) -> { - assigned.add(uuid + ":" + name); + assigned.add(uuid + ":" + Members.key(name)); return true; }); @@ -113,7 +117,6 @@ void setUp() throws IOException { void tearDown() throws IOException { online.close(); net.tfminecraft.simplefactions.managers.FactionManager.factions = previousFactions; - VehicleOwnershipQueries.setSourceForTests(null); BattleManager.resetForTests(); WarManager.get().clear(); WarManager.get().addAll(savedWars); @@ -134,7 +137,7 @@ void take_poolVehicleBecomesLeadersPersonalVehicle() { SimpleFactions plugin = savingPlugin(); try (MockedStatic sf = mockStatic(SimpleFactions.class)) { sf.when(SimpleFactions::getInstance).thenReturn(plugin); - Outcome outcome = service.take(faction, "Leader", "gun-1"); + Outcome outcome = service.take(faction, LEADER, "gun-1"); assertEquals(Status.OK, outcome.status()); assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("gun-1").orElseThrow().getMode()); assertEquals(List.of("gun-1:Leader"), assigned); @@ -148,12 +151,12 @@ void take_failedSaveKeepsTheFactionVehicleAndItsOwner() { FactionVehicleReleaseService failing = new FactionVehicleReleaseService( registry, (uuid, name) -> { - assigned.add(uuid + ":" + name); + assigned.add(uuid + ":" + Members.key(name)); return true; }, () -> false); - Outcome outcome = failing.take(faction, "Leader", "gun-1"); + Outcome outcome = failing.take(faction, LEADER, "gun-1"); assertEquals(Status.SAVE_FAILED, outcome.status()); assertTrue(registry.getByVehicleUuid("gun-1").isPresent()); @@ -166,7 +169,7 @@ void take_failedOwnerChangeRestoresTheFactionRecord() { FactionVehicleReleaseService unassignable = new FactionVehicleReleaseService(registry, (uuid, name) -> false, () -> true); - Outcome outcome = unassignable.take(faction, "Leader", "gun-1"); + Outcome outcome = unassignable.take(faction, LEADER, "gun-1"); assertEquals(Status.OWNERSHIP_UNAVAILABLE, outcome.status()); assertEquals(OwnershipMode.POOL, registry.getByVehicleUuid("gun-1").orElseThrow().getMode()); @@ -179,7 +182,7 @@ void take_failedRollbackSaveReportsSaveFailed() { FactionVehicleReleaseService service = new FactionVehicleReleaseService( registry, (uuid, name) -> false, () -> saves.incrementAndGet() == 1); - Outcome outcome = service.take(faction, "Leader", "gun-1"); + Outcome outcome = service.take(faction, LEADER, "gun-1"); assertEquals(Status.SAVE_FAILED, outcome.status()); assertEquals(2, saves.get()); @@ -196,7 +199,7 @@ void take_refusesAnotherFactionsVehicleAtASameNamedInstallation() { registry.register(new PlayerVehicleRecord( UUID.randomUUID(), "ship-1", "ironclad", OwnershipMode.INSTALLATION, "port-1", "blue")); - Outcome outcome = service.take(faction, "Leader", "ship-1"); + Outcome outcome = service.take(faction, LEADER, "ship-1"); assertEquals(Status.NOT_FACTION_VEHICLE, outcome.status()); assertTrue(registry.getByVehicleUuid("ship-1").isPresent()); @@ -211,7 +214,7 @@ void take_installationVehicleBecomesLeadersPersonalVehicle() { try (MockedStatic sf = mockStatic(SimpleFactions.class)) { sf.when(SimpleFactions::getInstance).thenReturn(savingPlugin()); - Outcome outcome = service.take(faction, "Leader", "ship-1"); + Outcome outcome = service.take(faction, LEADER, "ship-1"); assertEquals(Status.OK, outcome.status()); assertEquals(List.of("ship-1:Leader"), assigned); assertFalse(registry.isBerthed("ship-1")); @@ -221,10 +224,9 @@ void take_installationVehicleBecomesLeadersPersonalVehicle() { @Test void take_refusesWhenPersonalLimitIsFull() { registry.register(pool("car-1", "coal_car")); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("owned", "ironclad", "player_Leader")); + own("owned", "ironclad", LEADER); - Outcome outcome = service.take(faction, "Leader", "car-1"); + Outcome outcome = service.take(faction, LEADER, "car-1"); assertEquals(Status.NO_PERSONAL_ROOM, outcome.status()); assertEquals(CanBuildResult.TOTAL_LIMIT, outcome.slotFailure()); @@ -238,10 +240,9 @@ void take_refusesPerTypeLimit() { when(handler.getById("port-1")).thenReturn( new Installation("port-1", "Harbour", InstallationKind.PORT, 1, 0, 0, 0L)); registry.register(berthed("ship-1", "ironclad", "port-1")); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("owned", "ironclad", "player_Leader")); + own("owned", "ironclad", LEADER); - Outcome outcome = service.take(faction, "Leader", "ship-1"); + Outcome outcome = service.take(faction, LEADER, "ship-1"); assertEquals(Status.NO_PERSONAL_ROOM, outcome.status()); assertEquals(CanBuildResult.PER_TYPE_LIMIT, outcome.slotFailure()); @@ -252,7 +253,7 @@ void take_refusesDuringStartedCampaignBattle() { registry.register(pool("car-1", "coal_car")); startBattle(true, false); - Outcome outcome = service.take(faction, "Leader", "car-1"); + Outcome outcome = service.take(faction, LEADER, "car-1"); assertEquals(Status.IN_BATTLE, outcome.status()); assertTrue(registry.getByVehicleUuid("car-1").isPresent()); @@ -264,7 +265,7 @@ void take_refusesDuringStartedCampaignRaid() { registry.register(pool("car-1", "coal_car")); startBattle(true, true); - assertEquals(Status.IN_BATTLE, service.take(faction, "Leader", "car-1").status()); + assertEquals(Status.IN_BATTLE, service.take(faction, LEADER, "car-1").status()); } @Test @@ -274,7 +275,7 @@ void take_allowsScheduledBattleThatHasNotStarted() { try (MockedStatic sf = mockStatic(SimpleFactions.class)) { sf.when(SimpleFactions::getInstance).thenReturn(savingPlugin()); - assertEquals(Status.OK, service.take(faction, "Leader", "car-1").status()); + assertEquals(Status.OK, service.take(faction, LEADER, "car-1").status()); } } @@ -288,7 +289,7 @@ void take_refusesInstallationLock() { try (MockedStatic lock = mockStatic(VehicleInstallationLockService.class)) { lock.when(() -> VehicleInstallationLockService.isInstallationLocked(org.mockito.ArgumentMatchers.same(installation), org.mockito.ArgumentMatchers.any())) .thenReturn(true); - Outcome outcome = service.take(faction, "Leader", "ship-1"); + Outcome outcome = service.take(faction, LEADER, "ship-1"); assertEquals(Status.INSTALLATION_LOCKED, outcome.status()); assertEquals( VehicleInstallationLockService.UNBERTH_BLOCKED, @@ -302,7 +303,7 @@ void take_refusesAnotherFactionsPoolVehicle() { registry.register(new PlayerVehicleRecord( UUID.randomUUID(), "car-1", "coal_car", OwnershipMode.POOL, null, "blue")); - assertEquals(Status.NOT_FACTION_VEHICLE, service.take(faction, "Leader", "car-1").status()); + assertEquals(Status.NOT_FACTION_VEHICLE, service.take(faction, LEADER, "car-1").status()); } @Test @@ -311,7 +312,7 @@ void give_assignsRecipientWhenTheirLimitsAllow() { try (MockedStatic sf = mockStatic(SimpleFactions.class)) { sf.when(SimpleFactions::getInstance).thenReturn(savingPlugin()); - Outcome outcome = service.give(faction, "Leader", "Bob", "car-1"); + Outcome outcome = service.give(faction, "Leader", BOB, "car-1"); assertEquals(Status.OK, outcome.status()); assertEquals(List.of("car-1:Bob"), assigned); assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("car-1").orElseThrow().getMode()); @@ -321,10 +322,9 @@ void give_assignsRecipientWhenTheirLimitsAllow() { @Test void give_refusesWhenRecipientHasNoRoom() { registry.register(pool("car-1", "coal_car")); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("owned", "ironclad", "player_Bob")); + own("owned", "ironclad", BOB); - Outcome outcome = service.evaluateGive(faction, "Leader", "Bob", "car-1"); + Outcome outcome = service.evaluateGive(faction, "Leader", BOB, "car-1"); assertEquals(Status.NO_PERSONAL_ROOM, outcome.status()); assertTrue(registry.getByVehicleUuid("car-1").isPresent()); @@ -341,7 +341,7 @@ void give_refusesInstallationLockWithoutReleasing() { try (MockedStatic lock = mockStatic(VehicleInstallationLockService.class)) { lock.when(() -> VehicleInstallationLockService.isInstallationLocked(org.mockito.ArgumentMatchers.same(installation), org.mockito.ArgumentMatchers.any())) .thenReturn(true); - Outcome outcome = service.evaluateGive(faction, "Leader", "Bob", "ship-1"); + Outcome outcome = service.evaluateGive(faction, "Leader", BOB, "ship-1"); assertEquals(Status.INSTALLATION_LOCKED, outcome.status()); assertTrue(FactionVehicleReleaseMessages.forGive(outcome, "Bob").contains("give")); } @@ -357,6 +357,39 @@ private void startBattle(boolean started, boolean raid) { BattleManager.addBattle(battle); } + private void own(String vehicle, String type, CharacterRef owner) { + registry.register(new PlayerVehicleRecord(owner.characterId(), vehicle, type, OwnershipMode.PERSONAL, null, null, + owner.ownerId())); + } + + @Test + void releasedVehiclesCacheTheRecipientsAccountAndNeedARecipient() { + registry.register(pool("car-1", "coal_car")); + Outcome missing = service.give(faction, "Leader", null, "car-1"); + assertEquals(Status.OWNERSHIP_UNAVAILABLE, missing.status()); + assertTrue(assigned.isEmpty()); + + try (MockedStatic sf = mockStatic(SimpleFactions.class)) { + sf.when(SimpleFactions::getInstance).thenReturn(savingPlugin()); + assertEquals(Status.OK, service.give(faction, "Leader", BOB, "car-1").status()); + } + PlayerVehicleRecord row = registry.getByVehicleUuid("car-1").orElseThrow(); + assertEquals(BOB.characterId(), row.getPlayerUuid()); + assertEquals(BOB.ownerId(), row.getAccountUuid()); + } + + @Test + void theFrameworkOwnerIsTheRecipientsCharacterWithAnEmptyCrew() { + FakeOwnerApi api = FakeOwnerApi.install().stored("car-1", "coal_car", "char_none", "char_x;y;Crew"); + + assertTrue(FactionVehicleReleaseService.assignFrameworkOwner("car-1", BOB.characterId())); + assertEquals("char_" + BOB.characterId(), api.owner("car-1")); + assertEquals(List.of(), api.vehicles.get("car-1").current().orElseThrow().whitelist()); + assertEquals(List.of("car-1 char_" + BOB.characterId() + " RELEASE"), api.writes); + assertFalse(FactionVehicleReleaseService.assignFrameworkOwner("car-1", null)); + assertFalse(FactionVehicleReleaseService.assignFrameworkOwner("gone", BOB.characterId())); + } + private static PlayerVehicleRecord pool(String uuid, String typeId) { return new PlayerVehicleRecord(UUID.randomUUID(), uuid, typeId, OwnershipMode.POOL, null, "red"); } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSyncTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSyncTest.java index 62a7a0e7..54d51959 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSyncTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleOwnerSyncTest.java @@ -1,33 +1,42 @@ package net.tfminecraft.simplefactions.vehicles.berth; - -import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; -import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; -import net.tfminecraft.simplefactions.vehicles.berth.InstallationVehicleOwnerSync; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.logging.Logger; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import net.tfminecraft.simplefactions.managers.FactionManager; -import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.installation.Installation; import net.tfminecraft.simplefactions.installation.InstallationKind; import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; +import net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; class InstallationVehicleOwnerSyncTest { private List previousFactions; private PlayerVehicleRegistry registry; private InstallationVehicleOwnerSync ownerSync; + private FakeOwnerApi api; + private final UUID carol = UUID.randomUUID(); @BeforeEach void setUp() { @@ -35,6 +44,8 @@ void setUp() { FactionManager.factions = new ArrayList<>(); registry = new PlayerVehicleRegistry(); ownerSync = new InstallationVehicleOwnerSync(registry); + api = FakeOwnerApi.install(); + LeaderStamps.enable(true); } @AfterEach @@ -42,128 +53,104 @@ void tearDown() { FactionManager.factions = previousFactions; } - @Test - void expectedOwner_usesFactionLeader() { + private Faction faction(String id, String leader) { Faction faction = mock(Faction.class); - when(faction.getLeader()).thenReturn("Alice"); - - assertEquals("player_Alice", InstallationVehicleOwnerSync.expectedOwner(faction)); + when(faction.getId()).thenReturn(id); + when(faction.getLeader()).thenReturn(leader); + FactionManager.factions.add(faction); + return faction; } - @Test - void applyLeaderOwner_setsPlayerPrefix() { - Faction faction = mock(Faction.class); - when(faction.getLeader()).thenReturn("Bob"); - OwnerData ownerData = new OwnerData(); - - ownerSync.applyLeaderOwner(ownerData, faction); - - assertEquals("player_Bob", ownerData.getOwner()); + private static ActiveVehicle loaded(String uuid, String owner) { + ActiveVehicle vehicle = mock(ActiveVehicle.class); + OwnerData data = new OwnerData(); + data.setOwner(owner); + when(vehicle.getUUID()).thenReturn(uuid); + when(vehicle.getOwnerData()).thenReturn(data); + return vehicle; } @Test - void syncIfBerthed_updatesStaleOwnerForInstallationRecord() { - Installation installation = new Installation( - "port-1", - "Harbour", - InstallationKind.PORT, - 1, - 0, - 0, - 0L); - InstallationHandler handler = mock(InstallationHandler.class); - when(handler.getById("port-1")).thenReturn(installation); + void aVehicleJoiningAFactionIsStampedForItsLeaderAndLosesItsCrew() { + api.live("ship", "ironclad", "char_" + UUID.randomUUID(), "char_" + UUID.randomUUID() + ";x;Crew"); - Faction faction = mock(Faction.class); - when(faction.getLeader()).thenReturn("Carol"); - when(faction.getInstallationHandler()).thenReturn(handler); - FactionManager.factions.add(faction); - - registry.register(new PlayerVehicleRecord( - UUID.randomUUID(), - "vehicle-1", - "ironclad", - OwnershipMode.INSTALLATION, - "port-1")); - - OwnerData ownerData = new OwnerData(); - ownerData.setOwner("player_Alice"); + assertEquals(Result.OK, InstallationVehicleOwnerSync.stampForFaction("ship", faction("red", carol.toString()))); - ownerSync.syncIfBerthed("vehicle-1", ownerData); - - assertEquals("player_Carol", ownerData.getOwner()); + assertEquals("char_" + carol, api.owner("ship")); + assertEquals(List.of(), api.vehicles.get("ship").current().orElseThrow().whitelist()); + assertEquals(List.of("ship char_" + carol + " TRANSFER"), api.writes); } @Test - void syncIfBerthed_updatesStaleOwnerForPoolRecord() { - Faction faction = mock(Faction.class); - when(faction.getId()).thenReturn("red"); - when(faction.getLeader()).thenReturn("Dana"); - FactionManager.factions.add(faction); - - registry.register(new PlayerVehicleRecord( - UUID.randomUUID(), - "vehicle-pool", - "field_artillery", - OwnershipMode.POOL, - null, - "red")); + void factionsWithoutALeaderCharacterStampNobody() { + api.live("a", "cart", "none").live("b", "cart", "none").live("c", "cart", "none"); - OwnerData ownerData = new OwnerData(); - ownerData.setOwner("player_Alice"); + InstallationVehicleOwnerSync.stampForFaction("a", faction("red", "dummy_2")); + InstallationVehicleOwnerSync.stampForFaction("b", faction("blue", "")); + InstallationVehicleOwnerSync.stampForFaction("c", null); - ownerSync.syncIfBerthed("vehicle-pool", ownerData); - - assertEquals("player_Dana", ownerData.getOwner()); + assertEquals(List.of("char_none", "char_none", "char_none"), List.of(api.owner("a"), api.owner("b"), + api.owner("c"))); } @Test - void syncIfBerthed_migratesLegacyFactionOwner() { - Installation installation = new Installation( - "port-1", - "Harbour", - InstallationKind.PORT, - 1, - 0, - 0, - 0L); + void aSpawnedInstallationVehicleWithAStaleStampIsRestamped() { + Installation port = new Installation("port-1", "Harbour", InstallationKind.PORT, 1, 0, 0, 0L); + Faction faction = faction("red", carol.toString()); InstallationHandler handler = mock(InstallationHandler.class); - when(handler.getById("port-1")).thenReturn(installation); - - Faction faction = mock(Faction.class); - when(faction.getLeader()).thenReturn("Carol"); + when(handler.getById("port-1")).thenReturn(port); when(faction.getInstallationHandler()).thenReturn(handler); - FactionManager.factions.add(faction); - - registry.register(new PlayerVehicleRecord( - UUID.randomUUID(), - "vehicle-1", - "ironclad", - OwnershipMode.INSTALLATION, - "port-1")); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "ship", "ironclad", OwnershipMode.INSTALLATION, + "port-1", "red")); + api.live("ship", "ironclad", "player_Carol"); - OwnerData ownerData = new OwnerData(); - ownerData.setOwner("faction_Brume"); + ownerSync.syncIfBerthed(loaded("ship", "player_Carol")); - ownerSync.syncIfBerthed("vehicle-1", ownerData); + assertEquals(List.of("ship char_" + carol + " RESTAMP"), api.writes); + } - assertEquals("player_Carol", ownerData.getOwner()); + @Test + void aSpawnedPoolVehicleAlreadyStampedOrAPersonalOneIsLeftAlone() { + faction("red", carol.toString()); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "gun", "cannon", OwnershipMode.POOL, null, "red")); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "cart", "cart", OwnershipMode.PERSONAL, null)); + + ownerSync.syncIfBerthed(loaded("gun", "char_" + carol)); + ownerSync.syncIfBerthed(loaded("cart", "char_" + UUID.randomUUID())); + ownerSync.syncIfBerthed(loaded("unregistered", "none")); + ownerSync.syncIfBerthed(null); + ownerSync.syncIfBerthed(loaded(null, "none")); + + assertEquals(List.of(), api.writes); } @Test - void syncIfBerthed_noOpForPersonalRecord() { - registry.register(new PlayerVehicleRecord( - UUID.randomUUID(), - "vehicle-1", - "ironclad", - OwnershipMode.PERSONAL, - null)); + void spawnsBeforeTheVehicleStageFinishesAreLeftToTheBootReconcile() { + faction("red", carol.toString()); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "gun", "cannon", OwnershipMode.POOL, null, "red")); + LeaderStamps.enable(false); - OwnerData ownerData = new OwnerData(); - ownerData.setOwner("player_Alice"); + ownerSync.syncIfBerthed(loaded("gun", "player_Carol")); - ownerSync.syncIfBerthed("vehicle-1", ownerData); + assertEquals(List.of(), api.writes); + } - assertEquals("player_Alice", ownerData.getOwner()); + @Test + void aFailedSpawnRestampIsLogged() { + faction("red", carol.toString()); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "gun", "cannon", OwnershipMode.POOL, null, "red")); + api.live("gun", "cannon", "player_Carol"); + api.forced = Result.SAVE_FAILED; + SimpleFactions plugin = mock(SimpleFactions.class); + Logger logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + + ownerSync.syncIfBerthed(loaded("gun", "player_Carol")); + try (var sf = mockStatic(SimpleFactions.class)) { + sf.when(SimpleFactions::getInstance).thenReturn(plugin); + ownerSync.syncIfBerthed(loaded("gun", "player_Carol")); + } + + verify(logger).warning(contains("SAVE_FAILED")); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleServiceTest.java index 66ba43f5..8c866e28 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleServiceTest.java @@ -39,6 +39,29 @@ import net.tfminecraft.vehicleframework.data.OwnerData; class InstallationVehicleServiceTest { + private static final UUID ALICE = UUID.randomUUID(); + private static final String ALICE_TAG = "char_" + ALICE; + + @Test + void aFailedLeaderStampPutsThePersonalRowBack() { + PlayerVehicleRecord personal = new PlayerVehicleRecord(UUID.randomUUID(), "vehicle-1", "ironclad", + OwnershipMode.PERSONAL, null); + registry.register(personal); + var api = net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi.install(); + api.forced = net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result.SAVE_FAILED; + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn("red"); + SimpleFactions plugin = mock(SimpleFactions.class); + java.util.logging.Logger logger = mock(java.util.logging.Logger.class); + when(plugin.getLogger()).thenReturn(logger); + when(plugin.saveVehicleRegistry()).thenReturn(true, false); + try (MockedStatic sf = mockStatic(SimpleFactions.class)) { + sf.when(SimpleFactions::getInstance).thenReturn(plugin); + assertEquals(false, service.tryRegister(port, ownedTarget("vehicle-1", "ironclad"), faction, UUID.randomUUID())); + } + assertEquals(personal, registry.getByVehicleUuid("vehicle-1").orElseThrow()); + verify(logger).severe(org.mockito.ArgumentMatchers.contains("berth stamp failed")); + } private Path tempDir; private PlayerVehicleRegistry registry; private InstallationVehicleService service; @@ -255,7 +278,9 @@ void register_updatesRegistryOwnerAndPersists() { VehicleBerthTarget vehicle = berthTarget("vehicle-1", "ironclad", locationAt(0, 64, 0), ownerData); Faction faction = mock(Faction.class); - when(faction.getLeader()).thenReturn("Alice"); + when(faction.getLeader()).thenReturn(ALICE.toString()); + var api = net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi.install() + .live("vehicle-1", "ironclad", "char_" + UUID.randomUUID()); SimpleFactions plugin = mock(SimpleFactions.class); when(plugin.saveVehicleRegistry()).thenReturn(true); @@ -268,7 +293,7 @@ void register_updatesRegistryOwnerAndPersists() { assertEquals(OwnershipMode.INSTALLATION, updated.getMode()); assertEquals("port-1", updated.getInstallationId()); assertEquals(playerUuid, updated.getPlayerUuid()); - assertEquals("player_Alice", ownerData.getOwner()); + assertEquals(ALICE_TAG, api.owner("vehicle-1")); verify(plugin).saveVehicleRegistry(); } } @@ -279,7 +304,7 @@ private static OwnerData unowned() { private static OwnerData ownedData() { OwnerData ownerData = new OwnerData(); - ownerData.setOwner("player_Alice"); + ownerData.setOwner(ALICE_TAG); return ownerData; } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthServiceTest.java index bedd9dd0..8012c0f4 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/InstallationVehicleUnberthServiceTest.java @@ -1,7 +1,7 @@ package net.tfminecraft.simplefactions.vehicles.berth; -import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; @@ -37,6 +37,10 @@ import net.tfminecraft.simplefactions.installation.InstallationKind; class InstallationVehicleUnberthServiceTest { + private static final net.tfminecraft.tlibs.character.CharacterRef LEADER = new net.tfminecraft.tlibs.character.CharacterRef( + UUID.randomUUID(), net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Leader")); + private static final net.tfminecraft.tlibs.character.CharacterRef OTHER = new net.tfminecraft.tlibs.character.CharacterRef( + UUID.randomUUID(), net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Other")); private Path tempDir; private org.mockito.MockedStatic online; private PlayerVehicleRegistry registry; @@ -72,12 +76,12 @@ void setUp() throws IOException { static_emplacements: {} """); VehiclesConfigLoader.load(vehiclesYaml.toFile()); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory()); + FakeOwnerApi.install(); registry = new PlayerVehicleRegistry(); assigned = new ArrayList<>(); service = new InstallationVehicleUnberthService( registry, (uuid, name) -> { - assigned.add(uuid + ":" + name); + assigned.add(uuid + ":" + net.tfminecraft.simplefactions.identity.Members.key(name)); return true; }); faction = mock(Faction.class); @@ -96,7 +100,6 @@ void setUp() throws IOException { void tearDown() throws IOException { online.close(); net.tfminecraft.simplefactions.managers.FactionManager.factions = previousFactions; - VehicleOwnershipQueries.setSourceForTests(null); if (tempDir != null) { Files.walk(tempDir) .sorted(java.util.Comparator.reverseOrder()) @@ -121,7 +124,7 @@ void unberth_successAssignsLeaderAndClearsRegistry() { sf.when(SimpleFactions::getInstance).thenReturn(plugin); InstallationVehicleUnberthService.UnberthOutcome outcome = - service.unberth(faction, "Leader", port, vehicleUuid); + service.unberth(faction, LEADER, port, vehicleUuid); assertEquals(InstallationVehicleUnberthService.UnberthResult.OK, outcome.result()); assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid(vehicleUuid).orElseThrow().getMode()); @@ -143,7 +146,7 @@ void unberth_rejectsNonLeader() { "red")); InstallationVehicleUnberthService.UnberthOutcome outcome = - service.unberth(faction, "Other", port, vehicleUuid); + service.unberth(faction, OTHER, port, vehicleUuid); assertEquals(InstallationVehicleUnberthService.UnberthResult.NOT_LEADER, outcome.result()); assertTrue(registry.getByVehicleUuid(vehicleUuid).isPresent()); @@ -162,7 +165,7 @@ void unberth_rejectsPersonalVehicle() { null)); InstallationVehicleUnberthService.UnberthOutcome outcome = - service.unberth(faction, "Leader", port, vehicleUuid); + service.unberth(faction, LEADER, port, vehicleUuid); assertEquals(InstallationVehicleUnberthService.UnberthResult.NOT_BERTHED, outcome.result()); } @@ -184,7 +187,7 @@ void unberth_rejectsWhenInstallationLocked() { .thenReturn(true); InstallationVehicleUnberthService.UnberthOutcome outcome = - service.unberth(faction, "Leader", port, vehicleUuid); + service.unberth(faction, LEADER, port, vehicleUuid); assertEquals(InstallationVehicleUnberthService.UnberthResult.EMBARGO, outcome.result()); assertTrue(registry.getByVehicleUuid(vehicleUuid).isPresent()); @@ -203,11 +206,11 @@ void unberth_rejectsWhenPersonalLimitIsFull() { OwnershipMode.INSTALLATION, port.getId(), "red")); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("other", "cloudskimmer", "player_Leader")); + registry.register(new PlayerVehicleRecord(LEADER.characterId(), "other", "cloudskimmer", + OwnershipMode.PERSONAL, null, null, LEADER.ownerId())); InstallationVehicleUnberthService.UnberthOutcome outcome = - service.unberth(faction, "Leader", port, vehicleUuid); + service.unberth(faction, LEADER, port, vehicleUuid); assertEquals(InstallationVehicleUnberthService.UnberthResult.NO_PERSONAL_ROOM, outcome.result()); assertTrue(registry.getByVehicleUuid(vehicleUuid).isPresent()); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java index 093ddc99..989eb6d2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java @@ -59,7 +59,12 @@ import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerFields; +import net.tfminecraft.simplefactions.vehicles.owners.OwnerMeta; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerWrites; import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimListener; import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimService; import net.tfminecraft.simplefactions.war.battle.engine.core.Battle; @@ -73,7 +78,6 @@ import net.tfminecraft.simplefactions.war.core.War; import net.tfminecraft.simplefactions.war.enums.WarEndReason; import net.tfminecraft.vehicleframework.VehicleFramework; -import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; import net.tfminecraft.vehicleframework.data.OwnerData; import net.tfminecraft.vehicleframework.data.StoredVehicleMeta; import net.tfminecraft.vehicleframework.data.VehicleRemovePayload; @@ -133,7 +137,9 @@ class VehicleInteractionsCoverageTest { private org.mockito.MockedConstruction feeDatabase; private PlayerEconomyManager economy; private final Map balances = new HashMap<>(); - private final List storedOwned = new ArrayList<>(); + private final Map stored = new HashMap<>(); + private final Map storedTypes = new HashMap<>(); + private Framework framework; private OwnerData ownerData; private ActiveVehicle vehicle; private PlayerVehicleRecord originalRecord; @@ -163,8 +169,6 @@ void setup() throws Exception { snapshot(InstallationConfigLoader.class, field); Field requests = snapshot(RequestManager.class, "requests"); requests.set(null, new HashMap<>()); - snapshot(VehicleOwnershipQueries.class, "source"); - VehicleOwnershipQueries.setSourceForTests(null); snapshot(VehicleFeeService.class, "factionLookup"); snapshot(VehicleFeeService.class, "playerBank"); Field battles = snapshot(BattleManager.class, "battles"); @@ -215,13 +219,14 @@ void setup() throws Exception { when(fixture.ui.plugin.getVehicleTransferConsentService()).thenReturn(consent); VehicleManager manager = mock(VehicleManager.class); when(manager.get(anyString())).thenAnswer(call -> loaded.get(call.getArgument(0))); - when(manager.listOwnedVehicles(anyString())) - .thenAnswer(call -> ownedSummaries(call.getArgument(0))); - when(manager.listAllPlayerOwnedVehicles()).thenAnswer(call -> ownedSummaries(null)); vehicleFramework = mockStatic(VehicleFramework.class); vehicleFramework.when(VehicleFramework::getVehicleManager).thenReturn(manager); + framework = new Framework(); + VehicleOwnerWrites.use(framework); + // The vehicle stage has finished, so builds may start. + net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps.enable(true); ownerData = new OwnerData(); - ownerData.setOwner("player_Owner"); + ownerData.setOwner(tag("Owner")); vehicle = mock(ActiveVehicle.class); when(vehicle.getUUID()).thenReturn("vehicle-1"); when(vehicle.getId()).thenReturn("ironclad"); @@ -230,7 +235,8 @@ void setup() throws Exception { loaded.put("vehicle-1", vehicle); originalRecord = new PlayerVehicleRecord( - owner.getUniqueId(), "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null); + owner.getUniqueId(), "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null, null, + owner.getUniqueId()); registry.register(originalRecord); releases = new VehicleReleaseSessionManager(); releaseService = new FactionVehicleReleaseService(registry); @@ -350,7 +356,7 @@ void nearbyOwnersAcceptIntoTheRequestedFactionStoreExactlyOnce(boolean pool) { assertEquals(pool ? null : "harbor", updated.getInstallationId()); assertEquals("home", updated.getFactionId()); assertEquals(owner.getUniqueId(), updated.getPlayerUuid()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals("char_none", ownerData.getOwner(), "Name-keyed fixture leaders have no character"); assertFalse(RequestManager.hasRequest(owner)); RequestManager.accept(owner); assertSame(updated, registry.getByVehicleUuid("vehicle-1").orElseThrow()); @@ -395,7 +401,7 @@ void pendingConsentCannotBeRedirectedWhenItsProposerLeadsADifferentFaction(boole @ValueSource(booleans = {false, true}) void theCurrentLeaderCanTransferTheirOwnVehicleWithoutAConsentRequest(boolean pool) { arm(pool); - ownerData.setOwner("player_lEaDeR"); + ownerData.setOwner(tag("Leader")); assertTrue(click().isCancelled()); assertNull(sessions.get(leader.getUniqueId())); assertFalse(RequestManager.hasRequest(owner)); @@ -403,7 +409,7 @@ var record = registry.getByVehicleUuid("vehicle-1").orElseThrow(); assertEquals(pool ? OwnershipMode.POOL : OwnershipMode.INSTALLATION, record.getMode()); assertEquals(leader.getUniqueId(), record.getPlayerUuid()); assertEquals("home", record.getFactionId()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals("char_none", ownerData.getOwner(), "Name-keyed fixture leaders have no character"); verify(fixture.ui.plugin).saveVehicleRegistry(); } @@ -493,12 +499,12 @@ void failedOwnerAndStoreValidationKeepsTheSelectionArmedAndTheVehiclePersonal(St @ValueSource(booleans = {false, true}) void aLeadersOwnInvalidVehicleIsRefusedByTheSameStoreValidation(boolean pool) { arm(pool); - ownerData.setOwner("player_Leader"); + ownerData.setOwner(tag("Leader")); when(vehicle.getId()).thenReturn("unknown"); assertTrue(click().isCancelled()); assertNotNull(sessions.get(leader.getUniqueId())); assertSame(originalRecord, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); verify(leader).sendMessage(contains("not registered for faction upkeep")); verify(fixture.ui.plugin, never()).saveVehicleRegistry(); } @@ -523,7 +529,7 @@ void consentAcceptanceRechecksTheCurrentVehicleAndFactionBeforeWritingOwnership( click(); assertTrue(RequestManager.hasRequest(owner)); if (state.endsWith("unloaded")) loaded.clear(); - if (state.endsWith("changed_owner")) ownerData.setOwner("player_Buyer"); + if (state.endsWith("changed_owner")) ownerData.setOwner(tag("Buyer")); if (state.endsWith("unknown_type")) when(vehicle.getId()).thenReturn("unknown"); if (state.endsWith("removed_installation")) faction.getInstallationHandler().detachOnProvince(42); @@ -552,8 +558,9 @@ void requestUuidGuardsAndMissingFrameworkObjectsDoNotWriteOwnership() { "Harbor", "vehicle-1", "ironclad", - UUID.randomUUID(), - leader.getUniqueId()); + new net.tfminecraft.tlibs.character.CharacterRef(UUID.randomUUID(), UUID.randomUUID()), + ref(leader), + false); RequestManager.addRequest(leader, owner, request); consent.acceptRequest(owner); verify(owner).sendMessage("§cYou cannot accept this request."); @@ -576,8 +583,9 @@ void expiredRequestNotificationsOnlyReachOnlineParticipants() { "Harbor", "vehicle-1", "ironclad", - owner.getUniqueId(), - leader.getUniqueId()); + ref(owner), + ref(leader), + false); consent.notifyExpired(null, owner); verify(owner, never()).sendMessage(anyString()); consent.notifyExpired(request, owner); @@ -600,7 +608,7 @@ void aFailedRegistrySavePreservesBothTheOriginalRecordAndTheFrameworkOwner(Strin click(); assertTrue(RequestManager.hasRequest(owner)); } else { - ownerData.setOwner("player_Leader"); + ownerData.setOwner(tag("Leader")); } String priorOwner = ownerData.getOwner(); when(fixture.ui.plugin.saveVehicleRegistry()).thenReturn(false); @@ -667,8 +675,9 @@ void requestDestinationDoesNotFollowItsSenderGuildWhenTheGuildMoves() { "Harbor", "vehicle-1", "ironclad", - owner.getUniqueId(), - leader.getUniqueId()); + ref(owner), + ref(leader), + false); Faction destination = fixture.saved("next", "Next Leader"); guild.relocate(destination, -1); assertSame(destination, guild.getFaction()); @@ -689,7 +698,7 @@ void factionTakeRecordsTheNewPersonalPayer(boolean pool) { releaseListener.onVehiclePreInteract(event); assertTrue(event.isCancelled()); assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("vehicle-1").orElseThrow().getMode()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); assertNull(releases.get(leader.getUniqueId())); verify(fixture.ui.plugin).saveVehicleRegistry(); verify(fixture.ui.plugin).recordVehicleOwner("vehicle-1", "Leader"); @@ -707,12 +716,12 @@ void factionGiveWaitsForTheExactRecipientAndTransfersOnlyOnAcceptance(boolean po assertEquals( OwnershipMode.valueOf(pool ? "POOL" : "INSTALLATION"), registry.getByVehicleUuid("vehicle-1").orElseThrow().getMode()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); assertNull(releases.get(leader.getUniqueId())); RequestManager.accept(owner); assertFalse(RequestManager.hasRequest(owner)); assertEquals(OwnershipMode.PERSONAL, registry.getByVehicleUuid("vehicle-1").orElseThrow().getMode()); - assertEquals("player_Owner", ownerData.getOwner()); + assertEquals(tag("Owner"), ownerData.getOwner()); verify(fixture.ui.plugin).recordVehicleOwner("vehicle-1", "Owner"); verify(fixture.ui.plugin).saveVehicleRegistry(); } @@ -757,9 +766,7 @@ void releaseAndGiveRefusalsPreserveTheVehicleAndClearOnlyInvalidSelections(Strin "harbor", "home")); if (state.equals("recipient_full")) - storedOwned.add( - new OwnedVehicleSummary( - "other", "Other ship", "ironclad", Optional.empty(), false, "player_Owner")); + storeOwned("other", "ironclad", "Owner"); if (state.equals("battle")) startBattle(); var before = registry.getByVehicleUuid("vehicle-1").orElseThrow(); var event = @@ -768,7 +775,7 @@ void releaseAndGiveRefusalsPreserveTheVehicleAndClearOnlyInvalidSelections(Strin releaseListener.onVehiclePreInteract(event); assertEquals(!state.equals("no_session") && !state.equals("no_player"), event.isCancelled()); assertSame(before, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); assertFalse(RequestManager.hasRequest(owner)); assertEquals( state.equals("no_session") || state.equals("former_leader") || state.equals("battle"), @@ -793,7 +800,7 @@ void takeFailuresPreserveOwnershipAndPermitRetryWhenAppropriate(String state) { releaseListener.onVehiclePreInteract(event); assertTrue(event.isCancelled()); assertSame(before, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); assertEquals(state.equals("battle"), releases.get(leader.getUniqueId()) == null); verify(leader).sendMessage(startsWith("§c")); verify(fixture.ui.plugin, never()).recordVehicleOwner(anyString(), anyString()); @@ -818,16 +825,15 @@ void personalHandoverCollectsOrRefundsTheConfirmedFeeAndMovesOwnershipExactlyOnc handoverListener.onVehiclePreInteract(new VehiclePreInteractEvent(owner, vehicle)); assertTrue(RequestManager.hasRequest(recipient)); assertNull(handovers.get(owner.getUniqueId())); - if (state.equals("ownership_failure")) { - try (MockedStatic external = mockStatic(VehiclePersistence.class)) { - VehiclePersistence persistence = mock(VehiclePersistence.class); - external.when(VehiclePersistence::current).thenReturn(persistence); - when(persistence.saveLive(vehicle)).thenReturn(false); - RequestManager.accept(recipient); - } - } else RequestManager.accept(recipient); + if (state.equals("ownership_failure")) framework.forced = Result.SAVE_FAILED; + RequestManager.accept(recipient); boolean successful = state.equals("success"); - assertEquals(successful ? "player_Recipient" : "player_Owner", ownerData.getOwner()); + if (state.equals("ownership_failure")) { + // The recipient's payer row went first and was taken back. + assertEquals(originalRecord.getPlayerUuid(), registry.getByVehicleUuid("vehicle-1").orElseThrow().getPlayerUuid()); + assertEquals(List.of("vehicle-1 " + tag("Recipient") + " HANDOVER"), framework.writes); + } + assertEquals(successful ? tag("Recipient") : tag("Owner"), ownerData.getOwner()); assertEquals( successful ? 60.0 : state.equals("unaffordable") ? 10.0 : 100.0, balance(owner)); @@ -868,7 +874,7 @@ void pendingHandoverCannotChargeMoreThanTheFeeConfirmedByTheOwner() { RequestManager.accept(recipient); assertAll( - () -> assertEquals("player_Owner", ownerData.getOwner()), + () -> assertEquals(tag("Owner"), ownerData.getOwner()), () -> assertEquals(100.0, balance(owner)), () -> assertEquals(1000.0, faction.getBank().getWealth())); assertEquals(1000.0, faction.getBank().getWealth()); @@ -907,12 +913,10 @@ void personalHandoverSelectionRejectsChangedParticipantsAndUnavailableVehicles(S Player replacement = fixture.player("Recipient"); when(replacement.getUniqueId()).thenReturn(UUID.randomUUID()); } - if (state.equals("not_owner")) ownerData.setOwner("player_Another"); + if (state.equals("not_owner")) ownerData.setOwner(tag("Another")); if (state.equals("unknown_type")) when(vehicle.getId()).thenReturn("unknown"); if (state.equals("recipient_full")) - storedOwned.add( - new OwnedVehicleSummary( - "other", "Other", "ironclad", Optional.empty(), false, "player_Recipient")); + storeOwned("other", "ironclad", "Recipient"); if (state.equals("battle")) { faction.addMember("Owner"); startBattle(); @@ -941,12 +945,10 @@ void personalHandoverAcceptanceRechecksCurrentOwnershipRecipientCapacityAndBattl assertInstanceOf(VehicleHandoverRequest.class, RequestManager.getRequest(recipient)); assertEquals("ironclad", request.getVehicleTypeId()); if (state.equals("wrong_uuid")) when(recipient.getUniqueId()).thenReturn(UUID.randomUUID()); - if (state.equals("changed_owner")) ownerData.setOwner("player_Another"); + if (state.equals("changed_owner")) ownerData.setOwner(tag("Another")); if (state.equals("unknown_type")) when(vehicle.getId()).thenReturn("unknown"); if (state.equals("recipient_full")) - storedOwned.add( - new OwnedVehicleSummary( - "other", "Other", "ironclad", Optional.empty(), false, "player_Recipient")); + storeOwned("other", "ironclad", "Recipient"); if (state.equals("battle")) { faction.addMember("Owner"); startBattle(); @@ -992,7 +994,7 @@ void publicHandoverWithNullTypeRejectsWithoutChargingChangingOwnershipOrSaving() } assertFalse(RequestManager.hasRequest(recipient)); - assertEquals("player_Owner", ownerData.getOwner()); + assertEquals(tag("Owner"), ownerData.getOwner()); assertSame(originalRecord, registry.getByVehicleUuid("vehicle-1").orElseThrow()); assertEquals(100.0, balance(owner)); assertEquals(1000.0, faction.getBank().getWealth()); @@ -1019,14 +1021,14 @@ void handoverPreservesAnExistingRequestAndIgnoresUnrelatedAcceptance() { verify(owner).sendMessage("§cThe target is already considering another request."); assertEquals( VehicleHandoverService.Status.NOT_OWNER, - handoverService.evaluate(null, "Recipient", "vehicle-1").status()); + handoverService.evaluate(null, recipient.getUniqueId(), "vehicle-1").status()); assertEquals( VehicleHandoverService.Status.NOT_OWNER, - handoverService.evaluate("Owner", "Recipient", null).status()); + handoverService.evaluate(ref(owner), recipient.getUniqueId(), null).status()); factionVehicle(false); assertEquals( VehicleHandoverService.Status.NOT_OWNER, - handoverService.evaluate("Leader", "Recipient", "vehicle-1").status()); + handoverService.evaluate(ref(leader), recipient.getUniqueId(), "vehicle-1").status()); } @ParameterizedTest @@ -1078,7 +1080,7 @@ void factionGiftAcceptanceRechecksTheCapturedFactionAndRecipient(String state) { RequestManager.accept(owner); assertFalse(RequestManager.hasRequest(owner)); assertSame(priorRecord, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); verify(owner).sendMessage(startsWith("§c")); verify(fixture.ui.plugin, never()).saveVehicleRegistry(); } @@ -1102,7 +1104,8 @@ void missingGiftInputsAndUnrelatedRequestsDoNotIssueOrAcceptOwnershipChanges() { "ironclad", owner.getUniqueId(), null, - "Leader"); + ref(leader), + ref(owner)); giveService.notifyExpired(orphaned, owner); verify(owner).sendMessage(FactionVehicleReleaseMessages.giveExpired()); verify(leader, never()).sendMessage(anyString()); @@ -1117,7 +1120,6 @@ void missingGiftInputsAndUnrelatedRequestsDoNotIssueOrAcceptOwnershipChanges() { "no_vehicle", "blank_vehicle", "no_recipient", - "blank_recipient", "not_registered", "different_installation" }) @@ -1127,18 +1129,20 @@ var record = registry.getByVehicleUuid("vehicle-1").orElseThrow(); if (state.equals("not_registered")) registry.unregister("vehicle-1"); var result = state.equals("different_installation") - ? releaseService.takeFromInstallation(faction, "Leader", "elsewhere", "vehicle-1") + ? releaseService.takeFromInstallation(faction, ref(leader), "elsewhere", "vehicle-1") : releaseService.give( state.equals("no_faction") ? null : faction, state.equals("no_leader") ? null : state.equals("blank_leader") ? " " : "Leader", - state.equals("no_recipient") - ? null - : state.equals("blank_recipient") ? " " : "Owner", + state.equals("no_recipient") ? null : ref(owner), state.equals("no_vehicle") ? null : state.equals("blank_vehicle") ? " " : "vehicle-1"); - assertEquals(FactionVehicleReleaseService.Status.NOT_FACTION_VEHICLE, result.status()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals( + state.equals("no_recipient") + ? FactionVehicleReleaseService.Status.OWNERSHIP_UNAVAILABLE + : FactionVehicleReleaseService.Status.NOT_FACTION_VEHICLE, + result.status()); + assertEquals(tag("Leader"), ownerData.getOwner()); if (state.equals("not_registered")) assertTrue(registry.getAll().isEmpty()); else assertSame(record, registry.getByVehicleUuid("vehicle-1").orElseThrow()); verify(fixture.ui.plugin, never()).saveVehicleRegistry(); @@ -1151,139 +1155,41 @@ void failedFrameworkOwnerWritesRestoreTheFactionRecordAndReportRollbackSaveFailu factionVehicle(false); var record = registry.getByVehicleUuid("vehicle-1").orElseThrow(); when(fixture.ui.plugin.saveVehicleRegistry()).thenReturn(true, !rollbackFails); - try (MockedStatic external = mockStatic(VehiclePersistence.class)) { - VehiclePersistence persistence = mock(VehiclePersistence.class); - external.when(VehiclePersistence::current).thenReturn(persistence); - when(persistence.saveLive(vehicle)).thenReturn(false); - var outcome = releaseService.give(faction, "Leader", "Owner", "vehicle-1"); - assertEquals( - rollbackFails - ? FactionVehicleReleaseService.Status.SAVE_FAILED - : FactionVehicleReleaseService.Status.OWNERSHIP_UNAVAILABLE, - outcome.status()); - assertSame(record, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals("player_Leader", ownerData.getOwner()); - verify(fixture.ui.plugin, times(2)).saveVehicleRegistry(); - verify(persistence).saveLive(vehicle); - } + framework.forced = Result.SAVE_FAILED; + var outcome = releaseService.give(faction, "Leader", ref(owner), "vehicle-1"); + assertEquals( + rollbackFails + ? FactionVehicleReleaseService.Status.SAVE_FAILED + : FactionVehicleReleaseService.Status.OWNERSHIP_UNAVAILABLE, + outcome.status()); + assertSame(record, registry.getByVehicleUuid("vehicle-1").orElseThrow()); + assertEquals(tag("Leader"), ownerData.getOwner()); + verify(fixture.ui.plugin, times(2)).saveVehicleRegistry(); + assertEquals(List.of("vehicle-1 " + tag("Owner") + " RELEASE"), framework.writes); } @ParameterizedTest - @ValueSource(strings = {"no_owner", "save_true", "save_false", "save_throws"}) - void loadedFrameworkOwnerWritesAreAtomicAndNeverFallBackToStoredCopies(String state) { - if (state.equals("no_owner")) when(vehicle.getOwnerData()).thenReturn(null); - try (MockedStatic external = mockStatic(VehiclePersistence.class)) { - VehiclePersistence persistence = mock(VehiclePersistence.class); - external.when(VehiclePersistence::current).thenReturn(persistence); - when(persistence.saveLive(vehicle)).thenReturn(state.equals("save_true")); - if (state.equals("save_throws")) - when(persistence.saveLive(vehicle)) - .thenThrow(new IllegalStateException("database unavailable")); - boolean changed = FactionVehicleReleaseService.assignFrameworkOwner("vehicle-1", "Recipient"); - assertEquals(state.equals("save_true"), changed); - assertEquals(changed ? "player_Recipient" : "player_Owner", ownerData.getOwner()); - verify(persistence, never()).findLive(anyString()); - } - } + @ValueSource(strings = {"OK", "SAVE_FAILED", "NOT_FOUND", "REFUSED_MODE", "UNAVAILABLE"}) + void releasedOwnersAreWrittenThroughVehicleFrameworksSetOwnership(String result) { + framework.forced = Result.valueOf(result); + if (framework.forced == Result.OK) framework.forced = null; + UUID recipient = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Recipient"); + ownerData.setWhiteList(new ArrayList<>(List.of(tag("Crew") + ";x;Crew"))); - @ParameterizedTest - @ValueSource( - strings = { - "success", - "save_false", - "missing", - "empty_payload", - "null_payload", - "malformed", - "array_payload", - "lookup_throws", - "already_owned", - "no_persistence", - "manager_unavailable" - }) - void unloadedFrameworkTransfersPreserveSnapshotDataAndRequireAConfirmedStoredWrite(String state) { - loaded.clear(); - if (state.equals("manager_unavailable")) - vehicleFramework - .when(VehicleFramework::getVehicleManager) - .thenThrow(new IllegalStateException("not started")); - String payload = - switch (state) { - case "empty_payload" -> " "; - case "null_payload" -> null; - case "malformed" -> "{"; - case "array_payload" -> "[]"; - default -> "{\"owner\":\"player_Owner\",\"health\":27}"; - }; - VehicleSnapshot before = - new VehicleSnapshot( - "vehicle-1", - "ironclad", - "Named ship", - "player_Owner", - "world", - 7, - 64, - 9, - 12.0f, - 0, - 0, - payload, - 2, - 8, - false, - 123); - try (MockedStatic external = mockStatic(VehiclePersistence.class)) { - VehiclePersistence persistence = mock(VehiclePersistence.class); - VehicleRepository repository = mock(VehicleRepository.class); - external - .when(VehiclePersistence::current) - .thenReturn(state.equals("no_persistence") ? null : persistence); - when(persistence.findLive("vehicle-1")) - .thenReturn( - state.equals("missing") || state.equals("already_owned") - ? Optional.empty() - : Optional.of(before)); - if (state.equals("lookup_throws")) - when(persistence.findLive("vehicle-1")).thenThrow(new IllegalStateException("read failed")); - when(persistence.repository()).thenReturn(repository); - when(repository.saveLive(any(VehicleSnapshot.class))).thenReturn(!state.equals("save_false")); - when(persistence.readMeta("vehicle-1")) - .thenReturn( - state.equals("already_owned") - ? Optional.of( - new StoredVehicleMeta( - "vehicle-1", "Named ship", "ironclad", "player_Recipient")) - : Optional.empty()); - boolean result = FactionVehicleReleaseService.assignFrameworkOwner("vehicle-1", "Recipient"); - boolean successful = - List.of("success", "already_owned", "manager_unavailable").contains(state); - assertEquals(successful, result); - assertEquals("player_Owner", before.getOwner()); - assertEquals(8, before.getRevision()); - assertEquals(payload, before.getPayloadJson()); - if (state.equals("success") - || state.equals("manager_unavailable") - || state.equals("save_false")) { - var updated = ArgumentCaptor.forClass(VehicleSnapshot.class); - verify(repository).saveLive(updated.capture()); - assertEquals("player_Recipient", updated.getValue().getOwner()); - assertEquals("Named ship", updated.getValue().getName()); - assertEquals(7, updated.getValue().getX()); - assertEquals(9, updated.getValue().getZ()); - assertEquals(8, updated.getValue().getRevision()); - assertTrue(updated.getValue().getPayloadJson().contains("\"health\":27")); - assertTrue(updated.getValue().getPayloadJson().contains("\"owner\":\"player_Recipient\"")); - } else verify(repository, never()).saveLive(any(VehicleSnapshot.class)); - } + boolean changed = FactionVehicleReleaseService.assignFrameworkOwner("vehicle-1", recipient); + + assertEquals(result.equals("OK"), changed); + assertEquals(changed ? tag("Recipient") : tag("Owner"), ownerData.getOwner()); + assertEquals(changed, ownerData.getWhiteList().isEmpty(), "A release never keeps the old crew"); + assertEquals(List.of("vehicle-1 " + tag("Recipient") + " RELEASE"), framework.writes); } @Test void invalidFrameworkOwnershipIdentifiersCannotModifyEitherLiveOrStoredState() { + UUID recipient = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Recipient"); for (String vehicleId : new String[] {null, "", " "}) - assertFalse(FactionVehicleReleaseService.assignFrameworkOwner(vehicleId, "Recipient")); - for (String playerName : new String[] {null, "", " "}) - assertFalse(FactionVehicleReleaseService.assignFrameworkOwner("vehicle-1", playerName)); + assertFalse(FactionVehicleReleaseService.assignFrameworkOwner(vehicleId, recipient)); + assertFalse(FactionVehicleReleaseService.assignFrameworkOwner("vehicle-1", null)); assertPersonalOwnershipUnchanged(); } @@ -1317,7 +1223,7 @@ void pendingHandoverBindsTheFeeRecipientAndFreeStatusAsWellAsItsAmount(String st } if (state.equals("vehicle_type_changes")) when(vehicle.getId()).thenReturn("simple_locomotive"); RequestManager.accept(recipient); - assertEquals("player_Owner", ownerData.getOwner()); + assertEquals(tag("Owner"), ownerData.getOwner()); assertEquals(100.0, balance(owner)); assertEquals(1000.0, faction.getBank().getWealth()); if (second != null) assertEquals(500.0, second.getBank().getWealth()); @@ -1334,13 +1240,13 @@ void anOfflineOwnerStillPaysThePreviouslyConfirmedHandoverFeeFromTheirCapturedUu fixture.online.remove("Owner"); when(owner.isOnline()).thenReturn(false); RequestManager.accept(recipient); - assertEquals("player_Recipient", ownerData.getOwner()); + assertEquals(tag("Recipient"), ownerData.getOwner()); assertEquals(60.0, balance(owner)); assertEquals(1040.0, faction.getBank().getWealth()); assertEquals("Recipient", feeStore.getLastOwner("vehicle-1")); assertEquals( VehicleHandoverService.Status.NOT_OWNER, - handoverService.evaluate("Recipient", "Leader", "other-vehicle").status()); + handoverService.evaluate(ref(recipient), leader.getUniqueId(), "other-vehicle").status()); } @Test @@ -1350,9 +1256,9 @@ var record = registry.getByVehicleUuid("vehicle-1").orElseThrow(); faction.setLeader("Successor"); assertEquals( FactionVehicleReleaseService.Status.NOT_LEADER, - releaseService.take(faction, "Leader", "vehicle-1").status()); + releaseService.take(faction, ref(leader), "vehicle-1").status()); assertSame(record, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); verify(fixture.ui.plugin, never()).saveVehicleRegistry(); } @@ -1365,7 +1271,7 @@ void legacyRegistrationWrappersUseTheRealSaveAndOwnerUpdate(boolean pool) { var record = registry.getByVehicleUuid("vehicle-1").orElseThrow(); assertEquals(pool ? OwnershipMode.POOL : OwnershipMode.INSTALLATION, record.getMode()); assertEquals("home", record.getFactionId()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals("char_none", ownerData.getOwner(), "Name-keyed fixture leaders have no character"); verify(fixture.ui.plugin).saveVehicleRegistry(); } @@ -1381,7 +1287,7 @@ void failedFirstRegistrationRestoresTheAbsenceOfAnOwnershipRecord(boolean pool) : berths.tryRegister(port, vehicle, faction, owner.getUniqueId()); assertFalse(result); assertTrue(registry.getAll().isEmpty()); - assertEquals("player_Owner", ownerData.getOwner()); + assertEquals(tag("Owner"), ownerData.getOwner()); assertEquals( FactionVehiclePoolService.CanAddResult.NOT_OWNED, pools.canAdd(faction, (ActiveVehicle) null)); @@ -1524,7 +1430,8 @@ void constructionIntegrationChecksRealVehicleLimitsBeforeAllowingTheBuild(String state.equals("missing_blueprint") ? null : blueprint(state.equals("unknown") ? "unknown" : "ironclad"); - if (!state.equals("per_type_full")) loaded.clear(); + // Only the full case keeps the owner's personal ironclad row. + if (!state.equals("per_type_full")) registry.unregister("vehicle-1"); var event = new BeginVehicleConstructionEvent( state.equals("missing_player") ? null : owner, @@ -1535,48 +1442,8 @@ void constructionIntegrationChecksRealVehicleLimitsBeforeAllowingTheBuild(String boolean denied = List.of("unknown", "missing_blueprint", "per_type_full").contains(state); assertEquals(denied, event.isCancelled()); if (denied) verify(owner).sendMessage(startsWith("§c")); - assertSame(originalRecord, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals("player_Owner", ownerData.getOwner()); - } - - @ParameterizedTest - @ValueSource( - strings = { - "online", - "offline_named", - "offline_uuid", - "missing_vehicle", - "missing_uuid", - "missing_blueprint" - }) - void completedConstructionAssignsTheResolvedOwnerAndWhitelistWithoutInventingARegistryEntry( - String state) throws Exception { - snapshot(net.tfminecraft.vehicleframework.cache.Cache.class, "allowWhitelist"); - net.tfminecraft.vehicleframework.cache.Cache.allowWhitelist = true; - if (state.startsWith("offline")) { - fixture.online.remove("Owner"); - OfflinePlayer offline = mock(OfflinePlayer.class); - when(offline.getName()).thenReturn(state.equals("offline_named") ? "OfflineOwner" : null); - when(Bukkit.getOfflinePlayer(owner.getUniqueId())).thenReturn(offline); - } - Blueprint blueprint = state.equals("missing_blueprint") ? null : blueprint("ironclad"); - ActiveStation station = station(); - var event = - new VehicleConstructEvent( - state.equals("missing_uuid") ? null : owner.getUniqueId(), - state.equals("missing_vehicle") ? null : vehicle, - blueprint, - station.getLocation(), - station); - new VehicleIntegrationListener().onVehicleConstruct(event); - boolean missing = state.startsWith("missing"); - String expected = - state.equals("offline_named") - ? "player_OfflineOwner" - : state.equals("offline_uuid") ? "player_" + owner.getUniqueId() : "player_Owner"; - assertEquals(expected, ownerData.getOwner()); - assertEquals(!missing, ownerData.isWhiteListed()); - assertSame(originalRecord, registry.getByVehicleUuid("vehicle-1").orElseThrow()); + if (state.equals("per_type_full")) assertSame(originalRecord, registry.getByVehicleUuid("vehicle-1").orElseThrow()); + assertEquals(tag("Owner"), ownerData.getOwner()); } @ParameterizedTest @@ -1599,8 +1466,7 @@ void destructionRemovesFactionAndFeeRecordsButUnloadingPreservesThem(String stat : VehicleRemoveReason.PLAYER_DESTROY); var event = new VehicleRemoveEvent(state.equals("missing_vehicle") ? null : vehicle, payload); new VehicleIntegrationListener().onVehicleRemove(event); - new VehicleReclaimFeeListener(feeStore, new VehicleFeeConfirmations(), feeStore::save) - .onVehicleRemove(event); + new VehicleReclaimFeeListener(feeStore, feeStore::save).onVehicleRemove(event); boolean removed = List.of("death", "remove", "missing_record").contains(state); assertEquals(removed, registry.getByVehicleUuid("vehicle-1").isEmpty()); assertEquals(removed ? null : "Owner", feeStore.getLastOwner("vehicle-1")); @@ -1608,61 +1474,6 @@ void destructionRemovesFactionAndFeeRecordsButUnloadingPreservesThem(String stat .saveVehicleRegistry(); } - @ParameterizedTest - @ValueSource( - strings = { - "success", - "unaffordable", - "same_owner", - "admin", - "missing_last_owner", - "missing_player", - "missing_vehicle", - "missing_uuid", - "exempt" - }) - void reclaimChargesOnlyAConfirmedNewOwnerAndCommitsTheOwnerHistoryAfterSuccess(String state) { - Player claimant = fixture.player("Recipient"); - enableTransferFee(); - feeStore.setLastOwner("vehicle-1", state.equals("same_owner") ? "Recipient" : "Owner"); - fund(claimant, state.equals("unaffordable") ? 10.0 : 100.0); - if (state.equals("missing_last_owner")) feeStore.forgetVehicle("vehicle-1"); - if (state.equals("admin")) - when(claimant.hasPermission("simplefactions.admin")).thenReturn(true); - if (state.equals("missing_uuid")) when(vehicle.getUUID()).thenReturn(null); - if (state.equals("exempt")) - faction.getVehicleFeeHandler().setRate(FeeKind.TRANSFER_FEE, null, 0.0); - var fees = - new VehicleReclaimFeeListener(feeStore, new VehicleFeeConfirmations(), feeStore::save); - Player player = state.equals("missing_player") ? null : claimant; - ActiveVehicle claimed = state.equals("missing_vehicle") ? null : vehicle; - var event = new VehicleOwnerClaimedEvent(player, claimed, "none", "player_Recipient"); - fees.onVehicleOwnerClaimed(event); - boolean charged = state.equals("success") || state.equals("unaffordable"); - assertEquals(charged, event.isCancelled()); - assertEquals(1000.0, faction.getBank().getWealth()); - if (!charged) { - fees.onClaimed(event); - assertEquals( - List.of("missing_player", "missing_vehicle", "missing_uuid").contains(state) - ? "Owner" - : "Recipient", - feeStore.getLastOwner("vehicle-1")); - return; - } - var confirmation = new VehicleOwnerClaimedEvent(claimant, vehicle, "none", "player_Recipient"); - fees.onVehicleOwnerClaimed(confirmation); - assertEquals(state.equals("unaffordable"), confirmation.isCancelled()); - if (!confirmation.isCancelled()) fees.onClaimed(confirmation); - assertEquals(state.equals("success") ? 60.0 : 10.0, balance(claimant)); - assertEquals(state.equals("success") ? 1040.0 : 1000.0, faction.getBank().getWealth()); - assertEquals( - state.equals("success") ? "Recipient" : "Owner", feeStore.getLastOwner("vehicle-1")); - assertEquals( - state.equals("success") ? -40.0 : 0.0, - economy.getLedger(net.tfminecraft.simplefactions.identity.Members.session(claimant).orElseThrow().ref().characterId()).getAmount(PlayerCashflow.VEHICLE_FEES)); - } - @Test void enemyInstallationWithTheSameLocalIdCannotBorrowOurBattleCommitment() { Faction enemy = fixture.saved("enemy", "Enemy"); @@ -1720,7 +1531,7 @@ void battleEntryAndSpawnUseTheSameLiveRosterAndFactionOwnershipRules(String stat war.setBattleInstallationPicks(Map.of("home", new LinkedHashSet<>(List.of("harbor")))); Player actor = state.equals("no_faction") || state.equals("nonparticipant") ? owner : leader; if (state.equals("nonparticipant")) fixture.saved("neutral", "Neutral").addMember("Owner"); - ownerData.setOwner("player_" + actor.getName()); + ownerData.setOwner(tag(actor.getName())); Battle battle = new Battle("vehicle-entry"); battle.setWarId(war.getId()); battle.setStarted(!state.equals("prebattle")); @@ -1801,7 +1612,7 @@ void battleEntryAndSpawnUseTheSameLiveRosterAndFactionOwnershipRules(String stat @Test void battleNotificationsPreferCurrentOwnerThenRegistryIdentityAndIgnoreIncompleteEvents() { assertSame(owner, BattleVehicleEligibilityService.resolveNotifyPlayer(vehicle, registry)); - ownerData.setOwner("player_NotOnline"); + ownerData.setOwner(tag("NotOnline")); assertSame(owner, BattleVehicleEligibilityService.resolveNotifyPlayer(vehicle, registry)); when(vehicle.getOwnerData()).thenReturn(null); assertSame(owner, BattleVehicleEligibilityService.resolveNotifyPlayer(vehicle, registry)); @@ -1859,21 +1670,21 @@ void battleNotificationsPreferCurrentOwnerThenRegistryIdentityAndIgnoreIncomplet "faction_owned" }) void claimListenersEnforceRealPersonalLimitsWithoutCreatingPrematureOwnership(String state) { + // A stale personal row from another account; the claimer's own vehicles decide. + originalRecord = new PlayerVehicleRecord(owner.getUniqueId(), "vehicle-1", "ironclad", OwnershipMode.PERSONAL, + null, null, UUID.randomUUID()); + registry.register(originalRecord); var claims = new VehicleRegistryClaimService(registry); var listener = new VehicleRegistryClaimListener(claims); - ownerData.setOwner(state.equals("already_owned") ? "player_Owner" : "none"); + ownerData.setOwner(state.equals("already_owned") ? tag("Owner") : "none"); if (state.equals("missing_uuid")) when(vehicle.getUUID()).thenReturn(null); if (state.equals("no_owner_data")) when(vehicle.getOwnerData()).thenReturn(null); if (state.equals("unknown")) when(vehicle.getId()).thenReturn("unknown"); if (state.equals("per_type_limit")) - storedOwned.add( - new OwnedVehicleSummary( - "owned", "Owned", "ironclad", Optional.empty(), false, "player_Owner")); + storeOwned("owned", "ironclad", "Owner"); if (state.equals("total_limit")) for (int i = 0; i < VehiclesConfigLoader.getPersonalSlotLimit(); i++) - storedOwned.add( - new OwnedVehicleSummary( - "owned-" + i, "Owned", "horse_cart", Optional.empty(), false, "player_Owner")); + storeOwned("owned-" + i, "horse_cart", "Owner"); if (state.equals("faction_owned")) registry.register( new PlayerVehicleRecord( @@ -1888,16 +1699,13 @@ void claimListenersEnforceRealPersonalLimitsWithoutCreatingPrematureOwnership(St ActiveVehicle active = state.equals("missing_vehicle") ? null : vehicle; var clicked = new VehiclePreInteractEvent(player, active); listener.onVehiclePreInteract(clicked); - boolean denied = List.of("unknown", "per_type_limit", "total_limit").contains(state); + boolean denied = + List.of("unknown", "per_type_limit", "total_limit", "faction_owned").contains(state); assertEquals(denied, clicked.isCancelled()); - if (!state.equals("already_owned")) { - var claimed = new VehicleOwnerClaimedEvent(player, active, "none", "player_Owner"); - listener.onVehicleOwnerClaimed(claimed); - assertEquals(denied, claimed.isCancelled()); - } assertSame(before, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals(state.equals("already_owned") ? "player_Owner" : "none", ownerData.getOwner()); - if (denied) verify(owner, times(2)).sendMessage(startsWith("§c")); + assertEquals(state.equals("already_owned") ? tag("Owner") : "none", ownerData.getOwner()); + if (denied) verify(owner).sendMessage(startsWith("§c")); + if (state.equals("faction_owned")) verify(owner).sendMessage(VehicleRegistryClaimListener.FACTION_VEHICLE); verify(fixture.ui.plugin, never()).saveVehicleRegistry(); assertEquals( InstallationVehicleService.TryRegisterResult.SKIP, @@ -1932,11 +1740,11 @@ void vehicleSearchShowsOnlyThisInstallationsRecordsAndUsesLiveStoredThenIdNames( when(manager.readStoredVehicle("vehicle-1")) .thenReturn( Optional.of( - new StoredVehicleMeta("vehicle-1", "Stored name", "ironclad", "player_Leader"))); + new StoredVehicleMeta("vehicle-1", "Stored name", "ironclad", tag("Leader")))); assertEquals("Stored name", VehicleFindMessages.resolveVehicleName("vehicle-1")); when(manager.readStoredVehicle("vehicle-1")) .thenReturn( - Optional.of(new StoredVehicleMeta("vehicle-1", " ", "ironclad", "player_Leader"))); + Optional.of(new StoredVehicleMeta("vehicle-1", " ", "ironclad", tag("Leader")))); assertEquals("ironclad", VehicleFindMessages.resolveVehicleName("vehicle-1")); when(manager.readStoredVehicle("vehicle-1")).thenReturn(Optional.empty()); assertEquals("vehicle-1", VehicleFindMessages.resolveVehicleName("vehicle-1")); @@ -2052,30 +1860,31 @@ void spawningFactionVehiclesSynchronizesOnlyResolvableOwnersToTheCurrentLeader(S if (state.equals("missing_owner_data")) when(vehicle.getOwnerData()).thenReturn(null); if (state.equals("removed_installation")) faction.getInstallationHandler().load(List.of()); if (state.equals("legacy_owner")) ownerData.setOwner("faction_home"); - if (state.equals("already_current")) ownerData.setOwner("player_Leader"); + // The fixture keys members by name, so this faction's leader has no character: char_none. + if (state.equals("already_current")) ownerData.setOwner("char_none"); String previous = ownerData.getOwner(); var synchronizer = new InstallationVehicleOwnerSync(registry); var spawn = new VehicleSpawnListener(synchronizer); + net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps.enable(true); spawn.onVehicleSpawn(new VehicleSpawnEvent(vehicle)); boolean change = - List.of("pool", "installation", "legacy_owner", "already_current").contains(state); - assertEquals(change ? "player_Leader" : previous, ownerData.getOwner()); + !List.of("personal", "missing_record", "missing_uuid", "missing_owner_data", "already_current") + .contains(state); + assertEquals(change ? "char_none" : previous, ownerData.getOwner()); + assertEquals(change ? List.of("vehicle-1 char_none RESTAMP") : state.equals("missing_owner_data") + ? List.of("vehicle-1 char_none RESTAMP") : List.of(), framework.writes); spawn.onVehicleSpawn(new VehicleSpawnEvent(null)); synchronizer.syncIfBerthed((ActiveVehicle) null); - synchronizer.applyLeaderOwner((ActiveVehicle) null, faction); - synchronizer.applyLeaderOwner(vehicle, null); - assertEquals(change ? "player_Leader" : previous, ownerData.getOwner()); + assertEquals(change ? "char_none" : previous, ownerData.getOwner()); } @Test void explicitOwnerSynchronizationHandlesAbsentFactionOrOwnerDataWithoutInventingOwnership() { var synchronizer = new InstallationVehicleOwnerSync(registry); - assertEquals("player_none", InstallationVehicleOwnerSync.expectedOwner(null)); - synchronizer.applyLeaderOwner(vehicle, faction); - assertEquals("player_Leader", ownerData.getOwner()); - synchronizer.applyLeaderOwner((OwnerData) null, faction); - synchronizer.applyLeaderOwner(ownerData, null); - assertEquals("player_Leader", ownerData.getOwner()); + InstallationVehicleOwnerSync.stampForFaction("vehicle-1", faction); + assertEquals("char_none", ownerData.getOwner()); + ownerData.setOwner(tag("Leader")); + net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps.enable(true); registry.register( new PlayerVehicleRecord( owner.getUniqueId(), @@ -2085,7 +1894,7 @@ void explicitOwnerSynchronizationHandlesAbsentFactionOrOwnerDataWithoutInventing null, "missing")); synchronizer.syncIfBerthed(vehicle); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals("char_none", ownerData.getOwner(), "A pool whose faction is gone has no leader"); assertEquals( InstallationVehicleService.TryRegisterResult.SKIP, new VehicleRegistryClaimService(registry).tryRegisterOnClaim(null, vehicle).status()); @@ -2118,9 +1927,7 @@ void unberthingReportsRealReleaseOutcomesAndConservesTheFactionRecordOnFailure(S } if (state.equals("battle")) startBattle(); if (state.equals("full")) - storedOwned.add( - new OwnedVehicleSummary( - "other", "Other", "ironclad", Optional.empty(), false, "player_Leader")); + storeOwned("other", "ironclad", "Leader"); if (state.equals("unknown")) { registry.register( new PlayerVehicleRecord( @@ -2136,14 +1943,8 @@ void unberthingReportsRealReleaseOutcomesAndConservesTheFactionRecordOnFailure(S when(fixture.ui.plugin.saveVehicleRegistry()).thenReturn(false); var service = new InstallationVehicleUnberthService(registry); InstallationVehicleUnberthService.UnberthOutcome outcome; - if (state.equals("owner_failure")) { - try (MockedStatic external = mockStatic(VehiclePersistence.class)) { - VehiclePersistence persistence = mock(VehiclePersistence.class); - external.when(VehiclePersistence::current).thenReturn(persistence); - when(persistence.saveLive(vehicle)).thenReturn(false); - outcome = service.unberth(faction, "Leader", port, "vehicle-1"); - } - } else outcome = service.unberth(faction, "Leader", port, "vehicle-1"); + if (state.equals("owner_failure")) framework.forced = Result.SAVE_FAILED; + outcome = service.unberth(faction, ref(leader), port, "vehicle-1"); var expected = switch (state) { case "success" -> InstallationVehicleUnberthService.UnberthResult.OK; @@ -2165,7 +1966,7 @@ void unberthingReportsRealReleaseOutcomesAndConservesTheFactionRecordOnFailure(S assertSame( state.equals("not_berthed") ? originalRecord : before, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); assertEquals( "§cThat vehicle is not berthed at this installation.", InstallationVehicleUnberthService.messageFor(null)); @@ -2242,7 +2043,7 @@ void persistedRegistrationPaymentsRejectNonFiniteRefundAmounts(String amount) th + "\"}}}"); assertThrows(IllegalStateException.class, feeStore::load); assertNull(feeStore.takePaidBuild("station")); - assertEquals("player_Owner", ownerData.getOwner()); + assertEquals(tag("Owner"), ownerData.getOwner()); } @ParameterizedTest @@ -2370,10 +2171,18 @@ void unfinishedBuildAndClaimReceiptsRefuseNewPhysicalActions() { new VehicleRegistrationFeeListener(feeStore, new VehicleFeeConfirmations(), feeStore::save).onBeginVehicleConstruction(build); assertTrue(build.isCancelled()); feeStore.putReceipt("claim:vehicle-1", payment.receipt("build:world:6:64:7")); - var claim = new VehicleOwnerClaimedEvent(owner, vehicle, "none", "player_Owner"); - new VehicleReclaimFeeListener(feeStore, new VehicleFeeConfirmations(), feeStore::save).onVehicleOwnerClaimed(claim); + ownerData.setOwner("none"); + registry.unregister("vehicle-1"); + var claim = new net.tfminecraft.vehicleframework.events.VehicleOwnershipChangeEvent(owner, vehicle, + net.tfminecraft.vehicleframework.api.OwnershipReason.CLAIM, UUID.randomUUID(), ref(owner), false, ref(owner), + null, "none", tag("Owner")); + new net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate(registry, + new VehicleRegistryClaimService(registry), feeStore, new VehicleFeeConfirmations(), feeStore::save, + java.util.logging.Logger.getAnonymousLogger()).onChange(claim); assertTrue(claim.isCancelled()); - assertEquals("player_Owner", ownerData.getOwner()); + verify(owner).sendMessage(net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnershipGate.PAYMENT_UNAVAILABLE); + ownerData.setOwner(tag("Owner")); + assertEquals(tag("Owner"), ownerData.getOwner()); } @Test @@ -2381,7 +2190,7 @@ void handoverWithoutAFeeStillPersistsTheCapturedRecipient() { Player recipient = fixture.player("Recipient"); handoverService.offer(owner, recipient, "vehicle-1", "ironclad"); handoverService.acceptRequest(recipient); - assertEquals("player_Recipient", ownerData.getOwner()); + assertEquals(tag("Recipient"), ownerData.getOwner()); assertEquals("Recipient", feeStore.getLastOwner("vehicle-1")); assertEquals(net.tfminecraft.simplefactions.identity.Members.session(recipient).orElseThrow().ref().characterId(), registry.getByVehicleUuid("vehicle-1").orElseThrow().getPlayerUuid()); @@ -2401,7 +2210,7 @@ void changedCharacterAndUnavailablePaymentsRefusePendingHandovers() { net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments.stop(); handoverService.acceptRequest(recipient); verify(recipient).sendMessage("§cVehicle payment needs staff review or recovery."); - assertEquals("player_Owner", ownerData.getOwner()); + assertEquals(tag("Owner"), ownerData.getOwner()); } @Test @@ -2424,7 +2233,7 @@ void publicVehicleQueriesReportFactionUpkeepAndTheAbsenceOfMilitaryOrPluginState } finally { SimpleFactions.plugin = previous; } - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); } @Test @@ -2434,7 +2243,7 @@ void publicVehicleMessagesRenderAbsentOptionalDetailsAndDoNotReportSuccessfulHan assertEquals( "§cYou need to be a faction leader to transfer vehicles.", VehicleFindMessages.notLeader()); assertNull(VehicleHandoverMessages.forOutcome(null, "Leader")); - var permitted = handoverService.evaluate("Owner", "Leader", "vehicle-1"); + var permitted = handoverService.evaluate(ref(owner), leader.getUniqueId(), "vehicle-1"); assertEquals(VehicleHandoverService.Status.OK, permitted.status()); assertNull(VehicleHandoverMessages.forOutcome(permitted, "Leader")); var unknown = @@ -2468,7 +2277,7 @@ private ActiveStation station() { private void factionVehicle(boolean pool) { arm(pool); sessions.clear(leader.getUniqueId()); - ownerData.setOwner("player_Leader"); + ownerData.setOwner(tag("Leader")); registry.register( new PlayerVehicleRecord( owner.getUniqueId(), @@ -2506,23 +2315,74 @@ private void enableTransferFee() { net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(faction.getBank(), 1000.0); } - private List ownedSummaries(String ownerEntry) { - List all = new ArrayList<>(storedOwned); - for (ActiveVehicle active : loaded.values()) { - OwnerData data = active.getOwnerData(); - if (data != null) - all.add( - new OwnedVehicleSummary( - active.getUUID(), - active.getId(), - active.getId(), - Optional.ofNullable(active.getLocation()), - true, - data.getOwner())); + private static net.tfminecraft.tlibs.character.CharacterRef ref(Player player) { + return net.tfminecraft.simplefactions.identity.Members.session(player).orElseThrow().ref(); + } + + /** The owner value of the character a fixture player of that name plays. */ + private static String tag(String name) { + return "char_" + net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf(name); + } + + /** An unloaded personal vehicle of the named player's character, with its payer row. */ + private void storeOwned(String id, String type, String ownerName) { + stored.put(id, new OwnerFields(tag(ownerName), List.of(), false, false, null)); + storedTypes.put(id, type); + Player player = fixture.online.get(ownerName); + UUID account = player != null ? player.getUniqueId() : UUID.nameUUIDFromBytes(ownerName.getBytes()); + registry.register(new PlayerVehicleRecord( + net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf(ownerName), id, type, + OwnershipMode.PERSONAL, null, null, account)); + } + + /** VehicleFramework's setOwnership over the loaded mocks and the stored map. */ + private final class Framework implements VehicleOwnerApi { + final List writes = new ArrayList<>(); + Result forced; + + @Override + public boolean characters() { + return true; + } + + @Override + public List list() { + List all = new ArrayList<>(); + for (String id : loaded.keySet()) read(id).ifPresent(all::add); + for (String id : stored.keySet()) if (!loaded.containsKey(id)) read(id).ifPresent(all::add); + return all; + } + + @Override + public Optional read(String id) { + ActiveVehicle live = loaded.get(id); + if (live != null && live.getOwnerData() != null) { + OwnerData data = live.getOwnerData(); + OwnerFields fields = new OwnerFields(data.getOwner(), data.getWhiteList(), data.isWhiteListed(), + data.isTicketsEnabled(), data.getTicketId()); + return Optional.of(new OwnerMeta(id, live.getId(), fields.owner(), Optional.of(fields), Optional.of(fields))); + } + OwnerFields fields = stored.get(id); + return fields == null ? Optional.empty() + : Optional.of(new OwnerMeta(id, storedTypes.get(id), fields.owner(), Optional.of(fields), Optional.empty())); + } + + @Override + public Result write(String id, String owner, List whitelist, Reason reason) { + writes.add(id + " " + owner + " " + reason); + if (forced != null) return forced; + ActiveVehicle live = loaded.get(id); + if (live != null && live.getOwnerData() != null) { + live.getOwnerData().setOwner(owner); + if (reason != Reason.RESTAMP && reason != Reason.MIGRATE) live.getOwnerData().setWhiteList(new ArrayList<>()); + return Result.OK; + } + if (stored.containsKey(id)) { + stored.put(id, stored.get(id).withOwnership(owner, List.of())); + return Result.OK; + } + return Result.NOT_FOUND; } - return all.stream() - .filter(v -> ownerEntry == null || ownerEntry.equalsIgnoreCase(v.getOwner())) - .toList(); } private void fillPort() { @@ -2542,7 +2402,8 @@ private void arm(boolean pool) { when(vehicle.getId()).thenReturn(type); originalRecord = new PlayerVehicleRecord( - owner.getUniqueId(), "vehicle-1", type, OwnershipMode.PERSONAL, null); + owner.getUniqueId(), "vehicle-1", type, OwnershipMode.PERSONAL, null, null, + owner.getUniqueId()); registry.register(originalRecord); sessions.put( leader.getUniqueId(), @@ -2558,7 +2419,7 @@ private VehiclePreInteractEvent click() { private void assertPersonalOwnershipUnchanged() { assertSame(originalRecord, registry.getByVehicleUuid("vehicle-1").orElseThrow()); - assertEquals("player_Owner", ownerData.getOwner()); + assertEquals(tag("Owner"), ownerData.getOwner()); assertEquals(1, registry.getAll().size()); } @@ -2573,4 +2434,48 @@ private Field snapshot(Class type, String name) throws Exception { : value); return field; } + + @Test + void aHandoverWhosePayerRowCannotBeWrittenNeverChangesTheOwner() throws Exception { + Player recipient = fixture.player("Recipient"); + handoverService.offer(owner, recipient, "vehicle-1", "ironclad"); + Files.writeString(files.root.resolve("Cache"), "a file where the registry folder should be"); + + RequestManager.accept(recipient); + + assertEquals(tag("Owner"), ownerData.getOwner()); + assertEquals(List.of(), framework.writes); + verify(recipient).sendMessage(VehicleHandoverMessages.ownershipUnavailable()); + verify(owner).sendMessage(VehicleHandoverMessages.ownershipUnavailable()); + } + + @Test + void consentNeedsBothCharactersWhenOfferedAndTheSameOwnerCharacterWhenAccepted() { + arm(false); + try (var members = + mockStatic(net.tfminecraft.simplefactions.identity.Members.class, CALLS_REAL_METHODS)) { + members + .when(() -> net.tfminecraft.simplefactions.identity.Members.session(owner)) + .thenReturn(Optional.empty()); + assertFalse(consent.trySendConsentRequest(leader, owner, faction, port, vehicle)); + verify(leader).sendMessage(VehicleTransferMessages.ownerOffline()); + } + assertTrue(click().isCancelled()); + assertInstanceOf(VehicleTransferConsentRequest.class, RequestManager.getRequest(owner)); + var otherCharacter = + Optional.of( + new net.tfminecraft.tlibs.character.CharacterSession( + new net.tfminecraft.tlibs.character.CharacterRef(owner.getUniqueId(), UUID.randomUUID()), + 2L)); + try (var members = + mockStatic(net.tfminecraft.simplefactions.identity.Members.class, CALLS_REAL_METHODS)) { + members + .when(() -> net.tfminecraft.simplefactions.identity.Members.session(owner)) + .thenReturn(otherCharacter); + consent.acceptRequest(owner); + } + verify(owner).sendMessage("§cThis request belongs to another character."); + assertPersonalOwnershipUnchanged(); + assertEquals(List.of(), framework.writes); + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleSlotGuardTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleSlotGuardTest.java index 597366db..d7027e60 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleSlotGuardTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleSlotGuardTest.java @@ -2,11 +2,9 @@ -import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard; import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard.CanBuildResult; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -20,6 +18,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import net.tfminecraft.simplefactions.guild.TestCharacters; +import net.tfminecraft.simplefactions.identity.Members; import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; class VehicleSlotGuardTest { @@ -30,12 +30,16 @@ class VehicleSlotGuardTest { void setUp() throws IOException { tempDir = Files.createTempDirectory("sf-vehicle-slot-guard-"); registry = new PlayerVehicleRegistry(); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory()); + } + + /** A personal row cached against the account, as every claim writes it. */ + private void own(UUID account, String vehicle, String type) { + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), vehicle, type, OwnershipMode.PERSONAL, null, null, + account)); } @AfterEach void tearDown() throws IOException { - VehicleOwnershipQueries.setSourceForTests(null); if (tempDir != null) { Files.walk(tempDir) .sorted(java.util.Comparator.reverseOrder()) @@ -49,10 +53,6 @@ private void loadConfig(String yaml) throws IOException { VehiclesConfigLoader.load(vehiclesYaml.toFile()); } - private static String playerName(UUID player) { - return "p-" + player.toString().substring(0, 8); - } - @Test void checkCanBuild_okWhenUnderLimits() throws IOException { loadConfig(""" @@ -71,7 +71,7 @@ void checkCanBuild_okWhenUnderLimits() throws IOException { UUID player = UUID.randomUUID(); assertEquals( CanBuildResult.OK, - VehicleSlotGuard.checkCanBuild(playerName(player), "ironclad", registry)); + VehicleSlotGuard.checkCanBuild(player, "ironclad", registry)); } @Test @@ -91,7 +91,7 @@ void checkCanBuild_unknownType() throws IOException { UUID player = UUID.randomUUID(); assertEquals( CanBuildResult.UNKNOWN_TYPE, - VehicleSlotGuard.checkCanBuild(playerName(player), "unknown", registry)); + VehicleSlotGuard.checkCanBuild(player, "unknown", registry)); } @Test @@ -109,13 +109,11 @@ void checkCanBuild_perTypeLimit() throws IOException { """); UUID player = UUID.randomUUID(); - String name = playerName(player); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "cloudskimmer", "player_" + name)); + own(player, "vehicle-1", "cloudskimmer"); assertEquals( CanBuildResult.PER_TYPE_LIMIT, - VehicleSlotGuard.checkCanBuild(name, "cloudskimmer", registry)); + VehicleSlotGuard.checkCanBuild(player, "cloudskimmer", registry)); } @Test @@ -136,20 +134,17 @@ void checkCanBuild_landPerPersonThree() throws IOException { """); UUID player = UUID.randomUUID(); - String name = playerName(player); - FakeOwnedInventory inventory = new FakeOwnedInventory() - .add("vehicle-1", "horse_cart", "player_" + name) - .add("vehicle-2", "horse_cart", "player_" + name); - VehicleOwnershipQueries.setSourceForTests(inventory); + own(player, "vehicle-1", "horse_cart"); + own(player, "vehicle-2", "horse_cart"); assertEquals( CanBuildResult.OK, - VehicleSlotGuard.checkCanBuild(name, "horse_cart", registry)); + VehicleSlotGuard.checkCanBuild(player, "horse_cart", registry)); - inventory.add("vehicle-3", "horse_cart", "player_" + name); + own(player, "vehicle-3", "horse_cart"); assertEquals( CanBuildResult.PER_TYPE_LIMIT, - VehicleSlotGuard.checkCanBuild(name, "horse_cart", registry)); + VehicleSlotGuard.checkCanBuild(player, "horse_cart", registry)); } @Test @@ -177,15 +172,13 @@ void checkCanBuild_totalLimit() throws IOException { """); UUID player = UUID.randomUUID(); - String name = playerName(player); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("vehicle-1", "ironclad", "player_" + name) - .add("vehicle-2", "gunboat", "player_" + name) - .add("vehicle-3", "cruiser", "player_" + name)); + own(player, "vehicle-1", "ironclad"); + own(player, "vehicle-2", "gunboat"); + own(player, "vehicle-3", "cruiser"); assertEquals( CanBuildResult.TOTAL_LIMIT, - VehicleSlotGuard.checkCanBuild(name, "ironclad", registry)); + VehicleSlotGuard.checkCanBuild(player, "ironclad", registry)); } @Test @@ -215,15 +208,13 @@ void checkCanBuild_ignoreLimitSkipsTotal() throws IOException { """); UUID player = UUID.randomUUID(); - String name = playerName(player); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("vehicle-1", "ironclad", "player_" + name) - .add("vehicle-2", "gunboat", "player_" + name) - .add("vehicle-3", "cruiser", "player_" + name)); + own(player, "vehicle-1", "ironclad"); + own(player, "vehicle-2", "gunboat"); + own(player, "vehicle-3", "cruiser"); assertEquals( CanBuildResult.OK, - VehicleSlotGuard.checkCanBuild(name, "coal_car", registry)); + VehicleSlotGuard.checkCanBuild(player, "coal_car", registry)); } @Test @@ -245,15 +236,13 @@ void checkCanBuild_ignoreLimitStillPerType() throws IOException { """); UUID player = UUID.randomUUID(); - String name = playerName(player); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("vehicle-1", "coal_car", "player_" + name) - .add("vehicle-2", "coal_car", "player_" + name) - .add("vehicle-3", "coal_car", "player_" + name)); + own(player, "vehicle-1", "coal_car"); + own(player, "vehicle-2", "coal_car"); + own(player, "vehicle-3", "coal_car"); assertEquals( CanBuildResult.PER_TYPE_LIMIT, - VehicleSlotGuard.checkCanBuild(name, "coal_car", registry)); + VehicleSlotGuard.checkCanBuild(player, "coal_car", registry)); } @Test @@ -273,14 +262,12 @@ void checkCanBuild_unlimitedTotalWhenLimitZero() throws IOException { """); UUID player = UUID.randomUUID(); - String name = playerName(player); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("vehicle-1", "ironclad", "player_" + name) - .add("vehicle-2", "ironclad", "player_" + name)); + own(player, "vehicle-1", "ironclad"); + own(player, "vehicle-2", "ironclad"); assertEquals( CanBuildResult.OK, - VehicleSlotGuard.checkCanBuild(name, "ironclad", registry)); + VehicleSlotGuard.checkCanBuild(player, "ironclad", registry)); } @Test @@ -299,13 +286,11 @@ void checkCanBuild_perTypeCheckedBeforeTotal() throws IOException { """); UUID player = UUID.randomUUID(); - String name = playerName(player); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("vehicle-1", "ironclad", "player_" + name)); + own(player, "vehicle-1", "ironclad"); assertEquals( CanBuildResult.PER_TYPE_LIMIT, - VehicleSlotGuard.checkCanBuild(name, "ironclad", registry)); + VehicleSlotGuard.checkCanBuild(player, "ironclad", registry)); } @Test @@ -325,14 +310,109 @@ void checkCanBuild_excludesBerthedFromPersonalCount() throws IOException { """); UUID player = UUID.randomUUID(); - String name = playerName(player); registry.register(new PlayerVehicleRecord( - player, "vehicle-1", "ironclad", OwnershipMode.INSTALLATION, "port-1")); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("vehicle-1", "ironclad", "player_" + name)); + UUID.randomUUID(), "vehicle-1", "ironclad", OwnershipMode.INSTALLATION, "port-1", null, player)); assertEquals( CanBuildResult.OK, - VehicleSlotGuard.checkCanBuild(name, "ironclad", registry)); + VehicleSlotGuard.checkCanBuild(player, "ironclad", registry)); + } + + private void oneShipEach() throws IOException { + loadConfig(""" + personal-slot-limit: 3 + default-upkeep: 4 + + categories: + ships: + ironclad: + upkeep: 20 + size: 1 + static_emplacements: {} + aircraft: {} + """); + } + + @Test + void anotherAccountsRowsDoNotCount() throws IOException { + oneShipEach(); + UUID player = UUID.randomUUID(); + own(UUID.randomUUID(), "vehicle-1", "ironclad"); + + assertEquals(CanBuildResult.OK, VehicleSlotGuard.checkCanBuild(player, "ironclad", registry)); + } + + @Test + void anOlderRowCountsForTheAccountThatPlaysItsCharacter() throws IOException { + oneShipEach(); + UUID player = UUID.randomUUID(); + var characters = TestCharacters.install(); + UUID mine = Members.characterId(characters.character(player, "Kira")); + UUID theirs = Members.characterId(characters.character(UUID.randomUUID(), "Bran")); + registry.register(new PlayerVehicleRecord(theirs, "vehicle-2", "ironclad", OwnershipMode.PERSONAL, null)); + assertEquals(CanBuildResult.OK, VehicleSlotGuard.checkCanBuild(player, "ironclad", registry)); + + registry.register(new PlayerVehicleRecord(mine, "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null)); + assertEquals(CanBuildResult.PER_TYPE_LIMIT, VehicleSlotGuard.checkCanBuild(player, "ironclad", registry)); + assertEquals(player, VehicleSlotGuard.accountOf(registry.getByVehicleUuid("vehicle-1").orElseThrow())); + } + + @Test + void aRowWhoseAccountIsUnknownCountsAgainstEveryClaimer() throws IOException { + oneShipEach(); + TestCharacters.install(); + registry.register(new PlayerVehicleRecord(null, "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null)); + assertEquals(CanBuildResult.PER_TYPE_LIMIT, + VehicleSlotGuard.checkCanBuild(UUID.randomUUID(), "ironclad", registry)); + + registry.unregister("vehicle-1"); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "vehicle-1", "ironclad", OwnershipMode.PERSONAL, + null)); + assertEquals(CanBuildResult.PER_TYPE_LIMIT, + VehicleSlotGuard.checkCanBuild(UUID.randomUUID(), "ironclad", registry)); + } + + @Test + void aLookupErrorRefusesInsteadOfCountingNothing() throws IOException { + oneShipEach(); + Members.Resolver failing = org.mockito.Mockito.mock(Members.Resolver.class); + org.mockito.Mockito.when(failing.key(org.mockito.ArgumentMatchers.any(UUID.class))).thenCallRealMethod(); + org.mockito.Mockito.when(failing.info(org.mockito.ArgumentMatchers.anyString())) + .thenThrow(new IllegalStateException("off the main thread")); + Members.setResolver(failing); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "vehicle-1", "ironclad", OwnershipMode.PERSONAL, + null)); + + assertEquals(CanBuildResult.UNAVAILABLE, + VehicleSlotGuard.checkCanBuild(UUID.randomUUID(), "ironclad", registry)); + assertEquals(VehicleConstructionMessages.UNAVAILABLE, + VehicleConstructionMessages.forResult(CanBuildResult.UNAVAILABLE, "ironclad")); + } + + @Test + void aPlayerIsCheckedByTheirAccount() throws IOException { + oneShipEach(); + UUID account = UUID.randomUUID(); + org.bukkit.entity.Player player = org.mockito.Mockito.mock(org.bukkit.entity.Player.class); + org.mockito.Mockito.when(player.getUniqueId()).thenReturn(account); + PlayerVehicleRegistry shared = new PlayerVehicleRegistry(); + shared.register(new PlayerVehicleRecord(UUID.randomUUID(), "vehicle-1", "ironclad", OwnershipMode.PERSONAL, + null, null, account)); + try (var plugin = org.mockito.Mockito.mockStatic(net.tfminecraft.simplefactions.SimpleFactions.class)) { + plugin.when(net.tfminecraft.simplefactions.SimpleFactions::getVehicleRegistry).thenReturn(shared); + assertEquals(CanBuildResult.PER_TYPE_LIMIT, VehicleSlotGuard.checkCanBuild(player, "ironclad")); + } + assertEquals(CanBuildResult.UNKNOWN_TYPE, VehicleSlotGuard.checkCanBuild(null, "ironclad")); + assertEquals(CanBuildResult.UNKNOWN_TYPE, VehicleSlotGuard.checkCanBuild(account, "ironclad", null)); + assertEquals(CanBuildResult.UNKNOWN_TYPE, VehicleSlotGuard.checkCanBuild((UUID) null, "ironclad", shared)); + assertEquals(CanBuildResult.UNKNOWN_TYPE, VehicleSlotGuard.checkCanBuild(account, "", shared)); + assertEquals(CanBuildResult.UNKNOWN_TYPE, VehicleSlotGuard.checkCanBuild(account, null, shared)); + } + + @Test + void anUnreadableCountIsExplainedToTheOtherPlayer() { + assertEquals(VehicleConstructionMessages.UNAVAILABLE, FactionVehicleReleaseMessages.forGive( + new FactionVehicleReleaseService.Outcome(FactionVehicleReleaseService.Status.NO_PERSONAL_ROOM, + CanBuildResult.UNAVAILABLE, "ironclad"), "Bob")); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransactionBoundaryCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransactionBoundaryCoverageTest.java index 62946f45..44d158a8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransactionBoundaryCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransactionBoundaryCoverageTest.java @@ -15,6 +15,8 @@ import java.util.Map; import java.util.Set; import java.util.UUID; + +import net.tfminecraft.simplefactions.identity.Members; import java.util.concurrent.atomic.AtomicInteger; import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.government.proposal.FeeKind; @@ -48,7 +50,7 @@ import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolLore; import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService.CanAddResult; -import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; @@ -244,13 +246,12 @@ void releaseErrorsGiveUsefulRecipientsAndConfiguredLimitsWhenOptionalMetadataIsA new Outcome(Status.NO_PERSONAL_ROOM, null, "ironclad"))); assertEquals( CanBuildResult.UNKNOWN_TYPE, - VehicleSlotGuard.checkCanBuild((String) null, "ironclad", rig.registry)); + VehicleSlotGuard.checkCanBuild((UUID) null, "ironclad", rig.registry)); assertEquals( CanBuildResult.UNKNOWN_TYPE, - VehicleSlotGuard.checkCanBuild(" ", "ironclad", rig.registry)); - assertEquals( - CanBuildResult.UNKNOWN_TYPE, VehicleSlotGuard.checkCanBuild("Owner", "ironclad", null)); - CanBuildResult free = VehicleSlotGuard.checkCanBuild("Recipient", "ironclad", rig.registry); + VehicleSlotGuard.checkCanBuild(rig.owner.getUniqueId(), "ironclad", null)); + CanBuildResult free = + VehicleSlotGuard.checkCanBuild(rig.recipient.getUniqueId(), "ironclad", rig.registry); assertEquals(CanBuildResult.OK, free); assertNull( FactionVehicleReleaseMessages.personalRoom(free, "ironclad", "Recipient"), @@ -261,9 +262,11 @@ void releaseErrorsGiveUsefulRecipientsAndConfiguredLimitsWhenOptionalMetadataIsA "An unspecified external slot failure should still give a useful explanation"); assertNull(VehicleConstructionMessages.forResult(null, "ironclad")); assertEquals( - CanBuildResult.UNKNOWN_TYPE, VehicleSlotGuard.checkCanBuild("Owner", "", rig.registry)); + CanBuildResult.UNKNOWN_TYPE, + VehicleSlotGuard.checkCanBuild(rig.owner.getUniqueId(), "", rig.registry)); assertEquals( - CanBuildResult.UNKNOWN_TYPE, VehicleSlotGuard.checkCanBuild("Owner", null, rig.registry)); + CanBuildResult.UNKNOWN_TYPE, + VehicleSlotGuard.checkCanBuild(rig.owner.getUniqueId(), null, rig.registry)); } } @@ -272,14 +275,15 @@ void failedExternalOwnerAssignmentRestoresTheFactionRecordAndReportsTheRetry() t try (Rig rig = new Rig()) { PlayerVehicleRecord original = rig.pool("gift", "field_artillery"); rig.registry.register(original); + var recipient = Members.session(rig.recipient).orElseThrow().ref(); AtomicInteger writes = new AtomicInteger(); AtomicInteger assignments = new AtomicInteger(); FactionVehicleReleaseService service = new FactionVehicleReleaseService( rig.registry, - (id, name) -> { + (id, character) -> { assertEquals("gift", id); - assertEquals("Recipient", name); + assertEquals(recipient.characterId(), character); assignments.incrementAndGet(); return false; }, @@ -287,7 +291,7 @@ void failedExternalOwnerAssignmentRestoresTheFactionRecordAndReportsTheRetry() t writes.incrementAndGet(); return true; }); - Outcome outcome = service.give(rig.faction, "Leader", "Recipient", "gift"); + Outcome outcome = service.give(rig.faction, "Leader", recipient, "gift"); assertEquals(Status.OWNERSHIP_UNAVAILABLE, outcome.status()); assertSame(original, rig.registry.getByVehicleUuid("gift").orElseThrow()); assertEquals(2, writes.get()); @@ -392,15 +396,15 @@ void expiredConsentAndHandoverRequestsCannotMoveOwnershipOrChargeMoney() throws "Harbor", "v1", "ironclad", - rig.owner.getUniqueId(), - rig.leader.getUniqueId()); + Members.session(rig.owner).orElseThrow().ref(), + Members.session(rig.leader).orElseThrow().ref(), + false); var handover = new VehicleHandoverRequest( rig.faction.getOrCreateMainGuild(), "v2", "ironclad", rig.leader.getUniqueId(), - "Leader", rig.recipient.getUniqueId()); RequestManager.addRequest(rig.leader, rig.owner, transfer); RequestManager.addRequest(rig.leader, rig.recipient, handover); @@ -463,8 +467,7 @@ private static final class Rig implements AutoCloseable { snapshot(InstallationConfigLoader.class, name); snapshot(RequestManager.class, "requests").set(null, new HashMap<>()); snapshot(BattleManager.class, "battles").set(null, new ArrayList<>()); - snapshot(VehicleOwnershipQueries.class, "source"); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory()); + FakeOwnerApi.install(); snapshot(VehicleFeeService.class, "factionLookup"); snapshot(VehicleFeeService.class, "playerBank"); VehicleFeeService.setForTests(null, bank); @@ -511,7 +514,7 @@ ActiveVehicle vehicle(String id, String type) { when(vehicle.getUUID()).thenReturn(id); when(vehicle.getId()).thenReturn(type); OwnerData data = new OwnerData(); - data.setOwner("player_Owner"); + data.setOwner("char_" + net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Owner")); when(vehicle.getOwnerData()).thenReturn(data); return vehicle; } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentServiceTest.java index 5c1cfb41..86c10b42 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleTransferConsentServiceTest.java @@ -74,6 +74,19 @@ void setUp() throws IOException { installationVehicleService, registry, sessionManager); + api = net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi.install() + .live("vehicle-1", "ironclad", ownerTag()); + } + + private net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi api; + + private static net.tfminecraft.tlibs.character.CharacterRef ref(UUID account, String name) { + return new net.tfminecraft.tlibs.character.CharacterRef(account, + net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf(name)); + } + + private static String ownerTag() { + return "char_" + net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Owner"); } @AfterEach @@ -122,11 +135,12 @@ void acceptRequest_registersVehicleWhenValidationPasses(String leaderName) { "Harbour", "vehicle-1", "ironclad", - ownerUuid, - leaderUuid); + ref(ownerUuid, "Owner"), + ref(leaderUuid, "Leader"), + false); OwnerData ownerData = new OwnerData(); - ownerData.setOwner("player_Owner"); + ownerData.setOwner(ownerTag()); InstallationVehicleService.VehicleBerthTarget vehicle = berthTarget("vehicle-1", "ironclad", locationAt(0, 64, 0), ownerData); @@ -153,7 +167,8 @@ void acceptRequest_registersVehicleWhenValidationPasses(String leaderName) { PlayerVehicleRecord updated = registry.getByVehicleUuid("vehicle-1").orElseThrow(); assertEquals(OwnershipMode.INSTALLATION, updated.getMode()); - assertEquals("player_" + leaderName, ownerData.getOwner()); + assertEquals(List.of("vehicle-1 " + net.tfminecraft.simplefactions.vehicles.owners.LeaderStamps + .expectedStamp(faction) + " TRANSFER"), api.writes); assertEquals( List.of(VehicleTransferMessages.berthSuccess(installation)), ownerMessages); @@ -196,17 +211,18 @@ void acceptRequest_refusesWhenTheVehicleChangedOwner() { "Harbour", "vehicle-1", "ironclad", - ownerUuid, - leaderUuid); + ref(ownerUuid, "Owner"), + ref(leaderUuid, "Leader"), + false); OwnerData ownerData = new OwnerData(); - ownerData.setOwner("player_Owner"); + ownerData.setOwner(ownerTag()); InstallationVehicleService.VehicleBerthTarget vehicle = berthTarget("vehicle-1", "ironclad", locationAt(0, 64, 0), ownerData); VehicleTransferConsentService spiedService = spy(consentService); doReturn(vehicle).when(spiedService).resolveBerthTarget("vehicle-1"); - doReturn("player_Buyer").when(spiedService).currentOwnerEntry("vehicle-1"); + doReturn("char_" + UUID.randomUUID()).when(spiedService).currentOwnerEntry("vehicle-1"); try (MockedStatic requestManager = mockStatic(RequestManager.class); MockedStatic factionManager = mockStatic(FactionManager.class); @@ -226,7 +242,7 @@ void acceptRequest_refusesWhenTheVehicleChangedOwner() { spiedService.acceptRequest(owner); assertEquals(java.util.Optional.empty(), registry.getByVehicleUuid("vehicle-1")); - assertEquals("player_Owner", ownerData.getOwner()); + assertEquals(ownerTag(), ownerData.getOwner()); assertEquals(List.of(VehicleTransferMessages.ownerChanged()), ownerMessages); } } @@ -243,8 +259,9 @@ void acceptRequest_rejectsWrongAcceptor() { "Harbour", "vehicle-1", "ironclad", - UUID.randomUUID(), - UUID.randomUUID()); + ref(UUID.randomUUID(), "Owner"), + ref(UUID.randomUUID(), "Leader"), + false); try (MockedStatic requestManager = mockStatic(RequestManager.class)) { requestManager.when(() -> RequestManager.getRequest(owner)).thenReturn(request); @@ -266,8 +283,9 @@ void notifyExpired_sendsLockedMessageToOwner() { "Harbour", "vehicle-1", "ironclad", - ownerUuid, - UUID.randomUUID()); + ref(ownerUuid, "Owner"), + ref(UUID.randomUUID(), "Leader"), + false); try (MockedStatic bukkit = mockStatic(org.bukkit.Bukkit.class)) { bukkit.when(() -> org.bukkit.Bukkit.getPlayer(request.getProposerLeaderUuid())) diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java index 8e5f1625..da81f08e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePaymentsTest.java @@ -187,7 +187,7 @@ class VehiclePaymentsTest { @Test void failedPersonalRegistrationPreservesTheOriginalReceipt() { assertEquals(Result.APPLIED, payments.charge("claim:v", "fee", payer, quote())); - assertFalse(payments.personal("v", "ship", null)); + assertFalse(payments.personal("v", "ship", null, null)); assertFalse(payments.ready()); assertNotNull(payments.receipt("claim:v")); } @@ -216,4 +216,9 @@ class VehiclePaymentsTest { assertNull(payments.receipt("claim:v")); assertEquals(9999, Denars.balance(Denars.bank(payer))); } + + @Test void aRowThatCannotBeReplacedStopsVehiclePayments() { + assertFalse(payments.replaceRow("v", null)); + assertFalse(payments.ready()); + } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverServiceTest.java index b5b7702f..be4d1e2f 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverServiceTest.java @@ -13,11 +13,11 @@ import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore; import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverService.Status; -import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; +import net.tfminecraft.tlibs.character.CharacterRef; class VehicleHandoverServiceTest { private Path tempDir; @@ -43,20 +43,23 @@ void setUp() throws IOException { VehiclesConfigLoader.load(vehiclesYaml.toFile()); registry = new PlayerVehicleRegistry(); service = new VehicleHandoverService(registry, new VehicleFeeStore(), () -> {}, (uuid, name) -> true); + api = FakeOwnerApi.install(); } + private FakeOwnerApi api; + private final CharacterRef alice = new CharacterRef(java.util.UUID.randomUUID(), java.util.UUID.randomUUID()); + private final java.util.UUID bob = java.util.UUID.randomUUID(); + @AfterEach void tearDown() throws IOException { - VehicleOwnershipQueries.setSourceForTests(null); Files.walk(tempDir).sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); } @Test void ownerCanHandOverTheirOwnVehicle() { - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("v1", "sloop", "player_Alice")); + api.stored("v1", "sloop", "char_" + alice.characterId()); - VehicleHandoverService.Outcome outcome = service.evaluate("Alice", "Bob", "v1"); + VehicleHandoverService.Outcome outcome = service.evaluate(alice, bob, "v1"); assertEquals(Status.OK, outcome.status()); assertEquals("sloop", outcome.vehicleTypeId()); @@ -64,29 +67,33 @@ void ownerCanHandOverTheirOwnVehicle() { @Test void someoneElsesVehicleIsRefused() { - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("v1", "sloop", "player_Carol")); - - assertEquals(Status.NOT_OWNER, service.evaluate("Alice", "Bob", "v1").status()); + api.stored("v1", "sloop", "char_" + java.util.UUID.randomUUID()); + + assertEquals(Status.NOT_OWNER, service.evaluate(alice, bob, "v1").status()); + // Another character of the same account is a stranger too. + assertEquals(Status.NOT_OWNER, service.evaluate( + new CharacterRef(alice.ownerId(), java.util.UUID.randomUUID()), bob, "v1").status()); + assertEquals(Status.NOT_OWNER, service.evaluate(null, bob, "v1").status()); + assertEquals(Status.NOT_OWNER, service.evaluate(alice, bob, "missing").status()); + assertEquals(Status.NOT_OWNER, service.evaluate(alice, bob, null).status()); } @Test void factionVehiclesOwnedByTheLeaderAreRefused() { - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("v1", "sloop", "player_Alice")); + api.stored("v1", "sloop", "char_" + alice.characterId()); registry.register(new PlayerVehicleRecord( java.util.UUID.randomUUID(), "v1", "sloop", OwnershipMode.POOL, null, "rome")); - assertEquals(Status.NOT_OWNER, service.evaluate("Alice", "Bob", "v1").status()); + assertEquals(Status.NOT_OWNER, service.evaluate(alice, bob, "v1").status()); } @Test void recipientWithoutRoomIsRefused() { - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("v1", "sloop", "player_Alice") - .add("v2", "sloop", "player_Bob")); + api.stored("v1", "sloop", "char_" + alice.characterId()); + registry.register(new PlayerVehicleRecord( + java.util.UUID.randomUUID(), "v2", "sloop", OwnershipMode.PERSONAL, null, null, bob)); - VehicleHandoverService.Outcome outcome = service.evaluate("Alice", "Bob", "v1"); + VehicleHandoverService.Outcome outcome = service.evaluate(alice, bob, "v1"); assertEquals(Status.NO_ROOM, outcome.status()); } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java index ba598118..8a88f937 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleRuntimeLifecycleCoverageTest.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.vehicles.maintenance; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -37,7 +38,6 @@ import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; import net.tfminecraft.vehicleframework.VehicleFramework; -import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; import net.tfminecraft.vehicleframework.events.VehiclePreInteractEvent; import net.tfminecraft.vehicleframework.events.VehicleRepairStartEvent; import net.tfminecraft.vehicleframework.managers.VehicleManager; @@ -71,7 +71,6 @@ class VehicleRuntimeLifecycleCoverageTest { private VehicleMaintenancePayListener payListener; private VehicleManager manager; private MockedStatic framework; - private final List owned = new ArrayList<>(); private final List damage = new ArrayList<>(); private final Map globals = new LinkedHashMap<>(); @@ -92,10 +91,8 @@ void setup() throws Exception { "feeExcludedCategories")) { snapshot(VehiclesConfigLoader.class, name); } - snapshot(VehicleOwnershipQueries.class, "source"); snapshot(VehicleFeeService.class, "factionLookup"); snapshot(VehicleFeeService.class, "playerBank"); - VehicleOwnershipQueries.setSourceForTests(null); configure(20); faction = fixture.saved("home", "Leader"); faction.getOrCreateMainGuild().addMember("Alice"); @@ -135,14 +132,6 @@ void setup() throws Exception { new VehicleMaintenancePayListener( sessions, new VehicleMaintenancePayService(store)); manager = mock(VehicleManager.class); - when(manager.listAllPlayerOwnedVehicles()).thenAnswer(call -> new ArrayList<>(owned)); - when(manager.listOwnedVehicles(anyString())) - .thenAnswer( - call -> - owned.stream() - .filter( - vehicle -> call.getArgument(0).equalsIgnoreCase(vehicle.getOwner())) - .toList()); framework = mockStatic(VehicleFramework.class); framework.when(VehicleFramework::getVehicleManager).thenReturn(manager); } @@ -343,72 +332,13 @@ void liveDecayAdapterPassesFractionsToVehicleFrameworkOnlyWhenAvailable() { } @Test - void ownershipQueriesPreserveValidProviderRowsAndExcludeFactionRegistrations() { - OwnedVehicleSummary personal = summary("personal", "ironclad", "player_Alice"); - OwnedVehicleSummary ignored = summary("ignored", "raft", "player_Alice"); - OwnedVehicleSummary factionOwned = summary("pool", "ironclad", "player_Alice"); - registry.register( - new PlayerVehicleRecord( - player.getUniqueId(), "pool", "ironclad", OwnershipMode.POOL, null, "home")); - when(manager.listOwnedVehicles("player_Alice")) - .thenReturn( - Arrays.asList( - null, summary(null, "ironclad", "player_Alice"), personal, ignored, factionOwned)); - when(manager.listAllPlayerOwnedVehicles()) - .thenReturn(Arrays.asList(null, personal, factionOwned)); - - assertEquals( - List.of(personal, ignored), VehicleOwnershipQueries.personalVehicles("Alice", registry)); - assertEquals(List.of(personal), VehicleOwnershipQueries.allPersonalVehicles(registry)); - assertEquals( - List.of(personal, factionOwned), VehicleOwnershipQueries.allPersonalVehicles(null)); - assertEquals(1, VehicleOwnershipQueries.countOfType(List.of(personal, ignored), "IRONCLAD")); - assertEquals(1, VehicleOwnershipQueries.countExcludingIgnoreLimit(List.of(personal, ignored))); - assertEquals( - 0, - VehicleOwnershipQueries.countOfType( - List.of(summary("unknown", null, "player_Alice")), "ironclad")); - assertEquals(0, VehicleOwnershipQueries.countOfType(null, "ironclad")); - assertEquals(0, VehicleOwnershipQueries.countOfType(List.of(personal), null)); - assertEquals(0, VehicleOwnershipQueries.countOfType(List.of(personal), "")); - assertEquals(0, VehicleOwnershipQueries.countExcludingIgnoreLimit(null)); - for (String name : Arrays.asList(null, "", " ")) { - assertEquals("player_none", VehicleOwnershipQueries.ownerEntry(name)); - assertTrue(VehicleOwnershipQueries.personalVehicles(name, registry).isEmpty()); - assertFalse(VehicleOwnershipQueries.isPlayerOwner(name)); - assertNull(VehicleOwnershipQueries.playerNameFromOwner(name)); - } - for (String owner : List.of("player_", "player_none", "faction_home")) { - assertFalse(VehicleOwnershipQueries.isPlayerOwner(owner)); - assertNull(VehicleOwnershipQueries.playerNameFromOwner(owner)); - } - assertTrue(VehicleOwnershipQueries.isPlayerOwner("PLAYER_Alice")); - assertEquals("Alice", VehicleOwnershipQueries.playerNameFromOwner("PLAYER_Alice")); - when(manager.listOwnedVehicles("player_Alice")).thenReturn(null); - assertTrue(VehicleOwnershipQueries.personalVehicles("Alice", registry).isEmpty()); - framework - .when(VehicleFramework::getVehicleManager) - .thenThrow(new LinkageError("plugin absent")); - assertTrue(VehicleOwnershipQueries.personalVehicles("Alice", registry).isEmpty()); - assertTrue(VehicleOwnershipQueries.allPersonalVehicles(registry).isEmpty()); - } - - @Test - void ownershipNameResolutionUsesTheRealOnlineOrOfflineProviderContract() { + void legacyOwnerNamesResolveThroughDenarEconomysRememberedNames() { UUID offlineId = UUID.randomUUID(); - OfflinePlayer offline = mock(OfflinePlayer.class); - when(offline.getName()).thenReturn("OfflineAlice"); - when(Bukkit.getOfflinePlayer(offlineId)).thenReturn(offline); - assertEquals("Alice", VehicleOwnershipQueries.resolvePlayerName(player.getUniqueId())); - assertEquals("OfflineAlice", VehicleOwnershipQueries.resolvePlayerName(offlineId)); - assertNull(VehicleOwnershipQueries.resolvePlayerName(null)); try (MockedStatic identities = mockStatic(OfflineModifier.class)) { identities.when(() -> OfflineModifier.playerId("OfflineAlice")).thenReturn(offlineId); assertEquals(offlineId, VehicleOwnershipQueries.resolvePlayerUuid("OfflineAlice")); assertNull(VehicleOwnershipQueries.resolvePlayerUuid("Unknown")); } - when(Bukkit.getServer()).thenReturn(null); - assertNull(VehicleOwnershipQueries.resolvePlayerName(player.getUniqueId())); } @@ -487,9 +417,10 @@ void projectionIncludesCurrentLawTaxOnceThenUsesSettledAmountsWithoutDoubleCount Player taxpayer = fixture.player("Taxpayer"); UUID character = net.tfminecraft.simplefactions.identity.Members.session(taxpayer).orElseThrow().ref().characterId(); taxed.getVehicleFeeHandler().setRate(FeeKind.VEHICLE_TAX, null, 50); - owned.add(summary("personal-taxable", "ironclad", "player_Taxpayer")); - owned.add(summary("pooled-exempt", "ironclad", "player_Taxpayer")); - owned.add(summary("free-exempt", "raft", "player_Taxpayer")); + FakeOwnerApi.install() + .stored("personal-taxable", "ironclad", "player_Taxpayer") + .stored("pooled-exempt", "ironclad", "player_Taxpayer") + .stored("free-exempt", "raft", "player_Taxpayer"); registry.register( new PlayerVehicleRecord( character, "pooled-exempt", "ironclad", OwnershipMode.POOL, "taxed")); @@ -631,9 +562,6 @@ private ActiveVehicle vehicle(String id, String type) { return vehicle; } - private static OwnedVehicleSummary summary(String id, String type, String owner) { - return new OwnedVehicleSummary(id, type, type, Optional.empty(), false, owner); - } private void snapshot(Class type, String name) throws Exception { Field field = type.getDeclaredField(name); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java index 55428069..1fb634a5 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleSettlementTest.java @@ -1,5 +1,6 @@ package net.tfminecraft.simplefactions.vehicles.maintenance; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -42,14 +43,13 @@ class VehicleSettlementTest { MockedStatic members; MockedStatic fees; MockedStatic pools; - FakeOwnedInventory inventory; + FakeOwnerApi inventory; @BeforeEach void setup() throws Exception { Path config = folder.resolve("vehicles.yml"); Files.writeString(config, "categories:\n ships:\n ship:\n upkeep: 20\n size: 1\n free:\n upkeep: 0\n size: 1\n"); VehiclesConfigLoader.load(config.toFile()); - inventory = new FakeOwnedInventory(); - VehicleOwnershipQueries.setSourceForTests(inventory); + inventory = FakeOwnerApi.install(); denars = mockStatic(Denars.class, CALLS_REAL_METHODS); denars.when(() -> Denars.balance(anyString())).thenReturn(10000L); members = mockStatic(Members.class, CALLS_REAL_METHODS); @@ -63,13 +63,12 @@ class VehicleSettlementTest { @AfterEach void close() { pools.close(); fees.close(); members.close(); denars.close(); - VehicleOwnershipQueries.setSourceForTests(null); } PlayerVehicleRecord vehicle(String id, String type, UUID character, OwnershipMode mode) { var record = new PlayerVehicleRecord(character, id, type, mode, "fort", "realm"); registry.register(record); - inventory.add(id, type, "player_UnrelatedAccountTag"); + inventory.stored(id, type, "player_UnrelatedAccountTag"); return record; } @@ -149,7 +148,7 @@ Faction faction() { } @Test void missingRegistryCharacterNeverFallsBackToVehicleFrameworkOwner() { - inventory.add("unknown", "ship", "player_CurrentAccount"); + inventory.stored("unknown", "ship", "player_CurrentAccount"); vehicle("missing", "ship", UUID.randomUUID(), OwnershipMode.PERSONAL); vehicle("free", "free", payer.characterId(), OwnershipMode.PERSONAL); maintenance.markUnpaid("free", 1); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java index 0825be18..84af387b 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjectionTest.java @@ -2,7 +2,7 @@ -import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; @@ -57,13 +57,11 @@ void setUp() throws IOException { registry = new PlayerVehicleRegistry(); playerUuid = UUID.randomUUID(); registry.register(new PlayerVehicleRecord(playerUuid, "v1", "monoplane", OwnershipMode.PERSONAL, null)); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("v1", "monoplane", "player_Alice")); + FakeOwnerApi.install().stored("v1", "monoplane", "player_Alice"); } @AfterEach void tearDown() throws IOException { - VehicleOwnershipQueries.setSourceForTests(null); if (tempDir != null) { Files.walk(tempDir) .sorted(java.util.Comparator.reverseOrder()) @@ -83,9 +81,9 @@ void absentIdentityAndLedgerHaveNoProjectedCost() { void projectedDailyUpkeep_sumsPersonalVehiclesOnly() { registry.register(new PlayerVehicleRecord( playerUuid, "v2", "monoplane", OwnershipMode.INSTALLATION, "port-1")); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("v1", "monoplane", "player_Alice") - .add("v2", "monoplane", "player_Alice")); + FakeOwnerApi.install() + .stored("v1", "monoplane", "player_Alice") + .stored("v2", "monoplane", "player_Alice"); assertEquals(8.0, VehicleUpkeepProjection.projectedDailyUpkeep(playerUuid.toString(), registry)); } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java index 0ceb1c31..5ed4dfd7 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepServiceTest.java @@ -2,7 +2,7 @@ -import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; @@ -78,12 +78,11 @@ void setUp() throws IOException { store = new VehicleMaintenanceStore(); decayApi = new RecordingDecayApi(); service = new VehicleUpkeepService(registry, economyManager, bank, store, decayApi); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory()); + FakeOwnerApi.install(); } @AfterEach void tearDown() throws IOException { - VehicleOwnershipQueries.setSourceForTests(null); if (tempDir != null) { Files.walk(tempDir) .sorted(java.util.Comparator.reverseOrder()) @@ -115,8 +114,7 @@ void warnsPlayerWhoseBankCannotCoverUpkeep() { UUID playerUuid = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Alice"); bank.setBalance(playerUuid, 5.0); bank.remember("Alice", playerUuid); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); + FakeOwnerApi.install().stored("vehicle-1", "ironclad", "player_Alice"); Player alice = onlinePlayer("Alice", playerUuid); registry.register(new PlayerVehicleRecord(playerUuid, "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null)); @@ -131,8 +129,7 @@ void doesNotWarnWhenBankCoversUpkeep() { UUID playerUuid = net.tfminecraft.simplefactions.testsupport.NameKeyedMembers.characterOf("Alice"); bank.setBalance(playerUuid, 20.0); bank.remember("Alice", playerUuid); - VehicleOwnershipQueries.setSourceForTests( - new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice")); + FakeOwnerApi.install().stored("vehicle-1", "ironclad", "player_Alice"); Player alice = onlinePlayer("Alice", playerUuid); registry.register(new PlayerVehicleRecord(playerUuid, "vehicle-1", "ironclad", OwnershipMode.PERSONAL, null)); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStampTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStampTest.java new file mode 100644 index 00000000..f04dd79e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStampTest.java @@ -0,0 +1,216 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; + +import org.bukkit.Location; +import org.bukkit.World; +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.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.testsupport.NameKeyedMembers; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeConfirmations; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore; +import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleRegistrationFeeListener; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; +import net.tfminecraft.vfbuilders.core.ActiveStation; +import net.tfminecraft.vfbuilders.core.Blueprint; +import net.tfminecraft.vfbuilders.events.VehicleConstructEvent; + +class ConstructionStampTest { + @TempDir Path folder; + private final VehicleFeeStore store = new VehicleFeeStore(); + private final UUID account = UUID.randomUUID(); + private final UUID payer = NameKeyedMembers.characterOf("Builder"); + private final Logger logger = mock(Logger.class); + private FakeOwnerApi api; + private VehiclePayments payments; + private MockedStatic paymentsStatic; + private MockedStatic plugin; + private MockedStatic bukkit; + private ActiveStation station; + private ActiveVehicle vehicle; + private VehicleRegistrationFeeListener listener; + + @BeforeEach + void setUp() { + bukkit = mockStatic(org.bukkit.Bukkit.class); + store.bind(folder.toFile()); + api = FakeOwnerApi.install().live("ship-1", "ship", "none"); + payments = mock(VehiclePayments.class); + when(payments.personal(anyString(), anyString(), any(), any())).thenReturn(true); + paymentsStatic = mockStatic(VehiclePayments.class); + paymentsStatic.when(VehiclePayments::current).thenReturn(payments); + SimpleFactions instance = mock(SimpleFactions.class); + when(instance.getLogger()).thenReturn(logger); + plugin = mockStatic(SimpleFactions.class); + plugin.when(SimpleFactions::getInstance).thenReturn(instance); + station = mock(ActiveStation.class); + World world = mock(World.class); + when(world.getName()).thenReturn("world"); + when(station.getLocation()).thenReturn(new Location(world, 6, 64, 7)); + vehicle = mock(ActiveVehicle.class); + when(vehicle.getUUID()).thenReturn("ship-1"); + when(vehicle.getId()).thenReturn("ship"); + store.beginBuild("world:6:64:7", payer.toString()); + listener = new VehicleRegistrationFeeListener(store, new VehicleFeeConfirmations(), () -> {}); + VehicleQuarantine.use(new VehicleQuarantine(folder)); + } + + @AfterEach + void tearDown() { + plugin.close(); + paymentsStatic.close(); + bukkit.close(); + ConstructionStamp.resetProbe(); + } + + private VehicleConstructEvent built(UUID constructorCharacter) { + return new VehicleConstructEvent(account, constructorCharacter, vehicle, mock(Blueprint.class), + station.getLocation(), station); + } + + @Test + void theBuilderWhoPaidOwnsTheirVehicleWithAnEmptyCrew() { + listener.onVehicleConstruct(built(payer)); + + assertEquals("char_" + payer, api.owner("ship-1")); + assertEquals(List.of("ship-1 char_" + payer + " CONSTRUCT"), api.writes); + verify(payments).personal("ship-1", "ship", payer, null); + verify(payments).complete("build:world:6:64:7", null); + assertEquals("Builder", store.getLastOwner("ship-1")); + assertEquals(List.of(), VehicleQuarantine.current().list()); + } + + @Test + void aPaidReceiptNamesThePayerAndItsAccount() { + UUID payerAccount = UUID.randomUUID(); + when(payments.receipt("build:world:6:64:7")).thenReturn(new VehiclePayments.Receipt("r", + new CharacterRef(payerAccount, payer), "red", UUID.randomUUID(), FeeKind.REGISTRATION_FEE, 100)); + + listener.onVehicleConstruct(built(payer)); + + verify(payments).personal("ship-1", "ship", payer, payerAccount); + } + + @Test + void aPayerWhoIsNotTheConstructorLeavesTheVehicleToNobodyAndListsItForStaff() { + UUID constructor = UUID.randomUUID(); + + listener.onVehicleConstruct(built(constructor)); + + assertEquals("char_none", api.owner("ship-1")); + VehicleQuarantine.Entry entry = VehicleQuarantine.current().list().getFirst(); + assertEquals("ship-1", entry.vehicle()); + assertEquals("char_none", entry.owner()); + assertTrue(entry.reason().contains(payer + " is not the constructor " + constructor)); + verify(payments).complete("build:world:6:64:7", null); + verify(logger).warning(contains("held for staff")); + } + + @Test + void anOlderStationWithoutACharacterIsCheckedAgainstTheAccountsHeir() { + try (MockedStatic characters = mockStatic(Characters.class)) { + characters.when(() -> Characters.legacyHeir(account)).thenReturn(Optional.of(new CharacterRef(account, payer))); + listener.onVehicleConstruct(built(null)); + assertEquals("char_" + payer, api.owner("ship-1")); + + characters.when(() -> Characters.legacyHeir(account)) + .thenReturn(Optional.of(new CharacterRef(UUID.randomUUID(), payer))); + assertNull(ConstructionStamp.constructor(built(null)), "A foreign heir is not the constructor"); + } + assertNull(ConstructionStamp.constructor(new VehicleConstructEvent(null, null, vehicle, mock(Blueprint.class), + station.getLocation(), station))); + } + + @Test + void aFailedStampTakesThePayerRowBackAndKeepsTheBuildReceiptOpen() { + api.forced = Result.SAVE_FAILED; + + listener.onVehicleConstruct(built(payer)); + + verify(payments).replaceRow("ship-1", null); + verify(payments, never()).complete(anyString(), any()); + verify(logger).warning(contains("build receipt build:world:6:64:7 stays open")); + assertNull(store.getLastOwner("ship-1")); + // Unowned but held, so nobody claims it while the paid build is unresolved. + assertTrue(VehicleQuarantine.held("ship-1")); + assertEquals("none", VehicleQuarantine.current().list().getFirst().owner()); + } + + @Test + void aPayerRowThatCannotBeWrittenStopsBeforeAnyStamp() { + when(payments.personal(anyString(), anyString(), any(), any())).thenReturn(false); + listener.onVehicleConstruct(built(payer)); + store.endBuild("world:6:64:7"); + listener.onVehicleConstruct(built(payer)); + assertEquals(List.of(), api.writes); + } + + @Test + void aQuarantineThatCannotBeSavedIsLoggedAndTheVehicleStaysHeld() throws Exception { + java.nio.file.Files.writeString(folder.resolve("vehicles"), "a file where the folder should be"); + listener.onVehicleConstruct(built(UUID.randomUUID())); + assertEquals("char_none", api.owner("ship-1")); + verify(logger).warning(contains("Could not list quarantined vehicle ship-1")); + + VehicleQuarantine.use(null); + api.live("ship-1", "ship", "none"); + store.beginBuild("world:6:64:7", payer.toString()); + listener.onVehicleConstruct(built(UUID.randomUUID())); + assertEquals("char_none", api.owner("ship-1")); + plugin.when(SimpleFactions::getInstance).thenReturn(null); + api.live("ship-1", "ship", "none"); + store.beginBuild("world:6:64:7", payer.toString()); + listener.onVehicleConstruct(built(UUID.randomUUID())); + verify(logger, org.mockito.Mockito.times(2)).warning(contains("held for staff")); + } + + @Test + void anOlderVFBuildersWithoutCharactersIsDetected() { + assertTrue(ConstructionStamp.characterApi()); + assertTrue(ConstructionStamp.hasCharacter(VehicleConstructEvent.class)); + assertEquals(false, ConstructionStamp.hasCharacter(Object.class)); + } + + @Test + void noBuildStartsUntilTheVehicleStageRunsInCharactersMode() { + org.bukkit.entity.Player builder = mock(org.bukkit.entity.Player.class); + when(builder.getName()).thenReturn("Builder"); + Blueprint blueprint = mock(Blueprint.class); + when(blueprint.getId()).thenReturn("ship"); + var begin = new net.tfminecraft.vfbuilders.events.BeginVehicleConstructionEvent(builder, blueprint, station, + station.getLocation()); + + listener.onBeginVehicleConstruction(begin); + + assertTrue(begin.isCancelled()); + verify(builder).sendMessage(VehicleRegistrationFeeListener.OWNERSHIP_STARTING); + verify(payments, never()).charge(anyString(), anyString(), any(), any()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/FakeOwnerApi.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/FakeOwnerApi.java new file mode 100644 index 00000000..45278d40 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/FakeOwnerApi.java @@ -0,0 +1,87 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** VehicleFramework's ownership store in memory, with the same whitelist rules per reason. */ +public final class FakeOwnerApi implements VehicleOwnerApi { + public boolean characters = true; + public final Map vehicles = new LinkedHashMap<>(); + public final List writes = new ArrayList<>(); + /** When set, every write answers this without changing anything. */ + public Result forced; + public RuntimeException failure; + + /** A fresh store that every SimpleFactions owner write now goes to. */ + public static FakeOwnerApi install() { + FakeOwnerApi api = new FakeOwnerApi(); + VehicleOwnerWrites.use(api); + return api; + } + + public static OwnerFields fields(String owner, String... whitelist) { + return new OwnerFields(owner, List.of(whitelist), false, false, null); + } + + /** A stored, unloaded vehicle. */ + public FakeOwnerApi stored(String vehicle, String type, String owner, String... whitelist) { + vehicles.put(vehicle, new OwnerMeta(vehicle, type, owner, Optional.of(fields(owner, whitelist)), Optional.empty())); + return this; + } + + /** A loaded vehicle whose stored row matches it. */ + public FakeOwnerApi live(String vehicle, String type, String owner, String... whitelist) { + OwnerFields fields = fields(owner, whitelist); + vehicles.put(vehicle, new OwnerMeta(vehicle, type, owner, Optional.of(fields), Optional.of(fields))); + return this; + } + + public String owner(String vehicle) { + OwnerMeta meta = vehicles.get(vehicle); + return meta == null ? null : meta.currentOwner(); + } + + @Override + public boolean characters() { + return characters; + } + + @Override + public List list() { + if (failure != null) throw failure; + return new ArrayList<>(vehicles.values()); + } + + @Override + public Optional read(String vehicle) { + if (failure != null) throw failure; + return Optional.ofNullable(vehicles.get(vehicle)); + } + + @Override + public Result write(String vehicle, String owner, List whitelist, Reason reason) { + writes.add(vehicle + " " + owner + " " + reason); + if (failure != null) throw failure; + if (forced != null) return forced; + if (!characters) return Result.REFUSED_MODE; + OwnerMeta meta = vehicles.get(vehicle); + if (meta == null) return Result.NOT_FOUND; + vehicles.put(vehicle, new OwnerMeta(vehicle, meta.typeId(), meta.hasStoredRow() ? owner : null, + meta.stored().map(fields -> apply(fields, owner, whitelist, reason)), + meta.live().map(fields -> apply(fields, owner, whitelist, reason)))); + return Result.OK; + } + + static OwnerFields apply(OwnerFields fields, String owner, List whitelist, Reason reason) { + return switch (reason) { + case MIGRATE -> fields.withOwnership(owner, whitelist == null ? fields.whitelist() : whitelist); + case RESTAMP -> fields.withOwnership(owner, fields.whitelist()); + case TRANSFER -> fields.withOwnership(owner, List.of()); + case CONSTRUCT -> new OwnerFields(owner, List.of(), true, fields.ticketsEnabled(), fields.ticketId()); + case RELEASE, HANDOVER -> new OwnerFields(owner, List.of(), false, fields.ticketsEnabled(), fields.ticketId()); + }; + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStampsTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStampsTest.java new file mode 100644 index 00000000..28a3c8ed --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStampsTest.java @@ -0,0 +1,269 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; +import org.bukkit.scheduler.BukkitScheduler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.events.FactionLeaderChangedEvent; +import net.tfminecraft.simplefactions.installation.Installation; +import net.tfminecraft.simplefactions.installation.InstallationKind; +import net.tfminecraft.simplefactions.installation.handler.InstallationHandler; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; + +class LeaderStampsTest { + private final PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); + private final UUID kira = UUID.randomUUID(); + private FakeOwnerApi api; + private List previousFactions; + private Faction red; + + @BeforeEach + void setUp() { + api = FakeOwnerApi.install(); + previousFactions = FactionManager.factions; + FactionManager.factions = new ArrayList<>(); + red = faction("red", kira.toString()); + LeaderStamps.enable(true); + } + + @AfterEach + void tearDown() { + FactionManager.factions = previousFactions; + } + + private Faction faction(String id, String leader) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + when(faction.getLeader()).thenReturn(leader); + FactionManager.factions.add(faction); + return faction; + } + + private PlayerVehicleRecord pool(String vehicle, String faction) { + PlayerVehicleRecord record = new PlayerVehicleRecord(UUID.randomUUID(), vehicle, "gun", OwnershipMode.POOL, + null, faction); + registry.register(record); + return record; + } + + @Test + void theStampIsTheLeadersCharacterOrNobody() { + assertEquals("char_" + kira, LeaderStamps.expectedStamp(red)); + assertEquals("char_none", LeaderStamps.expectedStamp(faction("dummy", "dummy_4"))); + assertEquals("char_none", LeaderStamps.expectedStamp(faction("vacant", ""))); + assertEquals("char_none", LeaderStamps.expectedStamp(faction("unset", null))); + assertEquals("char_none", LeaderStamps.expectedStamp(null)); + assertEquals("char_" + kira, LeaderStamps.expected(pool("gun", "red"))); + assertEquals("char_none", LeaderStamps.expected(pool("orphan", "gone"))); + } + + @Test + void onlyInstallationAndPoolRowsAreFactionRows() { + assertTrue(LeaderStamps.isFactionRow(pool("gun", "red"))); + assertTrue(LeaderStamps.isFactionRow(new PlayerVehicleRecord(null, "ship", "ship", OwnershipMode.INSTALLATION, + "port", "red"))); + assertFalse(LeaderStamps.isFactionRow(new PlayerVehicleRecord(null, "cart", "cart", OwnershipMode.PERSONAL, null))); + assertFalse(LeaderStamps.isFactionRow(null)); + assertFalse(LeaderStamps.isStale(null, "anything")); + assertTrue(LeaderStamps.isStale(pool("gun", "red"), "char_" + UUID.randomUUID())); + assertFalse(LeaderStamps.isStale(pool("gun", "red"), "char_" + kira)); + } + + @Test + void aRestampWritesOnlyWhenTheVehicleCarriesSomethingElse() { + api.stored("gun", "gun", "char_" + kira).stored("stale", "gun", "player_Old", "char_x;y;Crew"); + + assertEquals(Result.OK, LeaderStamps.restamp(pool("gun", "red"))); + assertEquals(Result.OK, LeaderStamps.restamp(pool("stale", "red"))); + assertEquals(Result.NOT_FOUND, LeaderStamps.restamp(pool("missing", "red"))); + + assertEquals(List.of("stale char_" + kira + " RESTAMP"), api.writes); + assertEquals(List.of("char_x;y;Crew"), api.vehicles.get("stale").current().orElseThrow().whitelist(), + "A restamp keeps the crew"); + } + + @Test + void aLeaderChangeRestampsThatFactionsRowsOnlyOnceTheStageHasFinished() { + UUID next = UUID.randomUUID(); + faction("blue", UUID.randomUUID().toString()); + pool("gun", "red"); + pool("other", "blue"); + InstallationHandler handler = mock(InstallationHandler.class); + when(handler.getById("port")).thenReturn(new Installation("port", "Port", InstallationKind.PORT, 1, 0, 0, 0L)); + when(red.getInstallationHandler()).thenReturn(handler); + registry.register(new PlayerVehicleRecord(null, "legacy", "ship", OwnershipMode.INSTALLATION, "port", null)); + registry.register(new PlayerVehicleRecord(null, "cart", "cart", OwnershipMode.PERSONAL, null)); + api.stored("gun", "gun", "char_" + kira).stored("other", "gun", "char_" + kira) + .stored("legacy", "ship", "char_" + kira).stored("cart", "cart", "char_" + kira); + when(red.getLeader()).thenReturn(next.toString()); + + LeaderStamps.enable(false); + LeaderStamps.onLeaderChanged(red, registry); + assertEquals(List.of(), api.writes, "Changes before the stage finishes are left to the boot reconcile"); + + LeaderStamps.enable(true); + LeaderStamps.onLeaderChanged(red, registry); + LeaderStamps.onLeaderChanged(null, registry); + LeaderStamps.onLeaderChanged(red, null); + assertEquals(List.of("gun char_" + next + " RESTAMP", "legacy char_" + next + " RESTAMP"), + api.writes.stream().sorted().toList()); + assertEquals("char_" + kira, api.owner("other")); + assertTrue(LeaderStamps.enabled()); + } + + @Test + void aDeletedFactionsRemainingRowsLoseTheirLeader() { + SimpleFactions plugin = mock(SimpleFactions.class); + Logger logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + try (MockedStatic sf = mockStatic(SimpleFactions.class)) { + sf.when(SimpleFactions::getInstance).thenReturn(plugin); + sf.when(SimpleFactions::getVehicleRegistry).thenReturn(registry); + pool("gun", "red"); + pool("lost", "red"); + api.stored("gun", "gun", "char_" + kira); + + LeaderStamps.onFactionDeleted(red); + + assertEquals("char_none", api.owner("gun")); + verify(logger).warning(contains("lost (NOT_FOUND)")); + LeaderStamps.enable(false); + LeaderStamps.onFactionDeleted(red); + LeaderStamps.enable(true); + LeaderStamps.onFactionDeleted(null); + verify(logger, times(1)).warning(any(String.class)); + } + LeaderStamps.onFactionDeleted(red); + } + + @Test + void theBootReconcileStampsEveryFactionRowAndAbortsOnAFailedWrite() throws IOException { + pool("gun", "red"); + pool("gone", "red"); + registry.register(new PlayerVehicleRecord(null, "cart", "cart", OwnershipMode.PERSONAL, null)); + api.stored("gun", "gun", "player_Old").stored("cart", "cart", "player_Old"); + + LeaderStamps.reconcile(registry); + assertEquals(List.of("gun char_" + kira + " RESTAMP"), api.writes); + + api.stored("gun", "gun", "player_Old"); + api.forced = Result.SAVE_FAILED; + IOException failure = assertThrows(IOException.class, () -> LeaderStamps.reconcile(registry)); + assertTrue(failure.getMessage().contains("SAVE_FAILED")); + } + + @Test + void aStaleClickSchedulesOneRestampPerVehicleForTheNextTick() { + SimpleFactions plugin = mock(SimpleFactions.class); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + PlayerVehicleRecord gun = pool("gun", "red"); + api.stored("gun", "gun", "player_Old"); + try (MockedStatic sf = mockStatic(SimpleFactions.class); + MockedStatic bukkit = mockStatic(Bukkit.class)) { + sf.when(SimpleFactions::getInstance).thenReturn(plugin); + sf.when(SimpleFactions::getVehicleRegistry).thenReturn(registry); + bukkit.when(Bukkit::getScheduler).thenReturn(scheduler); + + LeaderStamps.scheduleRestamp(gun); + LeaderStamps.scheduleRestamp(gun); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler, times(1)).runTask(any(org.bukkit.plugin.Plugin.class), task.capture()); + task.getValue().run(); + assertEquals("char_" + kira, api.owner("gun")); + + LeaderStamps.scheduleRestamp(gun); + verify(scheduler, times(2)).runTask(any(org.bukkit.plugin.Plugin.class), task.capture()); + registry.unregister("gun"); + task.getValue().run(); + LeaderStamps.scheduleRestamp(gun); + verify(scheduler, times(3)).runTask(any(org.bukkit.plugin.Plugin.class), task.capture()); + LeaderStamps.enable(false); + task.getValue().run(); + LeaderStamps.scheduleRestamp(gun); + verify(scheduler, times(3)).runTask(any(org.bukkit.plugin.Plugin.class), any(Runnable.class)); + assertEquals(1, api.writes.size()); + } + LeaderStamps.enable(true); + LeaderStamps.scheduleRestamp(gun); + } + + @Test + void factionLeaderChangesAreAnnouncedOnlyWhenTheLeaderReallyChanges() { + PluginManager plugins = mock(PluginManager.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + FactionLeaderChangedEvent.fire(mock(Faction.class), "a", "b"); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + Faction faction = mock(Faction.class); + FactionLeaderChangedEvent.fire(faction, "a", "a"); + FactionLeaderChangedEvent.fire(faction, null, null); + verify(plugins, never()).callEvent(any(Event.class)); + + FactionLeaderChangedEvent.fire(faction, "a", "b"); + ArgumentCaptor fired = ArgumentCaptor.forClass(FactionLeaderChangedEvent.class); + verify(plugins).callEvent(fired.capture()); + assertEquals(faction, fired.getValue().getFaction()); + assertEquals("a", fired.getValue().getPreviousLeader()); + assertEquals("b", fired.getValue().getNewLeader()); + assertEquals(FactionLeaderChangedEvent.getHandlerList(), fired.getValue().getHandlers()); + + bukkit.when(Bukkit::getPluginManager).thenReturn(null); + FactionLeaderChangedEvent.fire(faction, "b", "c"); + bukkit.when(Bukkit::getServer).thenReturn(null); + FactionLeaderChangedEvent.fire(faction, "c", "d"); + verify(plugins, times(1)).callEvent(any(Event.class)); + } + } + + @Test + void aVehicleHeldForStaffIsNeverRestampedOrTreatedAsStale() throws Exception { + String staff = "char_" + UUID.randomUUID(); + PlayerVehicleRecord gun = pool("gun", "red"); + api.stored("gun", "gun", staff); + VehicleQuarantine quarantine = new VehicleQuarantine(java.nio.file.Files.createTempDirectory("held")); + quarantine.load(List.of(new VehicleQuarantine.Entry("gun", staff, "owner changed after the migration was planned"))); + VehicleQuarantine.use(quarantine); + + LeaderStamps.reconcile(registry); + LeaderStamps.onLeaderChanged(red, registry); + + assertEquals(List.of(), api.writes); + assertFalse(LeaderStamps.isStale(gun, staff)); + api.stored("gun", "gun", "player_Other"); + assertTrue(LeaderStamps.isStale(gun, "player_Other"), "Once staff change it, it is a faction vehicle again"); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerTagsTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerTagsTest.java new file mode 100644 index 00000000..921baed9 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerTagsTest.java @@ -0,0 +1,77 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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 OwnerTagsTest { + private final UUID kira = UUID.randomUUID(); + + @Test + void onlyAnExactCharacterUuidNamesACharacter() { + assertEquals("char_" + kira, OwnerTags.character(kira)); + assertEquals("char_none", OwnerTags.character(null)); + assertEquals(Optional.of(kira), OwnerTags.characterId("char_" + kira)); + assertEquals(Optional.of(kira), OwnerTags.characterId("CHAR_" + kira.toString().toUpperCase())); + for (String other : Arrays.asList(null, "char_none", "char_", "player_" + kira, "char_" + kira + "x", + "char_" + kira.toString().replace("-", "x"), "char_1-1-1-1-11111111111111111111111111")) { + assertEquals(Optional.empty(), OwnerTags.characterId(other), String.valueOf(other)); + assertFalse(OwnerTags.isCharacter(other)); + } + assertTrue(OwnerTags.names("char_" + kira, kira)); + assertFalse(OwnerTags.names("char_" + kira, UUID.randomUUID())); + assertFalse(OwnerTags.names("char_" + kira, null)); + } + + @Test + void unownedAndLegacyValuesAreRecognised() { + for (String unowned : Arrays.asList(null, "", " ", "none", "NONE")) assertTrue(OwnerTags.isUnowned(unowned)); + assertFalse(OwnerTags.isUnowned("char_none")); + assertEquals("Steve", OwnerTags.legacyName("player_Steve")); + assertEquals("Steve", OwnerTags.legacyName("PLAYER_Steve")); + for (String other : Arrays.asList(null, "player_", "player_none", "PLAYER_NONE", "char_" + kira, "steve")) { + assertNull(OwnerTags.legacyName(other), String.valueOf(other)); + } + } + + @Test + void whitelistEntriesUseVehicleFrameworksStoredForm() { + CharacterRef bran = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + String entry = OwnerTags.whitelistEntry(bran, "Bran"); + assertEquals("char_" + bran.characterId() + ";" + bran.ownerId() + ";Bran", entry); + assertEquals("char_" + bran.characterId() + ";" + bran.ownerId() + ";", OwnerTags.whitelistEntry(bran, null)); + assertTrue(OwnerTags.isCharacterEntry(entry)); + for (String legacy : Arrays.asList(null, "player_Bran", "Bran", ";x", "char_none;x;y")) { + assertFalse(OwnerTags.isCharacterEntry(legacy), String.valueOf(legacy)); + } + } + + @Test + void ownerFieldsCompareTheOwnershipAMigrationWrites() { + OwnerFields fields = new OwnerFields(null, Arrays.asList("a", null, "b"), true, true, "ticket"); + assertEquals("none", fields.owner()); + assertEquals(List.of("a", "b"), fields.whitelist()); + assertEquals("none", new OwnerFields(" ", null, false, false, null).owner()); + OwnerFields moved = fields.withOwnership("char_" + kira, List.of("c")); + assertEquals(new OwnerFields("char_" + kira, List.of("c"), true, true, "ticket"), moved); + assertTrue(moved.sameOwnership(new OwnerFields("char_" + kira, List.of("c"), true, false, null))); + assertFalse(moved.sameOwnership(new OwnerFields("char_" + kira, List.of("c"), false, true, "ticket"))); + assertFalse(moved.sameOwnership(new OwnerFields("char_" + kira, List.of(), true, true, "ticket"))); + assertFalse(moved.sameOwnership(fields)); + assertFalse(moved.sameOwnership(null)); + OwnerMeta meta = new OwnerMeta("v", "cart", null, null, null); + assertFalse(meta.hasStoredRow()); + assertNull(meta.currentOwner()); + assertEquals(Optional.empty(), meta.current()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleClaimsOffTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleClaimsOffTest.java new file mode 100644 index 00000000..9091c312 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleClaimsOffTest.java @@ -0,0 +1,36 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.vehicleframework.events.VehicleOwnerClaimedEvent; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class VehicleClaimsOffTest { + @Test + void withoutCharacterOwnershipOnlyStaffMayClaim() { + VehicleClaimsOff guard = new VehicleClaimsOff(); + Player player = mock(Player.class); + Player staff = mock(Player.class); + when(staff.hasPermission("simplefactions.admin")).thenReturn(true); + ActiveVehicle vehicle = mock(ActiveVehicle.class); + + VehicleOwnerClaimedEvent claim = new VehicleOwnerClaimedEvent(player, vehicle, "none", "player_Steve"); + guard.onClaim(claim); + VehicleOwnerClaimedEvent staffClaim = new VehicleOwnerClaimedEvent(staff, vehicle, "none", "player_Admin"); + guard.onClaim(staffClaim); + VehicleOwnerClaimedEvent nobody = new VehicleOwnerClaimedEvent(null, vehicle, "none", "player_x"); + guard.onClaim(nobody); + + assertTrue(claim.isCancelled()); + verify(player).sendMessage(VehicleClaimsOff.MESSAGE); + assertFalse(staffClaim.isCancelled()); + assertFalse(nobody.isCancelled()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigrationTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigrationTest.java new file mode 100644 index 00000000..260ec049 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigrationTest.java @@ -0,0 +1,552 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +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.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.tlibs.character.CharacterRef; + +class VehicleOwnerMigrationTest { + @TempDir Path folder; + private final PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); + private final Identity identity = new Identity(); + private final Backups backups = new Backups(); + private FakeOwnerApi api; + private List previousFactions; + + private final UUID alice = UUID.randomUUID(); + private final CharacterRef aliceCharacter = new CharacterRef(alice, UUID.randomUUID()); + private final UUID bob = UUID.randomUUID(); + private final CharacterRef bobHeir = new CharacterRef(bob, UUID.randomUUID()); + private final UUID dave = UUID.randomUUID(); + private final CharacterRef daveHeir = new CharacterRef(dave, UUID.randomUUID()); + private final UUID leader = UUID.randomUUID(); + + /** Name, account and heir lookups as the providers would answer them. */ + static final class Identity implements VehicleOwnerMigration.Identity { + final Map names = new HashMap<>(); + final Map accounts = new HashMap<>(); + final Map heirs = new HashMap<>(); + final Map characterNames = new HashMap<>(); + + void character(CharacterRef ref, String name) { + accounts.put(ref.characterId(), ref.ownerId()); + characterNames.put(ref.characterId(), name); + } + + @Override + public UUID roundTrip(String name) { + return names.get(name); + } + + @Override + public Optional accountOf(UUID character) { + return Optional.ofNullable(accounts.get(character)); + } + + @Override + public Optional heir(UUID account) { + return Optional.ofNullable(heirs.get(account)); + } + + @Override + public String nameOf(UUID character) { + return characterNames.get(character); + } + } + + /** Copies by writing a marker file; a failure set to throw simulates a crash mid-backup. */ + static final class Backups implements VehicleOwnerMigration.Backups { + int copies; + IOException failure; + boolean valid = true; + + @Override + public void vacuumInto(Path destination) throws IOException { + copies++; + if (failure != null) { + IOException thrown = failure; + failure = null; + throw thrown; + } + Files.writeString(destination, "copy " + copies); + } + + @Override + public boolean quickCheck(Path database) { + return valid; + } + } + + /** Throws after a number of writes, like a crash part-way through the apply step. */ + static class Crashing implements VehicleOwnerApi { + final FakeOwnerApi delegate; + int writesBeforeCrash; + + Crashing(FakeOwnerApi delegate, int writesBeforeCrash) { + this.delegate = delegate; + this.writesBeforeCrash = writesBeforeCrash; + } + + @Override + public boolean characters() { + return true; + } + + @Override + public List list() { + return delegate.list(); + } + + @Override + public Optional read(String vehicle) { + return delegate.read(vehicle); + } + + @Override + public Result write(String vehicle, String owner, List whitelist, Reason reason) { + if (writesBeforeCrash-- == 0) throw new IllegalStateException("server killed"); + return delegate.write(vehicle, owner, whitelist, reason); + } + } + + @BeforeEach + void setUp() { + api = FakeOwnerApi.install(); + previousFactions = FactionManager.factions; + FactionManager.factions = new ArrayList<>(); + identity.character(aliceCharacter, "Kira"); + identity.names.put("Alice", alice); + identity.names.put("Bob", bob); + identity.heirs.put(bob, bobHeir); + identity.character(bobHeir, "Bran"); + identity.names.put("Dave", dave); + identity.heirs.put(dave, daveHeir); + } + + @AfterEach + void tearDown() { + FactionManager.factions = previousFactions; + } + + private VehicleOwnerMigration migration(VehicleOwnerApi with) { + return new VehicleOwnerMigration(folder, with, registry, identity, backups); + } + + private VehicleOwnerMigration migration() { + return migration(api); + } + + private JsonObject plan() throws IOException { + return JsonParser.parseString(Files.readString(folder.resolve(VehicleOwnerMigration.PLAN))).getAsJsonObject(); + } + + private void personal(String vehicle, UUID character) { + registry.register(new PlayerVehicleRecord(character, vehicle, "cart", OwnershipMode.PERSONAL, null)); + } + + private Faction faction(String id, String leaderKey) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn(id); + when(faction.getLeader()).thenReturn(leaderKey); + FactionManager.factions.add(faction); + return faction; + } + + @Test + void personalRowsBecomeTheirCharacterWhenTheOwnerNameRoundTripsToItsAccount() throws IOException { + personal("personal", aliceCharacter.characterId()); + api.live("personal", "cart", "player_Alice", "player_Bob", "Bob", "char_" + UUID.randomUUID() + ";x;Kept", + "player_Renamed", "player_none", ""); + + VehicleOwnerMigration.Outcome outcome = migration().run(); + + assertEquals(new VehicleOwnerMigration.Outcome(true, 1, 0, 0, 0), outcome); + OwnerFields after = api.vehicles.get("personal").current().orElseThrow(); + assertEquals("char_" + aliceCharacter.characterId(), after.owner()); + // Round-tripped names move to the account's heir under its character name; the rest stay inert. + String bran = OwnerTags.whitelistEntry(bobHeir, "Bran"); + assertEquals(List.of(bran, after.whitelist().get(1), "player_Renamed", "player_none", ""), after.whitelist()); + assertTrue(after.whitelist().get(1).endsWith(";x;Kept")); + assertEquals(List.of("personal char_" + aliceCharacter.characterId() + " MIGRATE"), api.writes); + assertEquals(Map.of("personal", alice), VehicleOwnerMigration.accounts(folder)); + } + + @Test + void factionRowsGetTheirLeadersCharacterOrNobody() throws IOException { + faction("red", leader.toString()); + faction("blue", "dummy_3"); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "pool", "gun", OwnershipMode.POOL, null, "red")); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "dummy", "gun", OwnershipMode.POOL, null, "blue")); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "berth", "ship", OwnershipMode.INSTALLATION, + "port", "gone")); + api.stored("pool", "gun", "player_OldLeader").stored("dummy", "gun", "none").stored("berth", "ship", "faction_gone"); + + migration().run(); + + assertEquals("char_" + leader, api.owner("pool")); + assertEquals("char_none", api.owner("dummy")); + assertEquals("char_none", api.owner("berth")); + } + + @Test + void vehiclesWithoutARowGoToTheRoundTrippedAccountsHeir() throws IOException { + UUID uuidTagged = UUID.randomUUID(); + CharacterRef uuidHeir = new CharacterRef(uuidTagged, UUID.randomUUID()); + identity.heirs.put(uuidTagged, uuidHeir); + api.stored("orphan", "cart", "player_Dave").stored("uuid", "cart", "player_" + uuidTagged) + .stored("short", "cart", "player_1-1-1-1-1"); + + migration().run(); + + assertEquals("player_1-1-1-1-1", api.owner("short"), "Only a full account UUID is taken as one"); + assertEquals("char_" + daveHeir.characterId(), api.owner("orphan")); + assertEquals("char_" + uuidHeir.characterId(), api.owner("uuid"), "A player_ tag names its account"); + } + + @Test + void anythingThatCannotBeSeededSafelyIsQuarantinedAndKeepsItsLegacyOwner() throws IOException { + CharacterRef stranger = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + identity.character(stranger, "Mallory"); + identity.names.put("Heirless", UUID.randomUUID()); + personal("stranger", stranger.characterId()); + personal("nullcharacter", null); + personal("nobody", aliceCharacter.characterId()); + personal("renamedrow", aliceCharacter.characterId()); + api.stored("stranger", "cart", "player_Alice") + .stored("nullcharacter", "cart", "player_Alice") + .stored("nobody", "cart", "player_none") + .stored("renamedrow", "cart", "player_Renamed") + .stored("renamed", "cart", "player_Renamed") + .stored("heirless", "cart", "player_Heirless"); + api.vehicles.put("unreadable", new OwnerMeta("unreadable", "cart", "player_Alice", Optional.empty(), + Optional.empty())); + + VehicleOwnerMigration.Outcome outcome = migration().run(); + + assertEquals(7, outcome.quarantined()); + assertEquals(List.of(), api.writes); + assertEquals("player_Alice", api.owner("stranger")); + Map reasons = new HashMap<>(); + for (VehicleQuarantine.Entry entry : VehicleOwnerMigration.quarantine(folder)) { + reasons.put(entry.vehicle(), entry.reason()); + } + assertEquals("personal registry character belongs to another account", reasons.get("stranger")); + assertEquals("personal registry row has no character", reasons.get("nullcharacter")); + assertEquals("owner name does not match a known account", reasons.get("nobody")); + assertEquals("owner name does not match a known account", reasons.get("renamedrow")); + assertEquals("owner name does not match a known account", reasons.get("renamed")); + assertEquals("owner account has no reviewed heir", reasons.get("heirless")); + assertEquals("its ownership fields cannot be read", reasons.get("unreadable")); + } + + @Test + void unownedAndAlreadyCharacterOwnedVehiclesAreLeftAsTheyAre() throws IOException { + api.stored("unowned", "cart", "none").stored("owned", "cart", "char_" + UUID.randomUUID()) + .stored("leaderless", "cart", "char_none"); + + VehicleOwnerMigration.Outcome outcome = migration().run(); + + assertEquals(new VehicleOwnerMigration.Outcome(true, 0, 3, 0, 0), outcome); + assertEquals(List.of(), api.writes); + } + + @Test + void thePlanAndFixedNameBackupsComeBeforeAnyWriteAndASecondBootTouchesNothing() throws IOException { + Files.createDirectories(folder.resolve("Cache")); + Files.writeString(folder.resolve("Cache/vehicles_registry.json"), "[\"before\"]"); + api.stored("orphan", "cart", "player_Dave"); + + migration().run(); + + JsonObject plan = plan(); + assertTrue(plan.get("completed").getAsBoolean()); + assertTrue(plan.get("backedUp").getAsBoolean()); + Path database = folder.resolve(VehicleOwnerMigration.DATABASE_BACKUP); + Path registryCopy = folder.resolve(VehicleOwnerMigration.REGISTRY_BACKUP); + assertEquals(database.toString(), plan.get("databaseBackup").getAsString()); + assertEquals(registryCopy.toString(), plan.get("registryBackup").getAsString()); + assertEquals("copy 1", Files.readString(database)); + assertEquals("[\"before\"]", Files.readString(registryCopy)); + byte[] planBytes = Files.readAllBytes(folder.resolve(VehicleOwnerMigration.PLAN)); + + api.stored("orphan", "cart", "char_" + UUID.randomUUID()); + Files.writeString(folder.resolve("Cache/vehicles_registry.json"), "[\"after\"]"); + assertEquals(new VehicleOwnerMigration.Outcome(false, 0, 0, 0, 0), migration().run()); + + assertEquals(1, backups.copies, "Backups are never retaken"); + assertEquals("[\"before\"]", Files.readString(registryCopy)); + assertArrayEquals(planBytes, Files.readAllBytes(folder.resolve(VehicleOwnerMigration.PLAN))); + assertEquals(List.of("orphan char_" + daveHeir.characterId() + " MIGRATE"), api.writes); + } + + @Test + void anAbsentRegistryIsBackedUpAsAnEmptyList() throws IOException { + migration().run(); + assertEquals("[]", Files.readString(folder.resolve(VehicleOwnerMigration.REGISTRY_BACKUP))); + } + + @Test + void aCrashDuringTheBackupResumesAtTheBackupWithThePlannedDecisions() throws IOException { + api.stored("orphan", "cart", "player_Dave"); + backups.failure = new IOException("disk full"); + + assertThrows(IOException.class, () -> migration().run()); + assertFalse(plan().get("backedUp").getAsBoolean()); + assertEquals(List.of(), api.writes); + + // The name now belongs to someone else, but the plan's decision stands. + identity.names.remove("Dave"); + migration().run(); + + assertEquals(2, backups.copies); + assertEquals("char_" + daveHeir.characterId(), api.owner("orphan")); + } + + @Test + void aBackupThatFailsItsCheckIsNeverMarkedDone() throws IOException { + backups.valid = false; + IOException failure = assertThrows(IOException.class, () -> migration().run()); + assertTrue(failure.getMessage().contains("quick check")); + assertFalse(plan().get("backedUp").getAsBoolean()); + } + + @Test + void aCrashMidApplyResumesWithoutRewritingDoneVehiclesOrRedoingTheBackup() throws IOException { + for (int i = 0; i < 30; i++) api.stored("v" + i, "cart", "player_Dave"); + + assertThrows(IllegalStateException.class, () -> migration(new Crashing(api, 27)).run()); + assertEquals(27, api.writes.size()); + JsonObject crashed = plan(); + long done = crashed.getAsJsonArray("entries").asList().stream() + .filter(entry -> entry.getAsJsonObject().get("status").getAsString().equals("DONE")).count(); + assertEquals(VehicleOwnerMigration.SAVE_EVERY, done, "Only the batched done flags reached disk"); + + VehicleOwnerMigration.Outcome resumed = migration().run(); + + assertEquals(3, resumed.migrated(), "Applied-but-unflagged vehicles are marked done without a write"); + assertEquals(30, api.writes.size()); + assertEquals(1, backups.copies); + for (int i = 0; i < 30; i++) assertEquals("char_" + daveHeir.characterId(), api.owner("v" + i)); + } + + @Test + void aStaffTakeoverBetweenACrashAndTheResumeIsQuarantinedNotOverwritten() throws IOException { + api.stored("first", "cart", "player_Dave").stored("taken", "cart", "player_Dave"); + assertThrows(IllegalStateException.class, () -> migration(new Crashing(api, 1)).run()); + String staff = "char_" + UUID.randomUUID(); + api.stored("taken", "cart", staff); + + VehicleOwnerMigration.Outcome resumed = migration().run(); + + assertEquals(1, resumed.quarantined()); + assertEquals(staff, api.owner("taken")); + VehicleQuarantine.Entry entry = VehicleOwnerMigration.quarantine(folder).getFirst(); + assertEquals(new VehicleQuarantine.Entry("taken", staff, "owner changed after the migration was planned"), entry); + } + + @Test + void vehiclesGoneSincePlanningAreCountedWithoutBlockingCompletion() throws IOException { + api.stored("removed", "cart", "player_Dave").stored("vanishing", "cart", "player_Dave"); + VehicleOwnerApi vanishing = new Crashing(api, Integer.MAX_VALUE) { + @Override + public Result write(String vehicle, String owner, List whitelist, Reason reason) { + return vehicle.equals("vanishing") ? Result.NOT_FOUND : super.write(vehicle, owner, whitelist, reason); + } + }; + VehicleOwnerMigration migration = migration(vanishing); + VehicleOwnerMigration.Plan plan = migration.plan(); + com.google.gson.Gson gson = new com.google.gson.Gson(); + Files.createDirectories(folder.resolve("vehicles")); + Files.writeString(folder.resolve(VehicleOwnerMigration.PLAN), gson.toJson(plan)); + api.vehicles.remove("removed"); + + VehicleOwnerMigration.Outcome outcome = migration.run(); + + assertEquals(2, outcome.gone()); + assertTrue(plan().get("completed").getAsBoolean()); + } + + @Test + void aFailedOrRefusedWriteAbortsTheStageWithTheProgressSaved() throws IOException { + api.stored("a", "cart", "player_Dave"); + api.forced = Result.SAVE_FAILED; + IOException failure = assertThrows(IOException.class, () -> migration().run()); + assertTrue(failure.getMessage().contains("SAVE_FAILED")); + assertTrue(plan().get("backedUp").getAsBoolean()); + assertFalse(plan().get("completed").getAsBoolean()); + + api.forced = Result.REFUSED_MODE; + assertThrows(IOException.class, () -> migration().run()); + api.forced = null; + migration().run(); + assertEquals(1, backups.copies); + assertEquals("char_" + daveHeir.characterId(), api.owner("a")); + } + + @Test + void nothingIsPlannedOutsideCharactersMode() { + api.characters = false; + assertThrows(IOException.class, () -> migration().run()); + assertFalse(Files.exists(folder.resolve(VehicleOwnerMigration.PLAN))); + } + + @Test + void storedAndLiveCopiesThatDisagreeAfterTheApplyFailTheStage() throws IOException { + api.stored("torn", "cart", "player_Dave"); + VehicleOwnerApi tearing = new Crashing(api, Integer.MAX_VALUE) { + @Override + public Result write(String vehicle, String owner, List whitelist, Reason reason) { + Result result = super.write(vehicle, owner, whitelist, reason); + OwnerMeta meta = delegate.vehicles.get(vehicle); + delegate.vehicles.put(vehicle, new OwnerMeta(vehicle, "cart", meta.storedColumn(), meta.stored(), + Optional.of(FakeOwnerApi.fields("player_Dave")))); + return result; + } + }; + + IOException failure = assertThrows(IOException.class, () -> migration(tearing).run()); + + assertTrue(failure.getMessage().contains("torn")); + assertFalse(plan().get("completed").getAsBoolean()); + } + + @Test + void aStoredColumnThatDisagreesWithItsPayloadOrAMissingPayloadFailsTheStage() throws IOException { + api.stored("column", "cart", "player_Dave").stored("payload", "cart", "player_Dave"); + VehicleOwnerApi tearing = new Crashing(api, Integer.MAX_VALUE) { + @Override + public Result write(String vehicle, String owner, List whitelist, Reason reason) { + Result result = super.write(vehicle, owner, whitelist, reason); + OwnerMeta meta = delegate.vehicles.get(vehicle); + delegate.vehicles.put(vehicle, vehicle.equals("column") + ? new OwnerMeta(vehicle, "cart", "player_Dave", meta.stored(), Optional.empty()) + : new OwnerMeta(vehicle, "cart", owner, Optional.empty(), Optional.empty())); + return result; + } + }; + + IOException failure = assertThrows(IOException.class, () -> migration(tearing).run()); + + assertTrue(failure.getMessage().contains("column") && failure.getMessage().contains("payload")); + } + + @Test + void aDoneVehicleWithNeitherAStoredNorALiveCopyFailsTheStageAsTorn() throws IOException { + api.stored("blank", "cart", "player_Dave"); + VehicleOwnerApi emptying = new Crashing(api, Integer.MAX_VALUE) { + @Override + public Result write(String vehicle, String owner, List whitelist, Reason reason) { + Result result = super.write(vehicle, owner, whitelist, reason); + delegate.vehicles.put(vehicle, new OwnerMeta(vehicle, "cart", null, Optional.empty(), Optional.empty())); + return result; + } + }; + + IOException failure = assertThrows(IOException.class, () -> migration(emptying).run()); + + assertEquals("Stored and live vehicle owners disagree after migration: [blank]", failure.getMessage()); + assertFalse(plan().get("completed").getAsBoolean()); + } + + @Test + void copiesThatAgreeOnSomethingElseAtVerificationAreQuarantined() throws IOException { + api.stored("moved", "cart", "player_Dave").live("gone", "cart", "player_Dave"); + String staff = "char_" + UUID.randomUUID(); + VehicleOwnerApi movedOn = new Crashing(api, Integer.MAX_VALUE) { + @Override + public Result write(String vehicle, String owner, List whitelist, Reason reason) { + Result result = super.write(vehicle, owner, whitelist, reason); + if (vehicle.equals("moved")) delegate.stored("moved", "cart", staff); + else delegate.vehicles.remove("gone"); + return result; + } + }; + + VehicleOwnerMigration.Outcome outcome = migration(movedOn).run(); + + assertEquals(1, outcome.quarantined()); + assertEquals(1, outcome.gone()); + assertEquals(staff, VehicleOwnerMigration.quarantine(folder).getFirst().owner()); + } + + @Test + void anUnreadablePlanStopsTheStage() throws IOException { + Files.createDirectories(folder.resolve("vehicles")); + Path plan = folder.resolve(VehicleOwnerMigration.PLAN); + for (String invalid : List.of("{\"entries\":null,\"quarantine\":[]}", "{\"entries\":[],\"quarantine\":null}", + "{\"entries\":[null],\"quarantine\":[]}", "{\"entries\":[{\"status\":\"DONE\"}],\"quarantine\":[]}", + "{\"entries\":[{\"vehicle\":\"v\"}],\"quarantine\":[]}", + "{\"entries\":[{\"vehicle\":\"v\",\"status\":\"DONE\"}],\"quarantine\":[]}", + "{\"entries\":[{\"vehicle\":\"v\",\"status\":\"DONE\",\"original\":{}}],\"quarantine\":[]}")) { + Files.writeString(plan, invalid); + assertThrows(IOException.class, () -> migration().run(), invalid); + } + Files.writeString(plan, "{\"entries\":[{\"vehicle\":\"v\",\"status\":\"QUARANTINED\"}],\"quarantine\":[]," + + "\"completed\":true}"); + assertFalse(migration().run().ran()); + } + + @Test + void withoutAPlanThereIsNothingQuarantinedOrMatched() throws IOException { + assertEquals(List.of(), VehicleOwnerMigration.quarantine(folder)); + assertEquals(Map.of(), VehicleOwnerMigration.accounts(folder)); + } + + @Test + void sqliteBackupsCopyAConsistentDatabaseAndCheckIt() throws Exception { + // sqlite-jdbc logs through the server's slf4j bridge, which needs a logger first. + org.slf4j.jul.JDK14LoggerFactory.LOGGER = java.util.logging.Logger.getAnonymousLogger(); + Path live = folder.resolve("vehicles.db"); + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + live); + Statement statement = connection.createStatement()) { + statement.execute("PRAGMA journal_mode=WAL"); + statement.execute("CREATE TABLE vehicles (uuid TEXT PRIMARY KEY, owner TEXT)"); + statement.execute("INSERT INTO vehicles VALUES ('v', 'player_Dave')"); + var sqlite = new VehicleOwnerMigration.SqliteBackups(live); + Path copy = folder.resolve("it's a copy.db"); + sqlite.vacuumInto(copy); + assertTrue(sqlite.quickCheck(copy)); + assertThrows(IOException.class, () -> sqlite.vacuumInto(copy), "VACUUM INTO never overwrites"); + } + Path garbage = folder.resolve("garbage.db"); + Files.writeString(garbage, "not a database at all, just text that is long enough to have a header"); + var sqlite = new VehicleOwnerMigration.SqliteBackups(live); + assertThrows(IOException.class, () -> sqlite.quickCheck(garbage)); + Path empty = folder.resolve("empty.db"); + assertTrue(sqlite.quickCheck(empty), "An empty database is consistent"); + } + +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java new file mode 100644 index 00000000..82df038a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java @@ -0,0 +1,238 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +class VehicleOwnerRollbackTest { + @TempDir Path server; + private Path database; + private Path factions; + private final long migratedAt = 1_000_000L; + private final String kira = "char_" + UUID.randomUUID(); + private final String bran = "char_" + UUID.randomUUID() + ";" + UUID.randomUUID() + ";Bran"; + + @BeforeEach + void setUp() throws Exception { + org.slf4j.jul.JDK14LoggerFactory.LOGGER = java.util.logging.Logger.getAnonymousLogger(); + factions = server.resolve("plugins/SimpleFactions"); + database = server.resolve("plugins/VehicleFramework/data/vehicles.db"); + Files.createDirectories(database.getParent()); + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + database); + Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE vehicles (uuid TEXT PRIMARY KEY, owner TEXT NOT NULL, payload_json TEXT NOT NULL," + + " revision INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL)"); + } + row("restore", kira, "{\"owner\":\"" + kira + "\",\"whitelisted\":true,\"whitelist\":[\"" + bran + + "\"],\"ticketsEnabled\":true,\"ticketId\":\"t9\",\"health\":27,\"x\":1.5,\"note\":\"\"}", 0); + row("changed", "char_staff", "{\"owner\":\"char_staff\",\"whitelisted\":false,\"whitelist\":[]}", 0); + row("payload", kira, "{\"owner\":\"" + kira + "\",\"whitelisted\":false,\"whitelist\":[\"" + bran + "\"]}", 0); + row("tombstoned", kira, "{\"owner\":\"" + kira + "\"}", 1); + writePlan(); + Path journal = factions.resolve("vehicle-payments"); + Files.createDirectories(journal); + intent(journal, "a", "charge", "claim:v1", "sf:vehicle:claim:1", migratedAt + 5, "APPLIED"); + intent(journal, "b", "charge", "build:s", "sf:vehicle:build:2", migratedAt - 5, "APPLIED"); + Files.setLastModifiedTime(intent(journal, "c", "charge", "handover:v2", "sf:vehicle:handover:3", null, "APPLIED"), + FileTime.fromMillis(migratedAt + 10)); + Files.setLastModifiedTime(intent(journal, "c2", "charge", "handover:v4", "sf:vehicle:handover:4", null, "APPLIED"), + FileTime.fromMillis(migratedAt - 10)); + intent(journal, "d", "charge", "claim:v3", "sf:vehicle:claim:5", migratedAt + 1, "APPLIED"); + intent(journal, "e", "refund", "claim:v3", "sf:vehicle:claim:5", null, "APPLIED"); + intent(journal, "f", "charge", "other:v", "sf:vehicle:other", migratedAt + 1, "APPLIED"); + intent(journal, "g", "charge", "claim:v5", "sf:vehicle:claim:6", migratedAt + 1, "REFUSED"); + intent(journal, "h", "maintenance", null, "sf:vehicle:m", null, "APPLIED"); + intent(journal, "i", "charge", "build:t", "sf:vehicle:build:7", migratedAt + 1, null); + Files.writeString(journal.resolve("notes.txt"), "not an intent"); + } + + private void row(String uuid, String owner, String payload, int deleted) throws SQLException { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + database); + PreparedStatement insert = connection.prepareStatement( + "INSERT INTO vehicles (uuid, owner, payload_json, revision, deleted, updated_at) VALUES (?, ?, ?, 4, ?, 0)")) { + insert.setString(1, uuid); + insert.setString(2, owner); + insert.setString(3, payload); + insert.setInt(4, deleted); + insert.executeUpdate(); + } + } + + private String entry(String vehicle, String status, String originalOwner, String plannedOwner, String plannedEntry, + boolean plannedFlag) { + return "{\"vehicle\":\"" + vehicle + "\",\"status\":\"" + status + "\"," + + "\"original\":{\"owner\":\"" + originalOwner + "\",\"whitelist\":[\"player_Bob\"],\"whitelisted\":true," + + "\"ticketsEnabled\":false}," + + "\"planned\":{\"owner\":\"" + plannedOwner + "\",\"whitelist\":[" + plannedEntry + "],\"whitelisted\":" + + plannedFlag + ",\"ticketsEnabled\":false}}"; + } + + private void writePlan() throws IOException { + String quoted = "\"" + bran + "\""; + String entries = String.join(",", + entry("restore", "PENDING", "player_Alice", kira, quoted, true), + entry("changed", "DONE", "player_Alice", kira, quoted, true), + entry("payload", "DONE", "player_Alice", kira, quoted, true), + entry("tombstoned", "DONE", "player_Alice", kira, quoted, true), + entry("unchanged", "DONE", kira, kira, "\"player_Bob\"", true), + "{\"vehicle\":\"held\",\"status\":\"QUARANTINED\"}"); + Files.createDirectories(factions.resolve("vehicles")); + // An interrupted plan: no completion time, fees count from when it was made. + Files.writeString(factions.resolve(VehicleOwnerMigration.PLAN), "{\"completed\":false,\"createdAt\":" + migratedAt + + ",\"entries\":[" + entries + "],\"quarantine\":[]}"); + } + + private static Path intent(Path journal, String file, String action, String scope, String id, Long at, + String completed) throws IOException { + JsonObject changes = new JsonObject(); + changes.addProperty("action", action); + if (scope != null) changes.addProperty("scope", scope); + if (at != null) changes.addProperty("at", at); + JsonObject receipt = new JsonObject(); + receipt.addProperty("id", id); + JsonObject payer = new JsonObject(); + payer.addProperty("ownerId", "account-" + file); + payer.addProperty("characterId", "character-" + file); + receipt.add("payer", payer); + receipt.addProperty("factionId", "red"); + receipt.addProperty("cents", 4000); + if (!action.equals("maintenance")) changes.add("receipt", receipt); + JsonObject intent = new JsonObject(); + intent.addProperty("id", id + (action.equals("refund") ? ":refund" : "")); + intent.add("changes", changes); + JsonObject saved = new JsonObject(); + saved.add("intent", intent); + if (completed != null) saved.addProperty("completed", completed); + Path path = journal.resolve(file + ".json"); + Files.writeString(path, saved.toString()); + return path; + } + + private String[] stored(String uuid) throws SQLException { + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + database); + PreparedStatement select = connection.prepareStatement( + "SELECT owner, payload_json, revision FROM vehicles WHERE uuid = ?")) { + select.setString(1, uuid); + try (ResultSet row = select.executeQuery()) { + row.next(); + return new String[] {row.getString(1), row.getString(2), String.valueOf(row.getInt(3))}; + } + } + } + + @Test + void aDryRunListsWhatWouldChangeAndTheRefundsWithoutWritingAnything() throws Exception { + ByteArrayOutputStream printed = new ByteArrayOutputStream(); + + VehicleOwnerRollback.Report report = VehicleOwnerRollback.run(server, false, + new PrintStream(printed, true, StandardCharsets.UTF_8)); + + assertEquals(List.of("restore"), report.restored()); + assertEquals(List.of("changed", "payload"), report.changed()); + assertEquals(List.of("tombstoned"), report.gone()); + assertEquals(List.of("claim:v1", "handover:v2"), report.refunds().stream().map(VehicleOwnerRollback.Refund::scope).toList()); + VehicleOwnerRollback.Refund claim = report.refunds().getFirst(); + assertEquals(new VehicleOwnerRollback.Refund("sf:vehicle:claim:1", "claim:v1", "character-a", "account-a", "red", + 4000, migratedAt + 5), claim); + assertEquals(migratedAt + 10, report.refunds().get(1).at(), "Older entries fall back to the file time"); + String output = printed.toString(StandardCharsets.UTF_8); + assertTrue(output.contains("Would restore 1 vehicle owners")); + assertTrue(output.contains("Changed since the migration, left alone: changed")); + assertTrue(output.contains("No longer stored: tombstoned")); + assertTrue(output.contains("\"scope\": \"claim:v1\"")); + assertEquals(kira, stored("restore")[0]); + assertTrue(Files.exists(factions.resolve(VehicleOwnerMigration.PLAN))); + } + + @Test + void applyingRestoresOnlyTheOwnerFieldsOfUntouchedRowsAndResetsThePlan() throws Exception { + ByteArrayOutputStream printed = new ByteArrayOutputStream(); + + VehicleOwnerRollback.run(server, true, new PrintStream(printed, true, StandardCharsets.UTF_8)); + + String[] restored = stored("restore"); + assertEquals("player_Alice", restored[0]); + assertEquals("5", restored[2]); + JsonObject payload = JsonParser.parseString(restored[1]).getAsJsonObject(); + assertEquals("player_Alice", payload.get("owner").getAsString()); + assertTrue(payload.get("whitelisted").getAsBoolean()); + assertEquals("player_Bob", payload.getAsJsonArray("whitelist").get(0).getAsString()); + assertTrue(payload.get("ticketsEnabled").getAsBoolean(), "Tickets are never touched"); + assertEquals("t9", payload.get("ticketId").getAsString()); + assertEquals(27, payload.get("health").getAsInt(), "Position, health and cargo stay as they are now"); + assertEquals("", payload.get("note").getAsString()); + assertTrue(restored[1].contains(""), "No HTML escaping is introduced"); + assertEquals("char_staff", stored("changed")[0]); + assertFalse(Files.exists(factions.resolve(VehicleOwnerMigration.PLAN)), "A re-upgrade plans afresh"); + try (Stream files = Files.list(factions.resolve("vehicles"))) { + List names = files.map(path -> path.getFileName().toString()).sorted().toList(); + assertTrue(names.stream().anyMatch(name -> name.startsWith("owner-migration.json.rolled-back-"))); + Path refunds = factions.resolve("vehicles").resolve(names.stream() + .filter(name -> name.startsWith("refunds-")).findFirst().orElseThrow()); + assertTrue(Files.readString(refunds).contains("handover:v2")); + } + assertTrue(printed.toString(StandardCharsets.UTF_8).contains("Restored 1 vehicle owners")); + } + + @Test + void theCommandLineTakesTheServerFolderAndAnOptionalApply() throws Exception { + PrintStream original = System.out; + ByteArrayOutputStream printed = new ByteArrayOutputStream(); + System.setOut(new PrintStream(printed, true, StandardCharsets.UTF_8)); + try { + VehicleOwnerRollback.main(new String[] {server.toString()}); + } finally { + System.setOut(original); + } + assertTrue(printed.toString(StandardCharsets.UTF_8).contains("Would restore 1")); + for (String[] wrong : List.of(new String[0], new String[] {"a", "--force"}, new String[] {"a", "--apply", "b"})) { + assertThrows(IllegalArgumentException.class, () -> VehicleOwnerRollback.main(wrong)); + } + } + + @Test + void aDatabaseWithoutTheVehicleTableStopsWithNothingChanged() throws Exception { + Files.delete(database); + assertThrows(IOException.class, () -> VehicleOwnerRollback.run(server, true, new PrintStream( + new ByteArrayOutputStream(), true, StandardCharsets.UTF_8))); + assertTrue(Files.exists(factions.resolve(VehicleOwnerMigration.PLAN))); + } + + @Test + void withoutAPaymentJournalThereIsNothingToRefund() throws IOException { + assertEquals(List.of(), VehicleOwnerRollback.refunds(server.resolve("absent"), 0)); + } + + @Test + void anOldGsonOrAMissingSqliteDriverIsNamedBeforeAnythingRuns() { + assertEquals(null, VehicleOwnerRollback.missingLibraries(com.google.gson.JsonArray.class, "org.sqlite.JDBC")); + assertTrue(VehicleOwnerRollback.missingLibraries(Object.class, "org.sqlite.JDBC").startsWith("Gson 2.10 or newer")); + assertTrue(VehicleOwnerRollback.missingLibraries(com.google.gson.JsonArray.class, "org.sqlite.Missing") + .startsWith("sqlite-jdbc is needed")); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWritesTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWritesTest.java new file mode 100644 index 00000000..b9d97df0 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWritesTest.java @@ -0,0 +1,131 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.simplefactions.SimpleFactions; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result; +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; + +class VehicleOwnerWritesTest { + private final UUID id = UUID.randomUUID(); + + @Test + void theProbeFindsVehicleFrameworksOwnershipApi() { + assertInstanceOf(VfOwnerApi.class, VehicleOwnerWrites.probe()); + assertNull(VehicleOwnerWrites.probe("net.tfminecraft.vehicleframework.api.Missing"), + "An older VehicleFramework has no ownership API"); + assertNull(VehicleOwnerWrites.probe("java.lang.Object")); + } + + @Test + void withoutVehicleFrameworkEveryWriteIsUnavailableAndNothingCanBeRead() { + assertNull(VehicleOwnerWrites.api()); + assertFalse(VehicleOwnerWrites.ready()); + assertEquals(Result.UNAVAILABLE, VehicleOwnerWrites.write("v", "none", Reason.RESTAMP)); + assertEquals(Optional.empty(), VehicleOwnerWrites.read("v")); + assertNull(VehicleOwnerWrites.currentOwner("v")); + } + + @Test + void writesAndReadsGoThroughTheInstalledApiAndFailuresAreContained() { + FakeOwnerApi api = FakeOwnerApi.install().stored("v", "cart", "player_Old"); + assertSame(api, VehicleOwnerWrites.api()); + assertTrue(VehicleOwnerWrites.ready()); + assertEquals(Result.OK, VehicleOwnerWrites.write("v", "char_none", Reason.RESTAMP)); + assertEquals("char_none", VehicleOwnerWrites.currentOwner("v")); + assertEquals(Optional.empty(), VehicleOwnerWrites.read(null)); + api.characters = false; + assertFalse(VehicleOwnerWrites.ready()); + + SimpleFactions plugin = mock(SimpleFactions.class); + Logger logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + api.failure = new IllegalStateException("closed"); + try (MockedStatic sf = mockStatic(SimpleFactions.class)) { + sf.when(SimpleFactions::getInstance).thenReturn(plugin); + assertEquals(Result.SAVE_FAILED, VehicleOwnerWrites.write("v", "none", Reason.RELEASE)); + assertEquals(Optional.empty(), VehicleOwnerWrites.read("v")); + } + verify(logger).log(eq(Level.WARNING), contains("Could not set the owner of vehicle v to none (RELEASE)"), + any(IllegalStateException.class)); + verify(logger).log(eq(Level.WARNING), contains("Could not read the owner of vehicle v"), + any(IllegalStateException.class)); + assertEquals(Result.SAVE_FAILED, VehicleOwnerWrites.write("v", "none", Reason.RELEASE)); + + VehicleOwnerWrites.use(brokenMode()); + assertFalse(VehicleOwnerWrites.ready(), "A VehicleFramework without the mode accessor is not ready"); + } + + private static VehicleOwnerApi brokenMode() { + VehicleOwnerApi api = mock(VehicleOwnerApi.class); + when(api.characters()).thenThrow(new NoSuchMethodError("ownershipIdentity")); + return api; + } + + @Test + void theBridgeTranslatesVehicleFrameworksTypes() { + VfOwnerApi bridge = new VfOwnerApi(); + OwnershipValues stored = new OwnershipValues("player_Old", List.of("Bob"), true, true, "t1"); + OwnershipValues live = new OwnershipValues("char_none", List.of(), false, false, null); + OwnershipMeta meta = new OwnershipMeta(id, "cart", "Cart", "player_Old", Optional.of(stored), Optional.of(live)); + try (MockedStatic vf = mockStatic(VehicleFrameworkApi.class)) { + vf.when(VehicleFrameworkApi::ownershipIdentity).thenReturn(OwnershipIdentity.CHARACTERS); + assertTrue(bridge.characters()); + vf.when(VehicleFrameworkApi::ownershipIdentity).thenReturn(OwnershipIdentity.NAMES); + assertFalse(bridge.characters()); + + vf.when(VehicleFrameworkApi::listOwnershipMeta).thenReturn(List.of(meta)); + OwnerMeta converted = bridge.list().getFirst(); + assertEquals(id.toString(), converted.vehicle()); + assertEquals("cart", converted.typeId()); + assertEquals("player_Old", converted.storedColumn()); + assertEquals(new OwnerFields("player_Old", List.of("Bob"), true, true, "t1"), converted.stored().orElseThrow()); + assertEquals(new OwnerFields("char_none", List.of(), false, false, null), converted.live().orElseThrow()); + + vf.when(() -> VehicleFrameworkApi.readOwnershipMeta(id)).thenReturn(Optional.of(meta)); + assertEquals(converted, bridge.read(id.toString()).orElseThrow()); + assertEquals(Optional.empty(), bridge.read("not-a-uuid")); + assertEquals(Optional.empty(), bridge.read(null)); + + UUID kira = UUID.randomUUID(); + vf.when(() -> VehicleFrameworkApi.setOwnership(id, OwnerChange.character(kira), SystemReason.RESTAMP)) + .thenReturn(VehicleFrameworkApi.Result.OK); + assertEquals(Result.OK, bridge.write(id.toString(), "char_" + kira, null, Reason.RESTAMP)); + vf.when(() -> VehicleFrameworkApi.setOwnership(id, OwnerChange.seeded("char_" + kira, List.of("e")), + SystemReason.MIGRATE)).thenReturn(VehicleFrameworkApi.Result.SAVE_FAILED); + assertEquals(Result.SAVE_FAILED, bridge.write(id.toString(), "char_" + kira, List.of("e"), Reason.MIGRATE)); + assertEquals(Result.NOT_FOUND, bridge.write("not-a-uuid", "none", null, Reason.RELEASE)); + assertEquals(Result.NOT_FOUND, bridge.write(null, "none", null, Reason.RELEASE)); + assertThrows(IllegalArgumentException.class, + () -> bridge.write(id.toString(), "player_Steve", null, Reason.RESTAMP)); + } + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java new file mode 100644 index 00000000..71920ab9 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java @@ -0,0 +1,549 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +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.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.MockedStatic; + +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.simplefactions.events.FactionLeaderChangedEvent; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.identity.Members; +import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader; +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.testsupport.NameKeyedMembers; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeConfirmations; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeMessages; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService; +import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore; +import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimListener; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimService; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.vehicleframework.api.OwnershipReason; +import net.tfminecraft.vehicleframework.api.OwnershipValues; +import net.tfminecraft.vehicleframework.data.OwnerData; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangeEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent; +import net.tfminecraft.vehicleframework.events.VehicleOwnershipEditCheckEvent; +import net.tfminecraft.vehicleframework.events.VehiclePreInteractEvent; +import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle; + +class VehicleOwnershipGateTest { + @TempDir Path folder; + private final PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); + private final VehicleFeeStore store = new VehicleFeeStore(); + private final VehicleFeeConfirmations confirmations = new VehicleFeeConfirmations(); + private final AtomicInteger saves = new AtomicInteger(); + private final Logger logger = mock(Logger.class); + private VehicleOwnershipGate gate; + private MockedStatic paymentsStatic; + private MockedStatic fees; + private VehiclePayments payments; + private Player player; + private CharacterRef actor; + private ActiveVehicle vehicle; + private OwnerData ownerData; + private List previousFactions; + + @BeforeEach + void setUp() throws Exception { + Path config = folder.resolve("vehicles.yml"); + Files.writeString(config, "personal-slot-limit: 3\ncategories:\n ships:\n ship:\n upkeep: 20\n" + + " size: 1\n per-person: 1\n"); + VehiclesConfigLoader.load(config.toFile()); + gate = new VehicleOwnershipGate(registry, new VehicleRegistryClaimService(registry), store, confirmations, + saves::incrementAndGet, logger); + payments = mock(VehiclePayments.class); + when(payments.ready()).thenReturn(true); + when(payments.personal(anyString(), anyString(), any(), any())).thenReturn(true); + when(payments.replaceRow(anyString(), any())).thenReturn(true); + paymentsStatic = mockStatic(VehiclePayments.class); + paymentsStatic.when(VehiclePayments::current).thenReturn(payments); + fees = mockStatic(VehicleFeeService.class); + player = mock(Player.class); + when(player.getName()).thenReturn("Claimer"); + UUID account = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(account); + actor = new CharacterRef(account, NameKeyedMembers.characterOf("Claimer")); + vehicle = mock(ActiveVehicle.class); + when(vehicle.getUUID()).thenReturn("ship-1"); + when(vehicle.getId()).thenReturn("ship"); + ownerData = new OwnerData(); + when(vehicle.getOwnerData()).thenReturn(ownerData); + previousFactions = FactionManager.factions; + FactionManager.factions = new ArrayList<>(); + } + + @AfterEach + void tearDown() { + fees.close(); + paymentsStatic.close(); + FactionManager.factions = previousFactions; + } + + private VehicleOwnershipChangeEvent change(OwnershipReason reason, boolean staff) { + return new VehicleOwnershipChangeEvent(player, vehicle, reason, UUID.randomUUID(), actor, staff, + reason == OwnershipReason.CLAIM ? actor : null, null, "none", "char_" + actor.characterId()); + } + + private VehicleOwnershipChangedEvent changed(boolean success, OwnershipReason reason, UUID operation, boolean staff, + CharacterRef target) { + OwnershipValues values = new OwnershipValues("none", List.of(), false, false, null); + return new VehicleOwnershipChangedEvent(success, reason, operation, UUID.randomUUID(), vehicle, player, actor, + staff, target, null, values, values); + } + + private void factionRow() { + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "ship-1", "ship", OwnershipMode.POOL, null, "red")); + } + + @ParameterizedTest + @EnumSource(value = OwnershipReason.class, names = {"CLAIM", "CLEAR", "WHITELIST_ADD", "WHITELIST_REMOVE", + "WHITELIST_TOGGLE", "TICKETS_TOGGLE"}) + void everyNonStaffChangeToAFactionVehicleIsRefused(OwnershipReason reason) { + factionRow(); + VehicleOwnershipChangeEvent event = change(reason, false); + + gate.onChange(event); + + assertTrue(event.isCancelled()); + verify(player).sendMessage(VehicleRegistryClaimListener.FACTION_VEHICLE); + } + + @Test + void staffChangesPassEvenOnFactionVehiclesAndWithoutReadyPayments() { + factionRow(); + when(payments.ready()).thenReturn(false); + VehicleOwnershipChangeEvent takeover = change(OwnershipReason.TAKEOVER, true); + VehicleOwnershipChangeEvent claim = change(OwnershipReason.CLAIM, true); + + gate.onChange(takeover); + gate.onChange(claim); + gate.onClaimFee(claim); + + assertFalse(takeover.isCancelled()); + assertFalse(claim.isCancelled()); + verifyNoInteractions(player); + } + + @Test + void personalWhitelistAndTicketChangesAreLeftToVehicleFramework() { + registry.register(new PlayerVehicleRecord(actor.characterId(), "ship-1", "ship", OwnershipMode.PERSONAL, null)); + VehicleOwnershipChangeEvent event = change(OwnershipReason.WHITELIST_ADD, false); + gate.onChange(event); + gate.onClaimFee(event); + assertFalse(event.isCancelled()); + } + + @Test + void aClaimNeedsACharacterAndReadyUnblockedPayments() { + VehicleOwnershipChangeEvent noActor = new VehicleOwnershipChangeEvent(player, vehicle, OwnershipReason.CLAIM, + UUID.randomUUID(), null, false, null, null, "none", "player_Claimer"); + gate.onChange(noActor); + assertTrue(noActor.isCancelled()); + + when(payments.blockedVehicle("ship-1")).thenReturn(true); + VehicleOwnershipChangeEvent blocked = change(OwnershipReason.CLAIM, false); + gate.onChange(blocked); + assertTrue(blocked.isCancelled()); + + when(payments.ready()).thenReturn(false); + VehicleOwnershipChangeEvent unready = change(OwnershipReason.CLAIM, false); + gate.onChange(unready); + assertTrue(unready.isCancelled()); + + paymentsStatic.when(VehiclePayments::current).thenReturn(null); + VehicleOwnershipChangeEvent absent = change(OwnershipReason.CLAIM, false); + gate.onChange(absent); + assertTrue(absent.isCancelled()); + verify(player, times(4)).sendMessage(VehicleOwnershipGate.PAYMENT_UNAVAILABLE); + } + + @Test + void aClaimCountsTheClaimersAccountAgainstItsSlots() { + VehicleOwnershipChangeEvent free = change(OwnershipReason.CLAIM, false); + gate.onChange(free); + assertFalse(free.isCancelled()); + + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "ship-2", "ship", OwnershipMode.PERSONAL, null, + null, player.getUniqueId())); + VehicleOwnershipChangeEvent full = change(OwnershipReason.CLAIM, false); + gate.onChange(full); + assertTrue(full.isCancelled()); + verify(player).sendMessage(startsWith("§cYou already have the maximum number of ship")); + } + + @Test + void aVetoHandlerThatFailsRefusesTheChange() { + when(vehicle.getUUID()).thenThrow(new IllegalStateException("broken vehicle")); + VehicleOwnershipChangeEvent high = change(OwnershipReason.CLAIM, false); + VehicleOwnershipChangeEvent highest = change(OwnershipReason.CLAIM, false); + + gate.onChange(high); + gate.onClaimFee(highest); + + assertTrue(high.isCancelled()); + assertTrue(highest.isCancelled()); + verify(player, times(2)).sendMessage(VehicleOwnershipGate.FAILED); + verify(logger, times(2)).log(eq(Level.SEVERE), contains("refused"), any(IllegalStateException.class)); + } + + private VehicleFeeService.Quote quote() { + Faction faction = mock(Faction.class); + Guild guild = mock(Guild.class); + when(guild.getIncarnationId()).thenReturn(UUID.randomUUID()); + when(faction.getOrCreateMainGuild()).thenReturn(guild); + when(faction.getName()).thenReturn("Red"); + VehicleFeeService.Quote quote = new VehicleFeeService.Quote(FeeKind.TRANSFER_FEE, "ship", faction, 2, 40); + fees.when(() -> VehicleFeeService.quote(FeeKind.TRANSFER_FEE, "Previous", "ship")).thenReturn(quote); + return quote; + } + + @Test + void claimingSomeoneElsesReleasedVehicleShowsTheFeeThenChargesItUnderTheOperation() { + VehicleFeeService.Quote quote = quote(); + store.setLastOwner("ship-1", "Previous"); + VehicleOwnershipChangeEvent first = change(OwnershipReason.CLAIM, false); + gate.onClaimFee(first); + assertTrue(first.isCancelled()); + verify(player).sendMessage(VehicleFeeMessages.claimConfirm(quote, Members.display("Previous"))); + verify(payments, never()).charge(anyString(), anyString(), any(), any()); + + VehicleOwnershipChangeEvent second = change(OwnershipReason.CLAIM, false); + when(payments.charge("claim:ship-1", "sf:vehicle:claim:" + second.getOperationId(), actor, quote)) + .thenReturn(Result.APPLIED); + gate.onClaimFee(second); + assertFalse(second.isCancelled()); + verify(player).sendMessage(VehicleFeeMessages.claimPaid(quote)); + + gate.onChanged(changed(true, OwnershipReason.CLAIM, second.getOperationId(), false, actor)); + verify(payments).personal("ship-1", "ship", actor.characterId(), actor.ownerId()); + verify(payments).complete("claim:ship-1", Members.key(actor)); + assertEquals(0, saves.get()); + } + + @Test + void anUnaffordableClaimFeeRefusesTheClaim() { + VehicleFeeService.Quote quote = quote(); + store.setLastOwner("ship-1", "Previous"); + gate.onClaimFee(change(OwnershipReason.CLAIM, false)); + VehicleOwnershipChangeEvent second = change(OwnershipReason.CLAIM, false); + when(payments.charge(anyString(), anyString(), any(), any())).thenReturn(Result.REFUSED); + + gate.onClaimFee(second); + + assertTrue(second.isCancelled()); + verify(player).sendMessage(VehicleFeeMessages.claimUnaffordable(quote)); + } + + @Test + void ownVehiclesUnknownHistoryAndFeelessFactionsClaimFree() { + VehicleOwnershipChangeEvent unknown = change(OwnershipReason.CLAIM, false); + gate.onClaimFee(unknown); + store.setLastOwner("ship-1", Members.key(actor)); + VehicleOwnershipChangeEvent own = change(OwnershipReason.CLAIM, false); + gate.onClaimFee(own); + store.setLastOwner("ship-1", "Feeless"); + VehicleOwnershipChangeEvent feeless = change(OwnershipReason.CLAIM, false); + gate.onClaimFee(feeless); + + assertFalse(unknown.isCancelled() || own.isCancelled() || feeless.isCancelled()); + verify(payments, never()).charge(anyString(), anyString(), any(), any()); + + gate.onChanged(changed(true, OwnershipReason.CLAIM, feeless.getOperationId(), false, actor)); + verify(payments, never()).complete(anyString(), any()); + assertEquals(Members.key(actor), store.getLastOwner("ship-1")); + assertEquals(1, saves.get()); + } + + @Test + void aFailedClaimRefundsItsFeeOnceAndAFailedRefundIsReported() { + quote(); + store.setLastOwner("ship-1", "Previous"); + gate.onClaimFee(change(OwnershipReason.CLAIM, false)); + VehicleOwnershipChangeEvent paid = change(OwnershipReason.CLAIM, false); + when(payments.charge(anyString(), anyString(), any(), any())).thenReturn(Result.APPLIED); + gate.onClaimFee(paid); + when(payments.receipt("claim:ship-1")).thenReturn(transferReceipt(4000)); + when(payments.refund("claim:ship-1")).thenReturn(new VehiclePayments.Refund(Result.APPLIED, 40)); + + gate.onChanged(changed(false, OwnershipReason.CLAIM, paid.getOperationId(), false, actor)); + gate.onChanged(changed(false, OwnershipReason.CLAIM, paid.getOperationId(), false, actor)); + + verify(payments, times(1)).refund("claim:ship-1"); + verify(player).sendMessage("§aThe claim did not go through, so the transfer fee was refunded."); + verify(logger, never()).warning(anyString()); + verify(payments, never()).personal(anyString(), anyString(), any(), any()); + + confirmations.clear(player.getUniqueId()); + gate.onClaimFee(change(OwnershipReason.CLAIM, false)); + VehicleOwnershipChangeEvent again = change(OwnershipReason.CLAIM, false); + gate.onClaimFee(again); + when(payments.refund("claim:ship-1")).thenReturn(new VehiclePayments.Refund(Result.UNAVAILABLE, 0)); + gate.onChanged(changed(false, OwnershipReason.CLAIM, again.getOperationId(), false, actor)); + verify(logger).severe(contains("staff must recover it")); + + paymentsStatic.when(VehiclePayments::current).thenReturn(payments, (VehiclePayments) null); + gate.onClaimFee(change(OwnershipReason.CLAIM, false)); + VehicleOwnershipChangeEvent lost = change(OwnershipReason.CLAIM, false); + gate.onClaimFee(lost); + gate.onChanged(changed(false, OwnershipReason.CLAIM, lost.getOperationId(), false, actor)); + verify(logger, times(2)).severe(contains("staff must recover it")); + + gate.onChanged(changed(false, OwnershipReason.CLEAR, UUID.randomUUID(), false, null)); + verify(payments, times(2)).refund("claim:ship-1"); + + paymentsStatic.when(VehiclePayments::current).thenReturn(payments); + when(payments.receipt("claim:ship-1")).thenReturn(null); + confirmations.clear(player.getUniqueId()); + gate.onClaimFee(change(OwnershipReason.CLAIM, false)); + VehicleOwnershipChangeEvent unrecorded = change(OwnershipReason.CLAIM, false); + gate.onClaimFee(unrecorded); + gate.onChanged(changed(false, OwnershipReason.CLAIM, unrecorded.getOperationId(), false, actor)); + verify(logger, times(3)).severe(contains("staff must recover it")); + verify(payments, times(2)).refund("claim:ship-1"); + } + + private VehiclePayments.Receipt transferReceipt(long cents) { + return new VehiclePayments.Receipt("r", actor, "realm", UUID.randomUUID(), FeeKind.TRANSFER_FEE, cents); + } + + @Test + void aPartialRefundIsLoggedAndThePlayerToldTheShortfall() { + quote(); + store.setLastOwner("ship-1", "Previous"); + when(payments.charge(anyString(), anyString(), any(), any())).thenReturn(Result.APPLIED); + when(payments.receipt("claim:ship-1")).thenReturn(transferReceipt(4000)); + when(payments.refund("claim:ship-1")).thenReturn(new VehiclePayments.Refund(Result.APPLIED, 25), + new VehiclePayments.Refund(Result.APPLIED, 0)); + + for (int attempt = 0; attempt < 2; attempt++) { + confirmations.clear(player.getUniqueId()); + gate.onClaimFee(change(OwnershipReason.CLAIM, false)); + VehicleOwnershipChangeEvent paid = change(OwnershipReason.CLAIM, false); + gate.onClaimFee(paid); + gate.onChanged(changed(false, OwnershipReason.CLAIM, paid.getOperationId(), false, actor)); + } + + verify(logger).warning("Transfer fee refund short by 15.0 for " + actor.characterId() + " from faction realm"); + verify(logger).warning("Transfer fee refund short by 40.0 for " + actor.characterId() + " from faction realm"); + verify(player).sendMessage(VehicleFeeMessages.claimRefundedPart(25)); + verify(player).sendMessage(VehicleFeeMessages.claimRefundShort(15)); + verify(player).sendMessage(VehicleFeeMessages.claimRefundShort(40)); + verify(player, never()).sendMessage(VehicleFeeMessages.claimRefundedPart(0)); + verify(player, never()).sendMessage(VehicleFeeMessages.claimRefunded()); + assertTrue(VehicleFeeMessages.claimRefundShort(15).contains("15") + && VehicleFeeMessages.claimRefundedPart(25).contains("25")); + } + + @Test + void aStaffTakeoverRecordsTheTargetCharacterWithoutAFeeAndIsAudited() { + CharacterRef target = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + factionRow(); + + gate.onChanged(changed(true, OwnershipReason.TAKEOVER, UUID.randomUUID(), true, target)); + + verify(payments).personal("ship-1", "ship", target.characterId(), target.ownerId()); + verify(payments, never()).charge(anyString(), anyString(), any(), any()); + verify(logger).info(startsWith("[vehicle audit] Claimer TAKEOVER ship-1 (ship)")); + } + + @Test + void aClaimWhosePayerRowCannotBeWrittenIsLeftToTheReconcile() { + when(payments.personal(anyString(), anyString(), any(), any())).thenReturn(false); + gate.onChanged(changed(true, OwnershipReason.CLAIM, UUID.randomUUID(), false, null)); + paymentsStatic.when(VehiclePayments::current).thenReturn(null); + gate.onChanged(changed(true, OwnershipReason.CLAIM, UUID.randomUUID(), false, actor)); + VehicleOwnershipChangedEvent anonymous = new VehicleOwnershipChangedEvent(true, OwnershipReason.CLAIM, + UUID.randomUUID(), null, vehicle, null, null, false, null, null, + new OwnershipValues("none", List.of(), false, false, null), + new OwnershipValues("none", List.of(), false, false, null)); + gate.onChanged(anonymous); + verify(logger, times(3)).warning(contains("upkeep reconcile adds it")); + assertNull(store.getLastOwner("ship-1")); + } + + @Test + void clearingDropsThePayerRowButKeepsTheLastOwnerForTheTransferFee() { + store.setLastOwner("ship-1", "Previous"); + gate.onChanged(changed(true, OwnershipReason.CLEAR, UUID.randomUUID(), false, null)); + verify(payments, never()).replaceRow(anyString(), any()); + + registry.register(new PlayerVehicleRecord(actor.characterId(), "ship-1", "ship", OwnershipMode.PERSONAL, null)); + gate.onChanged(changed(true, OwnershipReason.CLEAR, UUID.randomUUID(), false, null)); + verify(payments).replaceRow("ship-1", null); + assertEquals("Previous", store.getLastOwner("ship-1")); + + when(payments.replaceRow(anyString(), any())).thenReturn(false); + gate.onChanged(changed(true, OwnershipReason.CLEAR, UUID.randomUUID(), true, null)); + verify(logger).warning(contains("could not be removed")); + verify(logger).info(startsWith("[vehicle audit] Claimer CLEAR ship-1")); + paymentsStatic.when(VehiclePayments::current).thenReturn(null); + gate.onChanged(changed(true, OwnershipReason.CLEAR, UUID.randomUUID(), false, null)); + verify(logger, times(2)).warning(contains("could not be removed")); + } + + @Test + void systemWritesAndWhitelistChangesNeverTouchTheRegistry() { + gate.onChanged(changed(true, OwnershipReason.RESTAMP, UUID.randomUUID(), false, null)); + gate.onChanged(changed(true, OwnershipReason.WHITELIST_ADD, UUID.randomUUID(), false, actor)); + verifyNoInteractions(payments); + } + + @Test + void anOperationIsRecordedOnceEvenIfReportedAgainAndOldOnesAreForgotten() { + UUID operation = UUID.randomUUID(); + gate.onChanged(changed(true, OwnershipReason.CLAIM, operation, false, actor)); + gate.onChanged(changed(true, OwnershipReason.CLAIM, operation, false, actor)); + verify(payments, times(1)).personal(anyString(), anyString(), any(), any()); + + for (int i = 0; i < VehicleOwnershipGate.REMEMBERED_OPERATIONS; i++) { + gate.onChanged(changed(true, OwnershipReason.WHITELIST_ADD, UUID.randomUUID(), false, actor)); + } + gate.onChanged(changed(true, OwnershipReason.CLAIM, operation, false, actor)); + verify(payments, times(2)).personal(anyString(), anyString(), any(), any()); + } + + @Test + void aRecordingFailureIsLoggedNotThrown() { + when(payments.personal(anyString(), anyString(), any(), any())).thenThrow(new IllegalStateException("disk")); + gate.onChanged(changed(true, OwnershipReason.CLAIM, UUID.randomUUID(), false, actor)); + verify(logger).log(eq(Level.SEVERE), contains("Could not record ownership change"), any(IllegalStateException.class)); + } + + private VehicleOwnershipEditCheckEvent check(OwnershipReason reason, boolean staff) { + return new VehicleOwnershipEditCheckEvent(player, vehicle, reason, actor, staff); + } + + @Test + void factionLeadersMayOpenTheMenuButUseNoControlAndStaleStampsAreHiddenAndFixed() { + UUID leader = UUID.randomUUID(); + Faction red = mock(Faction.class); + when(red.getId()).thenReturn("red"); + when(red.getLeader()).thenReturn(leader.toString()); + FactionManager.factions.add(red); + factionRow(); + ownerData.setOwner("char_" + leader); + try (MockedStatic stamps = mockStatic(LeaderStamps.class, CALLS_REAL_METHODS)) { + stamps.when(() -> LeaderStamps.scheduleRestamp(any())).thenAnswer(call -> null); + VehicleOwnershipEditCheckEvent menu = check(null, false); + gate.onEditCheck(menu); + assertTrue(menu.isAllowed()); + VehicleOwnershipEditCheckEvent toggle = check(OwnershipReason.WHITELIST_TOGGLE, false); + gate.onEditCheck(toggle); + assertFalse(toggle.isAllowed()); + VehicleOwnershipEditCheckEvent staff = check(OwnershipReason.CLEAR, true); + gate.onEditCheck(staff); + assertTrue(staff.isAllowed()); + stamps.verify(() -> LeaderStamps.scheduleRestamp(any()), never()); + + ownerData.setOwner("char_" + UUID.randomUUID()); + VehicleOwnershipEditCheckEvent stale = check(null, false); + gate.onEditCheck(stale); + assertFalse(stale.isAllowed()); + stamps.verify(() -> LeaderStamps.scheduleRestamp(any())); + } + registry.unregister("ship-1"); + VehicleOwnershipEditCheckEvent personal = check(OwnershipReason.CLEAR, false); + gate.onEditCheck(personal); + assertTrue(personal.isAllowed()); + when(vehicle.getUUID()).thenThrow(new IllegalStateException("broken")); + VehicleOwnershipEditCheckEvent broken = check(null, false); + gate.onEditCheck(broken); + assertFalse(broken.isAllowed()); + } + + @Test + void theClickBackstopRefusesAStaleFactionStampAndSchedulesTheFix() { + factionRow(); + ownerData.setOwner("player_FormerLeader"); + try (MockedStatic stamps = mockStatic(LeaderStamps.class, CALLS_REAL_METHODS)) { + stamps.when(() -> LeaderStamps.scheduleRestamp(any())).thenAnswer(call -> null); + VehiclePreInteractEvent stale = new VehiclePreInteractEvent(player, vehicle); + gate.onPreInteract(stale); + assertTrue(stale.isCancelled()); + stamps.verify(() -> LeaderStamps.scheduleRestamp(registry.getByVehicleUuid("ship-1").orElseThrow())); + + ownerData.setOwner("char_none"); + VehiclePreInteractEvent fresh = new VehiclePreInteractEvent(player, vehicle); + gate.onPreInteract(fresh); + assertFalse(fresh.isCancelled()); + } + VehiclePreInteractEvent nothing = new VehiclePreInteractEvent(player, null); + gate.onPreInteract(nothing); + assertFalse(nothing.isCancelled()); + when(vehicle.getOwnerData()).thenReturn(null); + VehiclePreInteractEvent noOwner = new VehiclePreInteractEvent(player, vehicle); + gate.onPreInteract(noOwner); + assertFalse(noOwner.isCancelled()); + when(vehicle.getOwnerData()).thenThrow(new IllegalStateException("broken")); + VehiclePreInteractEvent broken = new VehiclePreInteractEvent(player, vehicle); + gate.onPreInteract(broken); + assertTrue(broken.isCancelled()); + } + + @Test + void aLeaderChangeRestampsThatFactionsVehicles() { + try (MockedStatic stamps = mockStatic(LeaderStamps.class)) { + Faction faction = mock(Faction.class); + gate.onLeaderChanged(new FactionLeaderChangedEvent(faction, "a", "b")); + stamps.verify(() -> LeaderStamps.onLeaderChanged(faction, registry)); + } + assertEquals(Optional.empty(), registry.getByVehicleUuid("ship-1")); + } + + @Test + void aVehicleHeldForStaffCannotBeChangedByPlayers() throws Exception { + FakeOwnerApi.install().stored("ship-1", "ship", "none"); + VehicleQuarantine quarantine = new VehicleQuarantine(folder); + quarantine.load(List.of(new VehicleQuarantine.Entry("ship-1", "none", "construction owner could not be written"))); + VehicleQuarantine.use(quarantine); + VehicleOwnershipChangeEvent claim = change(OwnershipReason.CLAIM, false); + VehicleOwnershipChangeEvent staff = change(OwnershipReason.TAKEOVER, true); + + gate.onChange(claim); + gate.onChange(staff); + + assertTrue(claim.isCancelled()); + verify(player).sendMessage(VehicleOwnershipGate.HELD); + assertFalse(staff.isCancelled()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServicesTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServicesTest.java new file mode 100644 index 00000000..58e8cc62 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServicesTest.java @@ -0,0 +1,63 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.plugin.ServicesManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +import net.tfminecraft.vehicleframework.api.QuarantinedVehicle; +import net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority; +import net.tfminecraft.vehicleframework.api.VehicleQuarantineSource; + +class VehicleOwnershipServicesTest { + @TempDir Path folder; + + @Test + void theQuarantineSourceListsHeldBackVehiclesByIdForStaff() throws Exception { + ServicesManager services = mock(ServicesManager.class); + Plugin plugin = mock(Plugin.class); + UUID vehicle = UUID.randomUUID(); + FakeOwnerApi.install().stored(vehicle.toString(), "cart", "player_Old"); + VehicleQuarantine quarantine = new VehicleQuarantine(folder); + quarantine.load(List.of(new VehicleQuarantine.Entry(vehicle.toString(), "player_Old", "renamed"))); + quarantine.setFindings(List.of(new VehicleQuarantine.Entry("not-a-uuid", null, "reconcile: odd row"))); + + VehicleOwnershipServices.registerQuarantine(services, plugin, quarantine); + + ArgumentCaptor source = ArgumentCaptor.forClass(VehicleQuarantineSource.class); + verify(services).register(eq(VehicleQuarantineSource.class), source.capture(), eq(plugin), + eq(ServicePriority.Normal)); + assertEquals(List.of(new QuarantinedVehicle(vehicle, "player_Old", "renamed")), + source.getValue().quarantinedVehicles()); + } + + @Test + void theAuthorityAnswersReadyFromSimpleFactionsOnEveryCall() { + ServicesManager services = mock(ServicesManager.class); + AtomicBoolean ready = new AtomicBoolean(true); + + VehicleOwnershipServices.registerAuthority(services, mock(Plugin.class), ready::get); + + ArgumentCaptor authority = ArgumentCaptor.forClass(VehicleOwnershipAuthority.class); + verify(services).register(eq(VehicleOwnershipAuthority.class), authority.capture(), any(Plugin.class), + eq(ServicePriority.Normal)); + assertTrue(authority.getValue().ready()); + ready.set(false); + assertFalse(authority.getValue().ready()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantineTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantineTest.java new file mode 100644 index 00000000..95009905 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantineTest.java @@ -0,0 +1,78 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import net.tfminecraft.simplefactions.vehicles.owners.VehicleQuarantine.Entry; + +class VehicleQuarantineTest { + @TempDir Path folder; + + @Test + void heldBackVehiclesAreListedUntilStaffGiveThemAnOwnerAndSurviveRestarts() throws IOException { + FakeOwnerApi api = FakeOwnerApi.install().stored("legacy", "cart", "player_Old") + .stored("built", "cart", "char_none").stored("fixed", "cart", "char_none"); + VehicleQuarantine quarantine = new VehicleQuarantine(folder); + quarantine.load(List.of(new Entry("legacy", "player_Old", "renamed"), new Entry("fixed", "player_Old", "x"), + new Entry("gone", "player_Old", "removed"))); + quarantine.add(new Entry("built", "char_none", "payer is not the constructor")); + quarantine.setFindings(List.of(new Entry("row", null, "reconcile: registry row has no vehicle"))); + + assertEquals(List.of(new Entry("legacy", "player_Old", "renamed"), + new Entry("built", "char_none", "payer is not the constructor"), + new Entry("row", null, "reconcile: registry row has no vehicle")), quarantine.list()); + assertEquals(1, quarantine.findings().size()); + + api.stored("built", "cart", "char_" + java.util.UUID.randomUUID()); + VehicleQuarantine restarted = new VehicleQuarantine(folder); + restarted.load(List.of()); + assertEquals(List.of(), restarted.list()); + api.stored("built", "cart", "char_none"); + assertEquals(List.of(new Entry("built", "char_none", "payer is not the constructor")), restarted.list()); + } + + @Test + void theRunningListIsSharedAndAnUnreadableFileFailsTheLoad() throws IOException { + VehicleQuarantine quarantine = new VehicleQuarantine(folder); + assertNull(VehicleQuarantine.current()); + VehicleQuarantine.use(quarantine); + assertSame(quarantine, VehicleQuarantine.current()); + quarantine.load(List.of()); + assertEquals(List.of(), quarantine.list()); + + Files.createDirectories(folder.resolve("vehicles")); + Files.writeString(folder.resolve("vehicles/quarantine.json"), "{\"entries\":null}"); + quarantine.load(List.of()); + assertEquals(List.of(), quarantine.list()); + Files.writeString(folder.resolve("vehicles/quarantine.json"), "{broken"); + assertThrows(IOException.class, () -> new VehicleQuarantine(folder).load(List.of())); + } + + @Test + void heldVehiclesAreThoseStillListedAndUnreadableOnesStayListed() throws IOException { + FakeOwnerApi.install().stored("legacy", "cart", "player_Old").stored("fixed", "cart", "char_none"); + assertEquals(false, VehicleQuarantine.held("legacy"), "Nothing is held without a running list"); + VehicleQuarantine quarantine = new VehicleQuarantine(folder); + quarantine.load(List.of(new Entry("legacy", "player_Old", "renamed"), new Entry("fixed", "player_Old", "x"), + new Entry("unreadable", null, "its ownership fields cannot be read"))); + VehicleQuarantine.use(quarantine); + + assertEquals(true, VehicleQuarantine.held("legacy")); + assertEquals(false, VehicleQuarantine.held("fixed")); + assertEquals(true, VehicleQuarantine.held("unreadable")); + assertEquals(false, VehicleQuarantine.held("other")); + assertEquals(false, VehicleQuarantine.held(null)); + assertEquals(List.of(new Entry("legacy", "player_Old", "renamed"), + new Entry("unreadable", null, "its ownership fields cannot be read")), quarantine.list()); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcileTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcileTest.java new file mode 100644 index 00000000..59fa84cb --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcileTest.java @@ -0,0 +1,139 @@ +package net.tfminecraft.simplefactions.vehicles.owners; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.managers.FactionManager; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; +import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; + +class VehicleUpkeepReconcileTest { + private final PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); + private final Map accounts = new HashMap<>(); + private final List written = new ArrayList<>(); + private final Logger logger = mock(Logger.class); + private UUID leader; + private boolean rowsSave = true; + private FakeOwnerApi api; + private List previousFactions; + + @BeforeEach + void setUp() { + api = FakeOwnerApi.install(); + previousFactions = FactionManager.factions; + FactionManager.factions = new ArrayList<>(); + leader = UUID.randomUUID(); + Faction red = mock(Faction.class); + when(red.getId()).thenReturn("red"); + when(red.getLeader()).thenReturn(leader.toString()); + FactionManager.factions.add(red); + } + + @AfterEach + void tearDown() { + FactionManager.factions = previousFactions; + } + + private List run() { + return new VehicleUpkeepReconcile(api, registry, (vehicle, type, character, account) -> { + written.add(vehicle + " " + type + " " + character + " " + account); + return rowsSave; + }, leader::equals, character -> Optional.ofNullable(accounts.get(character)), logger).run(); + } + + private static Set reasons(List findings) { + Set out = new java.util.TreeSet<>(); + for (VehicleQuarantine.Entry entry : findings) out.add(entry.vehicle() + ": " + entry.reason()); + return out; + } + + @Test + void aCharacterOwnedVehicleWithoutARowGetsAPayerRowForItsOwner() { + UUID kira = UUID.randomUUID(); + UUID account = UUID.randomUUID(); + accounts.put(kira, account); + api.stored("cart", "cart", "char_" + kira); + + assertEquals(List.of(), run()); + + assertEquals(List.of("cart cart " + kira + " " + account), written); + verify(logger).info(contains("now pays its upkeep")); + } + + @Test + void whatCannotBeRepairedSafelyIsListedForStaff() { + UUID kira = UUID.randomUUID(); + UUID unknown = UUID.randomUUID(); + accounts.put(kira, UUID.randomUUID()); + api.stored("leaders", "cart", "char_" + leader) + .stored("unknown", "cart", "char_" + unknown) + .stored("mismatch", "cart", "char_" + UUID.randomUUID()) + .stored("stale", "gun", "player_Former") + .stored("fine", "gun", "char_" + leader) + .stored("legacy", "cart", "player_Old") + .stored("unowned", "cart", "none"); + registry.register(new PlayerVehicleRecord(kira, "mismatch", "cart", OwnershipMode.PERSONAL, null, null, + UUID.randomUUID())); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "stale", "gun", OwnershipMode.POOL, null, "red")); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "fine", "gun", OwnershipMode.POOL, null, "red")); + registry.register(new PlayerVehicleRecord(null, "destroyed", "cart", OwnershipMode.PERSONAL, null)); + registry.register(new PlayerVehicleRecord(null, "legacy", "cart", OwnershipMode.PERSONAL, null)); + + assertEquals(Set.of( + "destroyed: reconcile: registry row has no vehicle", + "leaders: reconcile: owner is a faction leader but the vehicle has no registry row", + "legacy: reconcile: personal registry row names character null, the vehicle names another owner", + "mismatch: reconcile: personal registry row names character " + kira + + ", the vehicle names another owner", + "stale: reconcile: faction vehicle should carry char_" + leader, + "unknown: reconcile: owner character is unknown and the vehicle has no registry row"), + reasons(run()).stream().filter(line -> !line.startsWith("legacy: reconcile: personal row's")) + .collect(java.util.stream.Collectors.toSet())); + assertEquals(List.of(), written); + } + + @Test + void aPersonalRowWithNoKnowableAccountIsListedBecauseItCountsAgainstEveryone() { + api.stored("legacy", "cart", "player_Old"); + registry.register(new PlayerVehicleRecord(null, "legacy", "cart", OwnershipMode.PERSONAL, null)); + + assertEquals(Set.of("legacy: reconcile: personal registry row names character null, the vehicle names another owner", + "legacy: reconcile: personal row's account is unknown, so it counts against every claimer"), + reasons(run())); + } + + @Test + void aRowThatCannotBeSavedIsListed() { + UUID kira = UUID.randomUUID(); + accounts.put(kira, UUID.randomUUID()); + api.stored("cart", "cart", "char_" + kira); + rowsSave = false; + + assertEquals(Set.of("cart: reconcile: could not save a payer row for its owner"), reasons(run())); + } + + @Test + void anUnreadableVehicleFrameworkFailsTheReconcile() { + api.failure = new IllegalStateException("SQLite closed"); + assertThrows(IllegalStateException.class, this::run); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolServiceTest.java index a7ae0b9d..95a0fdcb 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolServiceTest.java @@ -29,6 +29,31 @@ import net.tfminecraft.vehicleframework.data.OwnerData; class FactionVehiclePoolServiceTest { + private static final UUID ALICE = UUID.randomUUID(); + private static final String ALICE_TAG = "char_" + ALICE; + private net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi api; + + @Test + void aFailedLeaderStampPutsThePersonalRowBack() { + PlayerVehicleRecord personal = new PlayerVehicleRecord(UUID.randomUUID(), "v1", "coal_car", + OwnershipMode.PERSONAL, null); + registry.register(personal); + api.forced = net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result.SAVE_FAILED; + net.tfminecraft.simplefactions.SimpleFactions plugin = mock(net.tfminecraft.simplefactions.SimpleFactions.class); + java.util.logging.Logger logger = mock(java.util.logging.Logger.class); + when(plugin.getLogger()).thenReturn(logger); + when(plugin.saveVehicleRegistry()).thenReturn(true, false); + try (var sf = org.mockito.Mockito.mockStatic(net.tfminecraft.simplefactions.SimpleFactions.class)) { + sf.when(net.tfminecraft.simplefactions.SimpleFactions::getInstance).thenReturn(plugin); + assertEquals(false, service.tryRegister(faction, target("v1", "coal_car", owned("char_x")), UUID.randomUUID())); + } + assertEquals(personal, registry.getByVehicleUuid("v1").orElseThrow()); + org.mockito.Mockito.verify(logger).severe(org.mockito.ArgumentMatchers.contains("pool stamp failed")); + + registry.unregister("v1"); + assertEquals(false, service.tryRegister(faction, target("v1", "coal_car", owned("char_x")), UUID.randomUUID())); + assertEquals(java.util.Optional.empty(), registry.getByVehicleUuid("v1")); + } private Path tempDir; private PlayerVehicleRegistry registry; private FactionVehiclePoolService service; @@ -98,7 +123,9 @@ void setUp() throws IOException { when(military.getRegiment("artillery")).thenReturn(artillery); faction = mock(Faction.class); when(faction.getId()).thenReturn("red"); - when(faction.getLeader()).thenReturn("Alice"); + when(faction.getLeader()).thenReturn(ALICE.toString()); + api = net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi.install() + .live("gun-1", "field_artillery", ALICE_TAG).live("v1", "coal_car", "char_" + UUID.randomUUID()); when(faction.getMilitary()).thenReturn(military); } @@ -119,13 +146,13 @@ void poolTargetWordIsPool() { @Test void nonBerthableTrainCanJoinThePool() { - assertEquals(CanAddResult.OK, service.canAdd(faction, target("v1", "coal_car", "player_Alice"))); + assertEquals(CanAddResult.OK, service.canAdd(faction, target("v1", "coal_car", ALICE_TAG))); } @Test void berthableTypeIsRefused() { - assertEquals(CanAddResult.MUST_BERTH, service.canAdd(faction, target("v1", "horse_cart", "player_Alice"))); - assertEquals(CanAddResult.MUST_BERTH, service.canAdd(faction, target("v2", "ironclad", "player_Alice"))); + assertEquals(CanAddResult.MUST_BERTH, service.canAdd(faction, target("v1", "horse_cart", ALICE_TAG))); + assertEquals(CanAddResult.MUST_BERTH, service.canAdd(faction, target("v2", "ironclad", ALICE_TAG))); assertEquals( "§cThis vehicle belongs at an installation, not in the faction pool.", VehicleTransferMessages.forPoolResult(CanAddResult.MUST_BERTH, "ironclad", faction)); @@ -136,22 +163,22 @@ void artilleryIsCappedByRegimentSlots() { when(artillery.getCurrentSlots()).thenReturn(0); assertEquals( CanAddResult.NO_ARTILLERY_CAPACITY, - service.canAdd(faction, target("gun-1", "field_artillery", "player_Alice"))); + service.canAdd(faction, target("gun-1", "field_artillery", ALICE_TAG))); when(artillery.getCurrentSlots()).thenReturn(1); - assertEquals(CanAddResult.OK, service.canAdd(faction, target("gun-1", "field_artillery", "player_Alice"))); + assertEquals(CanAddResult.OK, service.canAdd(faction, target("gun-1", "field_artillery", ALICE_TAG))); - OwnerData owner = owned("player_Alice"); + OwnerData owner = owned(ALICE_TAG); service.register(faction, target("gun-1", "field_artillery", owner), UUID.randomUUID()); assertEquals( CanAddResult.NO_ARTILLERY_CAPACITY, - service.canAdd(faction, target("gun-2", "field_artillery", "player_Alice"))); + service.canAdd(faction, target("gun-2", "field_artillery", ALICE_TAG))); assertEquals(1, registry.countPoolCategory("red", "artillery")); } @Test void registerAssignsPoolOwnershipToTheFactionLeader() { - OwnerData owner = owned("player_Bob"); + OwnerData owner = owned("char_" + UUID.randomUUID()); UUID original = UUID.randomUUID(); service.register(faction, target("v1", "coal_car", owner), original); @@ -159,7 +186,8 @@ void registerAssignsPoolOwnershipToTheFactionLeader() { assertEquals(OwnershipMode.POOL, record.getMode()); assertEquals("red", record.getFactionId()); assertEquals(original, record.getPlayerUuid()); - assertEquals("player_Alice", owner.getOwner()); + assertEquals(ALICE_TAG, api.owner("v1")); + assertEquals(java.util.List.of("v1 " + ALICE_TAG + " TRANSFER"), api.writes); assertEquals(1.0, FactionVehiclePoolService.dailyUpkeep(registry, "red")); } @@ -170,10 +198,10 @@ void refusesVehiclesAlreadyInThePoolOrAtAnInstallation() { registry.register(new PlayerVehicleRecord( UUID.randomUUID(), "berthed", "ironclad", OwnershipMode.INSTALLATION, "port-1")); - assertEquals(CanAddResult.ALREADY_IN_POOL, service.canAdd(faction, target("pooled", "coal_car", "player_Alice"))); - assertEquals(CanAddResult.ALREADY_BERTHED, service.canAdd(faction, target("berthed", "ironclad", "player_Alice"))); + assertEquals(CanAddResult.ALREADY_IN_POOL, service.canAdd(faction, target("pooled", "coal_car", ALICE_TAG))); + assertEquals(CanAddResult.ALREADY_BERTHED, service.canAdd(faction, target("berthed", "ironclad", ALICE_TAG))); assertEquals(CanAddResult.NOT_OWNED, service.canAdd(faction, target("free", "coal_car", "none"))); - assertEquals(CanAddResult.UNKNOWN_TYPE, service.canAdd(faction, target("odd", "not_a_vehicle", "player_Alice"))); + assertEquals(CanAddResult.UNKNOWN_TYPE, service.canAdd(faction, target("odd", "not_a_vehicle", ALICE_TAG))); } @Test diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/FakeOwnedInventory.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/FakeOwnedInventory.java deleted file mode 100644 index 1de6d0d4..00000000 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/FakeOwnedInventory.java +++ /dev/null @@ -1,34 +0,0 @@ -package net.tfminecraft.simplefactions.vehicles.registry; - - -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; - -public final class FakeOwnedInventory implements VehicleOwnershipQueries.OwnedInventory { - private final List vehicles = new ArrayList<>(); - - public FakeOwnedInventory add(String uuid, String typeId, String owner) { - vehicles.add(new OwnedVehicleSummary(uuid, typeId, typeId, Optional.empty(), false, owner)); - return this; - } - - @Override - public List listByOwner(String ownerEntry) { - List out = new ArrayList<>(); - for (OwnedVehicleSummary vehicle : vehicles) { - if (ownerEntry != null && ownerEntry.equalsIgnoreCase(vehicle.getOwner())) { - out.add(vehicle); - } - } - return out; - } - - @Override - public List listAllPlayerOwned() { - return new ArrayList<>(vehicles); - } -} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccountsTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccountsTest.java new file mode 100644 index 00000000..d5e8989c --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccountsTest.java @@ -0,0 +1,85 @@ +package net.tfminecraft.simplefactions.vehicles.registry; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RegistryAccountsTest { + @TempDir Path folder; + private final PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); + + @Test + void olderPersonalRowsGetTheirCharactersAccountElseTheAccountTheMigrationsMatched() throws IOException { + UUID cached = UUID.randomUUID(); + UUID character = UUID.randomUUID(); + UUID characterAccount = UUID.randomUUID(); + UUID sixA = UUID.randomUUID(); + UUID payer = UUID.randomUUID(); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "cached", "cart", OwnershipMode.PERSONAL, null, + null, cached)); + registry.register(new PlayerVehicleRecord(character, "character", "cart", OwnershipMode.PERSONAL, null)); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "deleted", "cart", OwnershipMode.PERSONAL, null)); + registry.register(new PlayerVehicleRecord(null, "payer", "cart", OwnershipMode.PERSONAL, null)); + registry.register(new PlayerVehicleRecord(null, "unknown", "cart", OwnershipMode.PERSONAL, null)); + registry.register(new PlayerVehicleRecord(null, "pool", "gun", OwnershipMode.POOL, null, "red")); + + int filled = RegistryAccounts.fill(folder, registry, + id -> id.equals(character) ? Optional.of(characterAccount) : Optional.empty(), + List.of(Map.of("deleted", sixA, "pool", UUID.randomUUID()), Map.of("payer", payer, "deleted", payer))); + + assertEquals(3, filled); + assertEquals(cached, account("cached")); + assertEquals(characterAccount, account("character")); + assertEquals(sixA, account("deleted")); + assertEquals(payer, account("payer")); + assertNull(account("unknown")); + assertNull(account("pool")); + PlayerVehicleRegistry reloaded = new PlayerVehicleRegistry(); + new VehicleRegistryPersistence(folder.resolve("Cache").toFile(), reloaded).load(); + assertEquals(payer, reloaded.getByVehicleUuid("payer").orElseThrow().getAccountUuid()); + assertEquals(cached, reloaded.getByVehicleUuid("cached").orElseThrow().getAccountUuid()); + } + + @Test + void nothingToFillWritesNothing() throws IOException { + registry.register(new PlayerVehicleRecord(null, "unknown", "cart", OwnershipMode.PERSONAL, null)); + assertEquals(0, RegistryAccounts.fill(folder, registry, id -> Optional.empty(), List.of())); + assertFalse(Files.exists(folder.resolve("Cache/vehicles_registry.json"))); + } + + @Test + void rowsKeepTheirAccountAcrossTheRegistryFileAndReplacementsAreDurable() throws IOException { + UUID account = UUID.randomUUID(); + UUID character = UUID.randomUUID(); + VehicleRegistryPersistence.personal(folder, registry, "cart", "cart", character, account); + PlayerVehicleRecord row = registry.getByVehicleUuid("cart").orElseThrow(); + assertEquals(account, row.getAccountUuid()); + assertEquals(account, row.withAccount(account).getAccountUuid()); + + VehicleRegistryPersistence.replace(folder, registry, "cart", null); + assertTrue(registry.getAll().isEmpty()); + assertEquals("[]", Files.readString(folder.resolve("Cache/vehicles_registry.json")).strip()); + VehicleRegistryPersistence.replace(folder, registry, "cart", row); + PlayerVehicleRegistry reloaded = new PlayerVehicleRegistry(); + new VehicleRegistryPersistence(new File(folder.toFile(), "Cache"), reloaded).load(); + assertEquals(account, reloaded.getByVehicleUuid("cart").orElseThrow().getAccountUuid()); + assertEquals(character, reloaded.getByVehicleUuid("cart").orElseThrow().getPlayerUuid()); + } + + private UUID account(String vehicle) { + return registry.getByVehicleUuid(vehicle).orElseThrow().getAccountUuid(); + } +} diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java index efeed0c2..e0de403c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java @@ -22,16 +22,14 @@ import net.tfminecraft.denareconomy.accounts.OfflineModifier; import net.tfminecraft.simplefactions.identity.Members; -import net.tfminecraft.simplefactions.objects.Faction; -import net.tfminecraft.simplefactions.vehicles.berth.InstallationVehicleOwnerSync; +import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration; import net.tfminecraft.tlibs.character.CharacterIdentity; 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.vehicleframework.data.OwnerData; -/** VehicleFramework still names accounts; these bridge its owner tags and member keys. */ +/** The live lookups the vehicle owner migration seeds legacy owner names with. */ class VehicleOwnerKeysTest { private final UUID account = UUID.randomUUID(); private final UUID characterId = UUID.randomUUID(); @@ -62,78 +60,76 @@ void close() { bukkit.close(); } + private final VehicleOwnerMigration.LiveIdentity identity = new VehicleOwnerMigration.LiveIdentity(); + + @Test + void aLegacyOwnerNameCountsOnlyWhileItIsStillThatAccountsName() { + OfflinePlayer current = mock(OfflinePlayer.class); + when(current.getName()).thenReturn("owner"); + bukkit.when(() -> Bukkit.getOfflinePlayer(account)).thenReturn(current); + assertEquals(account, identity.roundTrip("Owner")); + + when(current.getName()).thenReturn("Renamed"); + assertNull(identity.roundTrip("Owner"), "The name now belongs to someone else, or to nobody"); + when(current.getName()).thenReturn(null); + assertNull(identity.roundTrip("Owner")); + assertNull(identity.roundTrip("Unknown"), "An account DenarEconomy never saw"); + } + @Test - void ownerKeyIsThePlayedCharacterElseTheLegacyHeirOfTheTaggedAccount() { - assertNull(VehicleOwnershipQueries.ownerKey(null)); - assertNull(VehicleOwnershipQueries.ownerKey(" ")); - assertNull(VehicleOwnershipQueries.ownerKey("Owner"), "No reviewed heir yet"); - heirs.put(account, new CharacterRef(UUID.randomUUID(), characterId)); - assertNull(VehicleOwnershipQueries.ownerKey("Owner"), "A foreign heir never names the owner"); + void charactersResolveToTheirAccountReviewedHeirAndPlainName() { + assertEquals(Optional.of(account), identity.accountOf(characterId)); + assertEquals(Optional.empty(), identity.accountOf(UUID.randomUUID())); + assertEquals(Optional.empty(), identity.heir(account)); heirs.put(account, new CharacterRef(account, characterId)); - assertEquals(Members.key(characterId), VehicleOwnershipQueries.ownerKey("Owner")); - assertNull(VehicleOwnershipQueries.ownerKey("Unknown"), "An account DenarEconomy never saw"); + assertEquals(Optional.of(new CharacterRef(account, characterId)), identity.heir(account)); + assertEquals("Owner's Character", identity.nameOf(characterId)); + assertNull(identity.nameOf(UUID.randomUUID())); + UUID coloured = UUID.randomUUID(); + when(provider.info(coloured)).thenReturn(Optional.of(new CharacterInfo(new CharacterRef(account, coloured), + "\u00a7a ", CharacterStatus.ALIVE, null, Set.of(), 0, null))); + assertNull(identity.nameOf(coloured), "A name that is only colour codes is no name"); Player online = mock(Player.class); when(online.getUniqueId()).thenReturn(account); when(provider.currentSession(online)).thenReturn(Optional.of(new CharacterSession(new CharacterRef(account, characterId), 1))); when(provider.isCurrent(any())).thenReturn(true); - bukkit.when(() -> Bukkit.getPlayerExact("Owner")).thenReturn(online); - assertEquals(Members.key(characterId), VehicleOwnershipQueries.ownerKey("Owner")); assertEquals(characterId, VehicleOwnershipQueries.characterId(online)); Player stranger = mock(Player.class); - UUID strangerAccount = UUID.randomUUID(); - when(stranger.getUniqueId()).thenReturn(strangerAccount); + when(stranger.getUniqueId()).thenReturn(UUID.randomUUID()); assertNull(VehicleOwnershipQueries.characterId(stranger), "No character means no payer"); } - @Test - void withoutAServerOrProviderNoCharacterIsKnown() { - heirs.put(account, new CharacterRef(account, characterId)); - registered = null; - assertNull(VehicleOwnershipQueries.ownerKey("Owner")); - registered = provider; - bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); - assertNull(VehicleOwnershipQueries.ownerKey("Owner")); - bukkit.when(Bukkit::getServer).thenReturn(null); - assertNull(VehicleOwnershipQueries.ownerKey("Owner")); + private static CharacterInfo info(UUID owner, UUID id) { + return new CharacterInfo(new CharacterRef(owner, id), "Owner's Character", CharacterStatus.ALIVE, + null, Set.of(), 0, null); } @Test - void leaderOwnedFactionVehiclesNameTheLeadersAccountAndWaitWhileItIsUnknown() { - OfflinePlayer owner = mock(OfflinePlayer.class); - when(owner.getName()).thenReturn("Owner"); - bukkit.when(() -> Bukkit.getOfflinePlayer(account)).thenReturn(owner); - assertNull(VehicleOwnershipQueries.accountNameOf(null)); - assertNull(VehicleOwnershipQueries.accountNameOf("")); - assertEquals("Legacy", VehicleOwnershipQueries.accountNameOf("Legacy")); - assertEquals("Owner", VehicleOwnershipQueries.accountNameOf(Members.key(characterId))); - - Faction faction = mock(Faction.class); - when(faction.getLeader()).thenReturn(Members.key(characterId)); - assertEquals("player_Owner", InstallationVehicleOwnerSync.expectedOwner(faction)); - - registered = null; - assertNull(InstallationVehicleOwnerSync.expectedOwner(faction)); - OwnerData data = new OwnerData(); - data.setOwner("player_Previous"); - InstallationVehicleOwnerSync sync = new InstallationVehicleOwnerSync(new PlayerVehicleRegistry()); - sync.applyLeaderOwner(data, faction); - assertEquals("player_Previous", data.getOwner(), "An unreadable leader leaves the owner alone"); - PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); - registry.register(new PlayerVehicleRecord(characterId, "pooled", "cart", OwnershipMode.POOL, null, "home")); - var vehicle = mock(net.tfminecraft.vehicleframework.vehicles.ActiveVehicle.class); - when(vehicle.getUUID()).thenReturn("pooled"); - when(vehicle.getOwnerData()).thenReturn(data); - try (var factions = mockStatic(net.tfminecraft.simplefactions.managers.FactionManager.class)) { - factions.when(() -> net.tfminecraft.simplefactions.managers.FactionManager.getByString("home")).thenReturn(faction); - new InstallationVehicleOwnerSync(registry).syncIfBerthed(vehicle); - } - assertEquals("player_Previous", data.getOwner()); - bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); - } + void anExpiredUserCacheFallsBackToTheNameDenarEconomyRememberedLast(@org.junit.jupiter.api.io.TempDir java.nio.file.Path folder) + throws Exception { + OfflinePlayer forgotten = mock(OfflinePlayer.class); + bukkit.when(() -> Bukkit.getOfflinePlayer(account)).thenReturn(forgotten); + java.nio.file.Path index = folder.resolve("player-names.json"); + UUID other = UUID.randomUUID(); + java.nio.file.Files.writeString(index, "{\"oldname\":\"" + account + "\",\"owner\":\"" + account + + "\",\"someone\":\"" + other + "\",\"broken\":\"not-a-uuid\",\"nothing\":null," + + "\"object\":{}}"); + accounts.when(() -> OfflineModifier.playerId("OldName")).thenReturn(account); - private static CharacterInfo info(UUID owner, UUID id) { - return new CharacterInfo(new CharacterRef(owner, id), "Owner's Character", CharacterStatus.ALIVE, - null, Set.of(), 0, null); + var remembered = new VehicleOwnerMigration.LiveIdentity(index); + assertEquals(account, remembered.roundTrip("Owner"), "The account's newest remembered name"); + assertNull(remembered.roundTrip("OldName"), "A name the account has since replaced"); + assertEquals("someone", remembered.latestRemembered().get(other)); + + java.nio.file.Files.writeString(index, "{\"owner\":\"" + account + "\",\"renamed\":\"" + account + "\"}"); + assertNull(new VehicleOwnerMigration.LiveIdentity(index).roundTrip("Owner"), "The owner renamed since"); + java.nio.file.Files.writeString(index, "[\"not an object\"]"); + assertNull(new VehicleOwnerMigration.LiveIdentity(index).roundTrip("Owner")); + assertNull(new VehicleOwnerMigration.LiveIdentity(folder.resolve("absent.json")).roundTrip("Owner")); + + when(forgotten.getName()).thenReturn("Owner"); + assertEquals(account, new VehicleOwnerMigration.LiveIdentity(folder.resolve("absent.json")).roundTrip("Owner"), + "A name the server still knows needs no index"); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueriesTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueriesTest.java index b42f4b62..3a78de40 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueriesTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnershipQueriesTest.java @@ -1,73 +1,71 @@ package net.tfminecraft.simplefactions.vehicles.registry; - -import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; -import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; -import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; -import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.util.List; import java.util.UUID; -import org.junit.jupiter.api.AfterEach; +import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; -import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary; +import net.tfminecraft.simplefactions.testsupport.NameKeyedMembers; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; +import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.PersonalVehicle; class VehicleOwnershipQueriesTest { - @AfterEach - void tearDown() { - VehicleOwnershipQueries.setSourceForTests(null); - } + private final PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); @Test - void isPlayerOwner_rejectsNoneAndBlank() { - assertFalse(VehicleOwnershipQueries.isPlayerOwner(null)); - assertFalse(VehicleOwnershipQueries.isPlayerOwner("none")); - assertFalse(VehicleOwnershipQueries.isPlayerOwner("player_none")); - assertTrue(VehicleOwnershipQueries.isPlayerOwner("player_Alice")); - assertEquals("Alice", VehicleOwnershipQueries.playerNameFromOwner("player_Alice")); + void personalVehiclesAreCharacterAndLegacyOwnersExceptFactionVehicles() { + UUID character = UUID.randomUUID(); + registry.register(new PlayerVehicleRecord(character, "berthed", "ironclad", OwnershipMode.INSTALLATION, "port")); + registry.register(new PlayerVehicleRecord(character, "pooled", "coal_car", OwnershipMode.POOL, null, "red")); + FakeOwnerApi.install() + .stored("personal", "ironclad", "char_" + character) + .live("legacy", "cart", "player_Alice") + .stored("berthed", "ironclad", "char_" + character) + .stored("pooled", "coal_car", "char_none") + .stored("leaderless", "cart", "char_none") + .stored("unowned", "cart", "none") + .stored("nobody", "cart", "player_none"); + + assertEquals(List.of(new PersonalVehicle("personal", "ironclad"), new PersonalVehicle("legacy", "cart")), + VehicleOwnershipQueries.allPersonalVehicles(registry)); } @Test - void personalVehicles_excludesBerthedUuids() { - PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); - registry.register(new PlayerVehicleRecord( - UUID.randomUUID(), - "v-berthed", - "ironclad", - OwnershipMode.INSTALLATION, - "port-1")); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("v-personal", "ironclad", "player_Alice") - .add("v-berthed", "ironclad", "player_Alice")); + void personalRegistryRowsStandInWithoutAReadableVehicleFramework() { + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "personal", "cart", OwnershipMode.PERSONAL, null)); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "pooled", "cart", OwnershipMode.POOL, null, "red")); + List rows = List.of(new PersonalVehicle("personal", "cart")); - List personal = - VehicleOwnershipQueries.personalVehicles("Alice", registry); - assertEquals(1, personal.size()); - assertEquals("v-personal", personal.get(0).getUuid()); + assertEquals(rows, VehicleOwnershipQueries.allPersonalVehicles(registry)); + FakeOwnerApi api = FakeOwnerApi.install().stored("other", "cart", "char_" + UUID.randomUUID()); + api.failure = new IllegalStateException("SQLite closed"); + assertEquals(rows, VehicleOwnershipQueries.allPersonalVehicles(registry)); } @Test - void personalVehicles_excludesPoolUuids() { - PlayerVehicleRegistry registry = new PlayerVehicleRegistry(); - registry.register(new PlayerVehicleRecord( - UUID.randomUUID(), - "v-pool", - "coal_car", - OwnershipMode.POOL, - null, - "red")); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("v-personal", "ironclad", "player_Alice") - .add("v-pool", "coal_car", "player_Alice")); + void recordsAreWrittenForThePlayersCurrentCharacter() { + Player player = mock(Player.class); + when(player.getName()).thenReturn("Alice"); + assertEquals(NameKeyedMembers.characterOf("Alice"), VehicleOwnershipQueries.characterId(player)); + assertNull(VehicleOwnershipQueries.characterId(null)); + } - List personal = - VehicleOwnershipQueries.personalVehicles("Alice", registry); - assertEquals(1, personal.size()); - assertEquals("v-personal", personal.get(0).getUuid()); + @Test + void theFallbackToRegistryRowsIsLogged() { + FakeOwnerApi.install().failure = new IllegalStateException("SQLite closed"); + net.tfminecraft.simplefactions.SimpleFactions plugin = mock(net.tfminecraft.simplefactions.SimpleFactions.class); + java.util.logging.Logger logger = mock(java.util.logging.Logger.class); + when(plugin.getLogger()).thenReturn(logger); + try (var sf = org.mockito.Mockito.mockStatic(net.tfminecraft.simplefactions.SimpleFactions.class)) { + sf.when(net.tfminecraft.simplefactions.SimpleFactions::getInstance).thenReturn(plugin); + assertEquals(List.of(), VehicleOwnershipQueries.allPersonalVehicles(registry)); + } + org.mockito.Mockito.verify(logger).warning(org.mockito.ArgumentMatchers.contains("using personal registry rows")); } } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java index 4ae9531a..2194bbf9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehiclePayerMigrationTest.java @@ -52,6 +52,23 @@ class VehiclePayerMigrationTest { assertTrue(Files.readString(folder.resolve("identity/vehicle-payers.json")).contains("\"completed\": true")); } + @Test void characterOwnersAreAlreadyPastTheBridgeAndThePlanRemembersMatchedAccounts() throws Exception { + var registry = new PlayerVehicleRegistry(); + assertEquals(Map.of(), VehiclePayerMigration.accounts(folder)); + VehiclePayerMigration.migrate(folder, registry, List.of(vehicle("migrated", "char_" + UUID.randomUUID()), + vehicle("faction", "char_none"), vehicle("legacy", "player_OldName"), vehicle("unknown", "player_Nobody")), + name -> name.equals("OldName") ? heir.ownerId() : null, id -> heir); + assertEquals(List.of("legacy", "unknown"), registry.getAll().stream().map(PlayerVehicleRecord::getVehicleUuid) + .sorted().toList()); + assertEquals(Map.of("legacy", heir.ownerId()), VehiclePayerMigration.accounts(folder)); + Files.writeString(folder.resolve("identity/vehicle-payers.json"), "{\"completed\":true}"); + assertEquals(Map.of(), VehiclePayerMigration.accounts(folder)); + Files.writeString(folder.resolve("identity/vehicle-payers.json"), + "{\"rows\":[null,{\"vehicle\":null,\"account\":\"" + heir.ownerId() + "\"},{\"vehicle\":\"v\"}]," + + "\"completed\":true}"); + assertEquals(Map.of(), VehiclePayerMigration.accounts(folder)); + } + @Test void wrongAccountHeirAndUnknownAccountRemainUnmapped() throws Exception { var registry = new PlayerVehicleRegistry(); VehiclePayerMigration.migrate(folder, registry, List.of(vehicle("v", "player_Wrong")), diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryClaimServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryClaimServiceTest.java index 8356441d..26115631 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryClaimServiceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleRegistryClaimServiceTest.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.vehicles.registry; +import net.tfminecraft.simplefactions.vehicles.owners.FakeOwnerApi; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry; import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord; import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode; @@ -52,12 +53,11 @@ void setUp() throws IOException { registry = new PlayerVehicleRegistry(); service = new VehicleRegistryClaimService(registry); playerUuid = UUID.randomUUID(); - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory()); + FakeOwnerApi.install(); } @AfterEach void tearDown() throws IOException { - VehicleOwnershipQueries.setSourceForTests(null); if (tempDir != null) { Files.walk(tempDir) .sorted(java.util.Comparator.reverseOrder()) @@ -78,7 +78,7 @@ void tryRegisterOnClaim_allowsWhenUnderLimitWithoutWritingRegistry() { } @Test - void tryRegisterOnClaim_skipsWhenInstallationRowExists() { + void tryRegisterOnClaim_refusesAFactionVehicleOutright() { registry.register(new PlayerVehicleRecord( UUID.randomUUID(), "veh-1", @@ -91,7 +91,7 @@ void tryRegisterOnClaim_skipsWhenInstallationRowExists() { var result = service.tryRegisterOnClaim(player, target("veh-1", "monoplane")); - assertEquals(TryRegisterResult.SKIP, result.status()); + assertEquals(TryRegisterResult.FAIL_FACTION_VEHICLE, result.status()); assertEquals(1, registry.getAll().size()); } @@ -109,9 +109,10 @@ void tryRegisterOnClaim_unknownType() { @Test void tryRegisterOnClaim_slotLimit() { - VehicleOwnershipQueries.setSourceForTests(new FakeOwnedInventory() - .add("veh-a", "ironclad", "player_Alice") - .add("veh-b", "ironclad", "player_Alice")); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "veh-a", "ironclad", OwnershipMode.PERSONAL, + null, null, playerUuid)); + registry.register(new PlayerVehicleRecord(UUID.randomUUID(), "veh-b", "ironclad", OwnershipMode.PERSONAL, + null, null, playerUuid)); Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(playerUuid); diff --git a/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java b/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java index 82525dbb..4e8ea067 100644 --- a/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/war/freeze/VfBuildersConstructionFreezeTest.java @@ -72,4 +72,22 @@ void anOfflineBuilderIsJudgedByTheAccountsLegacyHeir() { void anOfflineBuilderWithoutTheCharacterProviderHasNoCharacter() { assertNull(VfBuildersConstructionFreeze.constructorKey(account)); } + + @Test + void aProjectWithACapturedCharacterIsJudgedByThatCharacterNotWhoeverPlaysTheAccountNow() { + UUID character = UUID.randomUUID(); + Player builder = mock(Player.class); + when(builder.getName()).thenReturn("SomeoneElse"); + bukkit.when(() -> Bukkit.getPlayer(account)).thenReturn(builder); + try (MockedStatic freeze = mockStatic(PreparationFreeze.class, + org.mockito.Mockito.CALLS_REAL_METHODS)) { + freeze.when(() -> PreparationFreeze.frozenUntil(org.mockito.ArgumentMatchers.eq(Members.key(character)), + any())).thenReturn(Instant.now().plusSeconds(3600)); + String reason = new VfBuildersConstructionFreeze().freezeReason(account, character); + assertEquals(true, reason.startsWith("battle postponed (")); + assertNull(new VfBuildersConstructionFreeze().freezeReason(account, null), + "Without a captured character the account's current character decides"); + assertNull(new VfBuildersConstructionFreeze().freezeReason(account, UUID.randomUUID())); + } + } } diff --git a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension index 3a242080..b6c99855 100644 --- a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension +++ b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -1 +1,2 @@ net.tfminecraft.simplefactions.testsupport.NameKeyedMembers +net.tfminecraft.simplefactions.testsupport.VehicleOwnersReset