From 90972b4bf05778d620cb93acdb377049917f54f2 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:54:41 +0000 Subject: [PATCH 1/8] build: pin VehicleFramework 2.13.0 and VFBuilders 2.2.0 Co-Authored-By: Claude Opus 5.5 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From a3e17c1d5c104b15339ddd781cc64371025dee15 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:20:26 +0000 Subject: [PATCH 2/8] feat: character vehicle owners and the SimpleFactions vehicle stage Vehicle work moves into a stage that runs once factions have started and VehicleFramework is enabled: payer migration, the one-time owner migration (plan, fixed-name backups, conditional apply, separate stored/live verify), the leader and upkeep reconciles, the vehicle listeners and last the VehicleOwnershipAuthority. Every SimpleFactions owner write goes through VehicleFramework's setOwnership with the registry first and rolled back on failure. SimpleFactions vetoes non-staff changes to faction vehicles, charges claim fees under VehicleFramework's operation ID and records the payer row once from the post-write event. Leader changes and faction deletion restamp faction vehicles, with a click backstop. Slot limits count personal rows per cached account. Consent requests capture both characters. Co-Authored-By: Claude Opus 5.5 --- .../simplefactions/SimpleFactions.java | 159 ++++- .../events/FactionLeaderChangedEvent.java | 61 ++ .../InstallationTransferService.java | 26 +- .../managers/FactionManager.java | 2 + .../managers/inventory/InstallationView.java | 3 +- .../simplefactions/objects/Faction.java | 11 +- .../request/VehicleGiveConsentRequest.java | 20 +- .../request/VehicleHandoverRequest.java | 7 - .../VehicleTransferConsentRequest.java | 34 +- .../vehicles/VehicleIntegrationListener.java | 36 - .../BattleVehicleEligibilityService.java | 15 +- .../berth/FactionVehicleGiveService.java | 18 +- .../berth/FactionVehicleReleaseListener.java | 6 +- .../berth/FactionVehicleReleaseMessages.java | 1 + .../berth/FactionVehicleReleaseService.java | 151 +---- .../berth/InstallationVehicleOwnerSync.java | 106 +-- .../berth/InstallationVehicleService.java | 19 +- .../InstallationVehicleUnberthService.java | 6 +- .../berth/VehicleConstructionMessages.java | 3 + .../vehicles/berth/VehicleSlotGuard.java | 59 +- .../berth/VehicleTransferConsentService.java | 39 +- .../berth/VehicleTransferListener.java | 33 +- .../vehicles/fees/VehiclePayments.java | 14 +- .../fees/VehicleReclaimFeeListener.java | 71 +- .../fees/VehicleRegistrationFeeListener.java | 47 +- .../handover/VehicleHandoverListener.java | 3 +- .../handover/VehicleHandoverService.java | 65 +- .../maintenance/VehicleSettlement.java | 18 +- .../maintenance/VehicleUpkeepProjection.java | 7 +- .../vehicles/owners/ConstructionStamp.java | 47 ++ .../vehicles/owners/LeaderStamps.java | 137 ++++ .../vehicles/owners/OwnerFields.java | 27 + .../vehicles/owners/OwnerMeta.java | 31 + .../vehicles/owners/OwnerTags.java | 74 ++ .../vehicles/owners/VehicleClaimsOff.java | 24 + .../vehicles/owners/VehicleOwnerApi.java | 24 + .../owners/VehicleOwnerMigration.java | 474 +++++++++++++ .../vehicles/owners/VehicleOwnerWrites.java | 97 +++ .../vehicles/owners/VehicleOwnershipGate.java | 265 ++++++++ .../owners/VehicleOwnershipServices.java | 46 ++ .../vehicles/owners/VehicleQuarantine.java | 90 +++ .../owners/VehicleUpkeepReconcile.java | 100 +++ .../vehicles/owners/VfOwnerApi.java | 59 ++ .../pool/FactionVehiclePoolService.java | 16 +- .../registry/PlayerVehicleRecord.java | 22 + .../vehicles/registry/RegistryAccounts.java | 51 ++ .../registry/VehicleOwnershipQueries.java | 193 +----- .../registry/VehiclePayerMigration.java | 19 +- .../VehicleRegistryClaimListener.java | 22 +- .../registry/VehicleRegistryClaimService.java | 9 +- .../registry/VehicleRegistryPersistence.java | 24 +- .../freeze/VfBuildersConstructionFreeze.java | 18 +- .../PluginLifecycleCoverageTest.java | 318 ++++++++- .../InstallationProtectionCoverageTest.java | 9 +- .../InstallationLifecycleCoverageTest.java | 41 +- .../RequestLifecycleCoverageTest.java | 5 +- .../managers/RequestManagerDeclineTest.java | 4 +- .../InstallationMenusCoverageTest.java | 10 +- .../VehicleTransferConsentRequestTest.java | 5 +- .../testsupport/VehicleOwnersReset.java | 18 + .../berth/FactionVehicleGiveServiceTest.java | 48 +- .../FactionVehicleReleaseServiceTest.java | 89 ++- .../InstallationVehicleOwnerSyncTest.java | 193 +++--- .../berth/InstallationVehicleServiceTest.java | 31 +- ...InstallationVehicleUnberthServiceTest.java | 25 +- .../VehicleInteractionsCoverageTest.java | 631 ++++++++---------- .../vehicles/berth/VehicleSlotGuardTest.java | 188 ++++-- ...ehicleTransactionBoundaryCoverageTest.java | 37 +- .../VehicleTransferConsentServiceTest.java | 44 +- .../vehicles/fees/VehiclePaymentsTest.java | 7 +- .../handover/VehicleHandoverServiceTest.java | 41 +- .../VehicleRuntimeLifecycleCoverageTest.java | 84 +-- .../maintenance/VehicleSettlementTest.java | 11 +- .../VehicleUpkeepProjectionTest.java | 12 +- .../maintenance/VehicleUpkeepServiceTest.java | 11 +- .../owners/ConstructionStampTest.java | 197 ++++++ .../vehicles/owners/FakeOwnerApi.java | 87 +++ .../vehicles/owners/LeaderStampsTest.java | 251 +++++++ .../vehicles/owners/OwnerTagsTest.java | 77 +++ .../vehicles/owners/VehicleClaimsOffTest.java | 36 + .../owners/VehicleOwnerMigrationTest.java | 534 +++++++++++++++ .../owners/VehicleOwnerWritesTest.java | 131 ++++ .../owners/VehicleOwnershipGateTest.java | 488 ++++++++++++++ .../owners/VehicleOwnershipServicesTest.java | 63 ++ .../owners/VehicleQuarantineTest.java | 60 ++ .../owners/VehicleUpkeepReconcileTest.java | 139 ++++ .../pool/FactionVehiclePoolServiceTest.java | 54 +- .../vehicles/registry/FakeOwnedInventory.java | 34 - .../registry/RegistryAccountsTest.java | 85 +++ .../registry/VehicleOwnerKeysTest.java | 92 +-- .../registry/VehicleOwnershipQueriesTest.java | 98 ++- .../registry/VehiclePayerMigrationTest.java | 17 + .../VehicleRegistryClaimServiceTest.java | 15 +- .../VfBuildersConstructionFreezeTest.java | 18 + .../org.junit.jupiter.api.extension.Extension | 1 + 95 files changed, 5686 insertions(+), 1562 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/events/FactionLeaderChangedEvent.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStamp.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStamps.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerFields.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerMeta.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerTags.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleClaimsOff.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerApi.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWrites.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServices.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantine.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcile.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VfOwnerApi.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccounts.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/testsupport/VehicleOwnersReset.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStampTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/FakeOwnerApi.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStampsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/OwnerTagsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleClaimsOffTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigrationTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWritesTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServicesTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantineTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcileTest.java delete mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/registry/FakeOwnedInventory.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccountsTest.java 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/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..24e8916a 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; @@ -33,13 +32,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 +61,6 @@ public UUID getOwnerUuid() { return ownerUuid; } - public String getOwnerName() { - return ownerName; - } - public String getOwnerKey() { return ownerKey; } 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/VehiclePayments.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java index 75cb25ec..836df7d4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java @@ -74,10 +74,18 @@ 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; } } @@ -94,6 +102,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", System.currentTimeMillis()); 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..9cffd1fb 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,16 @@ 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.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; @@ -73,7 +78,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 +96,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 +122,43 @@ 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); + log("Could not stamp constructed vehicle " + vehicle + " (" + stamped + + "); it stays unowned and build receipt " + scope + " stays open for staff"); + return; + } + if (!agree) { + quarantine(vehicle, "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 reason) { + var quarantine = VehicleQuarantine.current(); + try { + if (quarantine != null) quarantine.add(new VehicleQuarantine.Entry(vehicle, OwnerTags.NO_CHARACTER, 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..3106853e 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,26 @@ 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) != 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..da2c0d01 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStamps.java @@ -0,0 +1,137 @@ +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) { + 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); + } + + /** 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..9d441ed8 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java @@ -0,0 +1,474 @@ +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.size() == 2 && !copies.get(0).sameOwnership(copies.get(1))) { + 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: DenarEconomy's remembered names, Bukkit's name cache and TLibs characters. */ + public static final class LiveIdentity implements Identity { + @Override + public UUID roundTrip(String name) { + UUID account = VehicleOwnershipQueries.resolvePlayerUuid(name); + if (account == null) return null; + String current = Bukkit.getOfflinePlayer(account).getName(); + return name.equalsIgnoreCase(current) ? account : null; + } + + @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/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..12db7a5f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java @@ -0,0 +1,265 @@ +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.events.FactionLeaderChangedEvent; +import net.tfminecraft.simplefactions.government.proposal.FeeKind; +import net.tfminecraft.simplefactions.identity.Members; +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."; + 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 (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); + } + + private void refund(VehiclePayments payments, String scope, VehicleOwnershipChangedEvent event) { + var refund = payments == 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; + } + event.getPlayer().ifPresent(player -> player.sendMessage( + "§aThe claim did not go through, so the transfer fee was refunded.")); + } + + 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..7d0c5968 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantine.java @@ -0,0 +1,90 @@ +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)) { + String current = VehicleOwnerWrites.currentOwner(entry.vehicle()); + if (current != null && Objects.equals(current, entry.owner())) out.add(entry); + } + out.addAll(findings); + return out; + } + + 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/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..207c89db 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,12 @@ 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); 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 +233,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 +354,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 +399,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 +407,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 +497,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 +527,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 +556,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 +581,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 +606,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 +673,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 +696,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 +714,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 +764,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 +773,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 +798,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 +823,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 +872,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 +911,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 +943,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 +992,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 +1019,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 +1078,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 +1102,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 +1118,6 @@ void missingGiftInputsAndUnrelatedRequestsDoNotIssueOrAcceptOwnershipChanges() { "no_vehicle", "blank_vehicle", "no_recipient", - "blank_recipient", "not_registered", "different_installation" }) @@ -1127,18 +1127,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 +1153,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 +1221,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 +1238,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 +1254,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 +1269,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 +1285,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 +1428,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 +1440,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 +1464,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 +1472,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 +1529,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 +1610,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 +1668,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 +1697,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 +1738,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 +1858,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 +1892,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 +1925,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 +1941,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 +1964,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 +2041,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 +2169,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 +2188,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 +2208,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 +2231,7 @@ void publicVehicleQueriesReportFactionUpkeepAndTheAbsenceOfMilitaryOrPluginState } finally { SimpleFactions.plugin = previous; } - assertEquals("player_Leader", ownerData.getOwner()); + assertEquals(tag("Leader"), ownerData.getOwner()); } @Test @@ -2434,7 +2241,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 +2275,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 +2313,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 +2400,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 +2417,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 +2432,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..57c56576 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStampTest.java @@ -0,0 +1,197 @@ +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")); + } + + @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)); + } +} 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..3c861349 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStampsTest.java @@ -0,0 +1,251 @@ +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)); + } + } +} 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..48b27fd0 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigrationTest.java @@ -0,0 +1,534 @@ +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 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/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..7176945e --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java @@ -0,0 +1,488 @@ +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.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(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"); + } + + @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")); + } +} 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..1c663787 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantineTest.java @@ -0,0 +1,60 @@ +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())); + } +} 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..e4cdafec 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,76 +60,46 @@ void close() { bukkit.close(); } + private final VehicleOwnerMigration.LiveIdentity identity = new VehicleOwnerMigration.LiveIdentity(); + @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 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 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")); - } - - @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()); - } - 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); 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 From d02b43d3450027a624c11463b5b9bf73b81135b2 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:20:26 +0000 Subject: [PATCH 3/8] feat: vehicle owner rollback tool and post-migration refund list Co-Authored-By: Claude Opus 5.5 --- .../vehicles/owners/VehicleOwnerRollback.java | 205 ++++++++++++++++ .../owners/VehicleOwnerRollbackTest.java | 229 ++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java 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..cae1dafd --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java @@ -0,0 +1,205 @@ +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]"); + } + run(Path.of(args[0]), args.length == 2, System.out); + } + + 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())) { + report = restore(connection, plan, apply, refunds(factions.resolve(PAYMENTS), plan.completedAt)); + } 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) { + if (entry.status != Status.DONE || 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/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..13a115ce --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java @@ -0,0 +1,229 @@ +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", "DONE", "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")); + Files.writeString(factions.resolve(VehicleOwnerMigration.PLAN), "{\"completed\":true,\"completedAt\":" + 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)); + } +} From 2d8fa32bcf2b1dd4b9e9ba57a850e3f73f2cef79 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:36:03 +0000 Subject: [PATCH 4/8] fix: hold unresolved vehicles for staff and roll back interrupted plans Builds wait for the finished vehicle stage, a construction whose owner cannot be written is held so nobody claims it, and vehicles held for staff are never restamped or changed by players. Rollback also restores pending entries of an interrupted plan and counts fees from when the plan was made. Handover fees carry the offer time in their replayable intent. Co-Authored-By: Claude Opus 5.5 --- .../request/VehicleHandoverRequest.java | 3 +++ .../vehicles/fees/VehiclePayments.java | 7 +++++- .../fees/VehicleRegistrationFeeListener.java | 16 +++++++++----- .../handover/VehicleHandoverService.java | 3 ++- .../vehicles/owners/LeaderStamps.java | 5 ++++- .../vehicles/owners/VehicleOwnerRollback.java | 7 ++++-- .../vehicles/owners/VehicleOwnershipGate.java | 5 +++++ .../vehicles/owners/VehicleQuarantine.java | 22 +++++++++++++++++-- .../VehicleInteractionsCoverageTest.java | 2 ++ .../owners/ConstructionStampTest.java | 19 ++++++++++++++++ .../vehicles/owners/LeaderStampsTest.java | 18 +++++++++++++++ .../owners/VehicleOwnerRollbackTest.java | 5 +++-- .../owners/VehicleOwnershipGateTest.java | 17 ++++++++++++++ .../owners/VehicleQuarantineTest.java | 18 +++++++++++++++ 14 files changed, 133 insertions(+), 14 deletions(-) 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 24e8916a..41d1c333 100644 --- a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java +++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java @@ -25,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( @@ -72,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/vehicles/fees/VehiclePayments.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java index 836df7d4..cf0ce6b4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehiclePayments.java @@ -91,6 +91,11 @@ public boolean replaceRow(String vehicle, net.tfminecraft.simplefactions.vehicle } 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; @@ -103,7 +108,7 @@ public Result charge(String scope, String operationId, CharacterRef payer, Vehic 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", System.currentTimeMillis()); + 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/VehicleRegistrationFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java index 9cffd1fb..cc528078 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java @@ -17,6 +17,7 @@ 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; @@ -34,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; @@ -54,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; @@ -134,21 +138,23 @@ public void onVehicleConstruct(VehicleConstructEvent event) { Result stamped = VehicleOwnerWrites.write(vehicle, stamp, Reason.CONSTRUCT); if (stamped != Result.OK) { payments.replaceRow(vehicle, null); - log("Could not stamp constructed vehicle " + vehicle + " (" + stamped - + "); it stays unowned and build receipt " + scope + " stays open for staff"); + // 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, "registration fee payer " + payer + " is not the constructor " + constructor); + 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 reason) { + private static void quarantine(String vehicle, String owner, String reason) { var quarantine = VehicleQuarantine.current(); try { - if (quarantine != null) quarantine.add(new VehicleQuarantine.Entry(vehicle, OwnerTags.NO_CHARACTER, reason)); + if (quarantine != null) quarantine.add(new VehicleQuarantine.Entry(vehicle, owner, reason)); } catch (java.io.IOException error) { log("Could not list quarantined vehicle " + vehicle + ": " + error); } 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 3106853e..952ae4bb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java @@ -154,7 +154,8 @@ public void acceptRequest(Player recipient) { tellBoth(req, recipient, VehicleHandoverMessages.feeChanged()); return; } - if (quote != null && payments.charge(scope, req.getOperationId(), req.getOwnerCharacter(), quote) != net.tfminecraft.denareconomy.api.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; } // The recipient becomes the payer before VehicleFramework's owner changes, and stops being it if that fails. diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStamps.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStamps.java index da2c0d01..d1907ddb 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStamps.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStamps.java @@ -61,6 +61,8 @@ public static Result restamp(PlayerVehicleRecord 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; @@ -110,7 +112,8 @@ public static void reconcile(PlayerVehicleRegistry registry) throws IOException /** 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); + return isFactionRow(record) && !expected(record).equals(currentOwner) + && !VehicleQuarantine.held(record.getVehicleUuid()); } /** Restamps on the next tick, once per vehicle however often it is clicked. */ diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java index cae1dafd..ea1c22d5 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java @@ -65,7 +65,8 @@ public static Report run(Path server, boolean apply, PrintStream out) throws IOE Report report; try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + server.resolve("plugins/VehicleFramework/data/vehicles.db").toAbsolutePath())) { - report = restore(connection, plan, apply, refunds(factions.resolve(PAYMENTS), plan.completedAt)); + // 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); } @@ -92,7 +93,9 @@ static Report restore(Connection connection, Plan plan, boolean apply, List findings() { public List list() { List out = new ArrayList<>(); for (Entry entry : concat(migration, runtime)) { - String current = VehicleOwnerWrites.currentOwner(entry.vehicle()); - if (current != null && Objects.equals(current, entry.owner())) out.add(entry); + 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); 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 207c89db..989eb6d2 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/berth/VehicleInteractionsCoverageTest.java @@ -223,6 +223,8 @@ void setup() throws Exception { 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(tag("Owner")); vehicle = mock(ActiveVehicle.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStampTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStampTest.java index 57c56576..f04dd79e 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStampTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/ConstructionStampTest.java @@ -158,6 +158,9 @@ void aFailedStampTakesThePayerRowBackAndKeepsTheBuildReceiptOpen() { 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 @@ -194,4 +197,20 @@ void anOlderVFBuildersWithoutCharactersIsDetected() { 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/LeaderStampsTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStampsTest.java index 3c861349..28a3c8ed 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStampsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/LeaderStampsTest.java @@ -248,4 +248,22 @@ void factionLeaderChangesAreAnnouncedOnlyWhenTheLeaderReallyChanges() { 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/VehicleOwnerRollbackTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java index 13a115ce..88e6d366 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java @@ -95,14 +95,15 @@ private String entry(String vehicle, String status, String originalOwner, String private void writePlan() throws IOException { String quoted = "\"" + bran + "\""; String entries = String.join(",", - entry("restore", "DONE", "player_Alice", kira, quoted, true), + 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")); - Files.writeString(factions.resolve(VehicleOwnerMigration.PLAN), "{\"completed\":true,\"completedAt\":" + migratedAt + // 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\":[]}"); } diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java index 7176945e..62c328a9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java @@ -485,4 +485,21 @@ void aLeaderChangeRestampsThatFactionsVehicles() { } 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/VehicleQuarantineTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantineTest.java index 1c663787..95009905 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantineTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantineTest.java @@ -57,4 +57,22 @@ void theRunningListIsSharedAndAnUnreadableFileFailsTheLoad() throws IOException 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()); + } } From 2a3bf53f812155236c1e6ff66b4458da4c05993f Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:25:09 +0000 Subject: [PATCH 5/8] fix: durable owner-name round trip and rollback library check A legacy owner name now also round-trips through the name DenarEconomy remembered last for the account when the server's user cache has forgotten it, so expiring cache entries no longer quarantine personal vehicles; a name the account has since replaced still fails. The rollback tool names a too-old Gson (needs 2.10) or a missing sqlite-jdbc before it starts. Co-Authored-By: Claude Opus 5.5 --- .../owners/VehicleOwnerMigration.java | 48 ++++++++++++++++++- .../vehicles/owners/VehicleOwnerRollback.java | 22 +++++++++ .../owners/VehicleOwnerRollbackTest.java | 8 ++++ .../registry/VehicleOwnerKeysTest.java | 28 +++++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java index 9d441ed8..cb8e0a1a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java @@ -407,16 +407,62 @@ private void verify(Path file, Plan plan) throws IOException { // Production lookups. - /** Live providers: DenarEconomy's remembered names, Bukkit's name cache and TLibs characters. */ + /** + * 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)); diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java index ea1c22d5..c0e249cc 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java @@ -55,9 +55,31 @@ 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); diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java index 88e6d366..82df038a 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollbackTest.java @@ -227,4 +227,12 @@ void aDatabaseWithoutTheVehicleTableStopsWithNothingChanged() throws Exception { 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/registry/VehicleOwnerKeysTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java index e4cdafec..e0de403c 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/registry/VehicleOwnerKeysTest.java @@ -104,4 +104,32 @@ 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 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); + + 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"); + } } From fe04bfdaae8e43197961b4cab67536d9179fd571 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:55:05 +0000 Subject: [PATCH 6/8] fix: tolerate malformed old death journals in the lock-guild backfill A pre-6a journal whose rights snapshot has a missing or non-array guild list, a non-object guild, missing members or a blank or invalid incarnation now gives that character no lock-guild entry instead of failing the index rebuild. Co-Authored-By: Claude Opus 5.5 --- .../identity/death/LockGuildIndex.java | 20 +++++++-- .../identity/death/LockGuildIndexTest.java | 41 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) 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/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); + } + } } From 4e689fcbc9f0dbc9f15c18b677fa02bf004bbe6e Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:55:05 +0000 Subject: [PATCH 7/8] fix: report a vehicle with no readable owner copy as torn Verification listed a DONE vehicle with neither a stored nor a live copy by indexing an empty list; it now joins the torn-vehicle failure. Co-Authored-By: Claude Opus 5.5 --- .../vehicles/owners/VehicleOwnerMigration.java | 3 ++- .../owners/VehicleOwnerMigrationTest.java | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java index cb8e0a1a..078384ef 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigration.java @@ -391,7 +391,8 @@ private void verify(Path file, Plan plan) throws IOException { } copies.add(stored); } - if (copies.size() == 2 && !copies.get(0).sameOwnership(copies.get(1))) { + 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. diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigrationTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigrationTest.java index 48b27fd0..260ec049 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigrationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerMigrationTest.java @@ -463,6 +463,24 @@ public Result write(String vehicle, String owner, List whitelist, Reason 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"); From b4fb94a44818a757bb1db7b8488da4dfe5e2e63b Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:55:05 +0000 Subject: [PATCH 8/8] fix: tell players when a transfer fee refund falls short The refund is capped at the guild bank's balance. A short refund is now logged with the shortfall and the player told what came back and what did not, as the registration-fee cancel does. Co-Authored-By: Claude Opus 5.5 --- .../vehicles/fees/VehicleFeeMessages.java | 12 +++++ .../vehicles/owners/VehicleOwnershipGate.java | 22 ++++++++-- .../owners/VehicleOwnershipGateTest.java | 44 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) 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/owners/VehicleOwnershipGate.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java index 5e016e4b..cf547e99 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java @@ -16,9 +16,11 @@ 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; @@ -186,15 +188,29 @@ private void forgetOwner(VehicleOwnershipChangedEvent event, ActiveVehicle vehic 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 refund = payments == null ? null : payments.refund(scope); + 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; } - event.getPlayer().ifPresent(player -> player.sendMessage( - "§aThe claim did not go through, so the transfer fee was refunded.")); + 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) { diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java index 62c328a9..71920ab9 100644 --- a/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGateTest.java @@ -302,6 +302,7 @@ void aFailedClaimRefundsItsFeeOnceAndAFailedRefundIsReported() { 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)); @@ -309,6 +310,7 @@ void aFailedClaimRefundsItsFeeOnceAndAFailedRefundIsReported() { 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()); @@ -328,6 +330,48 @@ void aFailedClaimRefundsItsFeeOnceAndAFailedRefundIsReported() { 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