heir(UUID account) {
+ return Characters.legacyHeir(account);
+ }
+
+ @Override
+ public String nameOf(UUID character) {
+ return Members.info(Members.key(character)).map(CharacterInfo::name).map(VehicleOwnerMigration::plain)
+ .orElse(null);
+ }
+ }
+
+ static String plain(String name) {
+ String stripped = org.bukkit.ChatColor.stripColor(name);
+ return stripped == null || stripped.isBlank() ? null : stripped.strip();
+ }
+
+ /** {@code VACUUM INTO} and {@code quick_check} through SQLite JDBC, beside VehicleFramework's own connection. */
+ public static final class SqliteBackups implements Backups {
+ private final Path database;
+
+ public SqliteBackups(Path database) {
+ this.database = database;
+ }
+
+ @Override
+ public void vacuumInto(Path destination) throws IOException {
+ try (Connection connection = connect(database); Statement statement = connection.createStatement()) {
+ statement.execute("PRAGMA busy_timeout=5000");
+ statement.execute("VACUUM INTO '" + destination.toAbsolutePath().toString().replace("'", "''") + "'");
+ } catch (SQLException error) {
+ throw new IOException("Could not copy " + database, error);
+ }
+ }
+
+ @Override
+ public boolean quickCheck(Path copy) throws IOException {
+ try (Connection connection = connect(copy); Statement statement = connection.createStatement();
+ ResultSet result = statement.executeQuery("PRAGMA quick_check")) {
+ return result.next() && "ok".equalsIgnoreCase(result.getString(1)) && !result.next();
+ } catch (SQLException error) {
+ throw new IOException("Could not check " + copy, error);
+ }
+ }
+
+ private static Connection connect(Path file) throws SQLException {
+ return DriverManager.getConnection("jdbc:sqlite:" + file.toAbsolutePath());
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java
new file mode 100644
index 00000000..c0e249cc
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerRollback.java
@@ -0,0 +1,230 @@
+package net.tfminecraft.simplefactions.vehicles.owners;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import net.tfminecraft.simplefactions.database.DurableJson;
+import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration.Entry;
+import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration.Plan;
+import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerMigration.Status;
+
+/**
+ * Stage 6a rollback for vehicles, run from the server folder with the server stopped:
+ * java -cp <SimpleFactions jar>:<gson jar>:<sqlite-jdbc jar>
+ * net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerRollback <server folder> [--apply]
+ * It restores only the owner fields (the SQLite column and the payload's {@code owner},
+ * {@code whitelisted} and {@code whitelist}) of rows that still hold exactly what the
+ * migration wrote; anything changed since is listed and left alone. It also lists the
+ * claim, build and handover fees paid after the migration, for refunds. Without
+ * {@code --apply} nothing is written.
+ */
+public final class VehicleOwnerRollback {
+ static final String PAYMENTS = "vehicle-payments";
+ private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create();
+ private static final Gson PRETTY = new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create();
+
+ /** A fee paid after the migration. */
+ public record Refund(String receipt, String scope, String payerCharacter, String payerAccount, String factionId,
+ long cents, long at) {}
+
+ public record Report(List restored, List changed, List gone, List refunds) {}
+
+ private VehicleOwnerRollback() {}
+
+ public static void main(String[] args) throws IOException {
+ if (args.length < 1 || args.length > 2 || (args.length == 2 && !args[1].equals("--apply"))) {
+ throw new IllegalArgumentException("Usage: VehicleOwnerRollback [--apply]");
+ }
+ String missing = missingLibraries(JsonArray.class, "org.sqlite.JDBC");
+ if (missing != null) throw new IllegalStateException(missing);
+ run(Path.of(args[0]), args.length == 2, System.out);
+ }
+
+ /**
+ * Why the classpath cannot run the tool, or null. Gson must be 2.10 or newer (records,
+ * {@code JsonParser.parseString}); a server's {@code libraries/} may also hold older ones.
+ */
+ static String missingLibraries(Class> jsonArray, String sqliteDriver) {
+ try {
+ jsonArray.getMethod("asList");
+ } catch (NoSuchMethodException old) {
+ return "Gson 2.10 or newer is needed on the classpath (found an older one); use the newest"
+ + " libraries/com/google/code/gson/gson//gson-.jar";
+ }
+ try {
+ Class.forName(sqliteDriver);
+ } catch (ClassNotFoundException | LinkageError absent) {
+ return "sqlite-jdbc is needed on the classpath:"
+ + " libraries/org/xerial/sqlite-jdbc//sqlite-jdbc-.jar";
+ }
+ return null;
+ }
+
+ public static Report run(Path server, boolean apply, PrintStream out) throws IOException {
+ Path factions = server.resolve("plugins/SimpleFactions");
+ Path planFile = factions.resolve(VehicleOwnerMigration.PLAN);
+ Plan plan = DurableJson.read(planFile, Plan.class);
+ Report report;
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite:"
+ + server.resolve("plugins/VehicleFramework/data/vehicles.db").toAbsolutePath())) {
+ // Fees count from when the plan was made, so an unfinished migration lists them too.
+ report = restore(connection, plan, apply, refunds(factions.resolve(PAYMENTS), plan.createdAt));
+ } catch (SQLException error) {
+ throw new IOException("Could not roll back vehicle owners", error);
+ }
+ Path refundFile = factions.resolve("vehicles/refunds-" + System.currentTimeMillis() + ".json");
+ if (apply) {
+ DurableJson.write(refundFile, report.refunds());
+ // A re-upgrade must plan afresh; the old plan is kept beside it for the record.
+ Files.move(planFile, planFile.resolveSibling(planFile.getFileName() + ".rolled-back-"
+ + System.currentTimeMillis()), StandardCopyOption.ATOMIC_MOVE);
+ }
+ out.println((apply ? "Restored " : "Would restore ") + report.restored().size() + " vehicle owners");
+ report.changed().forEach(vehicle -> out.println("Changed since the migration, left alone: " + vehicle));
+ report.gone().forEach(vehicle -> out.println("No longer stored: " + vehicle));
+ out.println(report.refunds().size() + " fees paid after the migration"
+ + (apply ? ", listed in " + refundFile : ":"));
+ if (!apply) report.refunds().forEach(refund -> out.println(PRETTY.toJson(refund)));
+ return report;
+ }
+
+ static Report restore(Connection connection, Plan plan, boolean apply, List refunds) throws SQLException {
+ List restored = new ArrayList<>();
+ List changed = new ArrayList<>();
+ List gone = new ArrayList<>();
+ connection.setAutoCommit(false);
+ try {
+ for (Entry entry : plan.entries) {
+ // Pending entries may have been written before a crash; the row check keeps the rest safe.
+ if (entry.status == Status.QUARANTINED || entry.planned == null
+ || entry.original.value().sameOwnership(entry.planned.value())) continue;
+ Row row = row(connection, entry.vehicle);
+ if (row == null) {
+ gone.add(entry.vehicle);
+ } else if (!row.holds(entry.planned)) {
+ changed.add(entry.vehicle);
+ } else {
+ write(connection, entry, row.payload());
+ restored.add(entry.vehicle);
+ }
+ }
+ if (apply) connection.commit();
+ else connection.rollback();
+ } catch (SQLException | RuntimeException error) {
+ connection.rollback();
+ throw error;
+ }
+ return new Report(restored, changed, gone, refunds);
+ }
+
+ /** A stored row's owner column and payload. */
+ private record Row(String column, JsonObject payload) {
+ /** Whether the column and payload still hold exactly the migration's values. */
+ boolean holds(VehicleOwnerMigration.Fields planned) {
+ List whitelist = new ArrayList<>();
+ if (payload.get("whitelist") instanceof JsonArray entries) {
+ for (JsonElement entry : entries) whitelist.add(entry.getAsString());
+ }
+ return planned.owner.equals(column)
+ && planned.owner.equals(text(payload, "owner"))
+ && planned.whitelist.equals(whitelist)
+ && planned.whitelisted == (payload.has("whitelisted") && payload.get("whitelisted").getAsBoolean());
+ }
+ }
+
+ private static Row row(Connection connection, String vehicle) throws SQLException {
+ try (PreparedStatement select = connection.prepareStatement(
+ "SELECT owner, payload_json FROM vehicles WHERE uuid = ? AND deleted = 0")) {
+ select.setString(1, vehicle);
+ try (ResultSet row = select.executeQuery()) {
+ if (!row.next()) return null;
+ return new Row(row.getString(1), JsonParser.parseString(row.getString(2)).getAsJsonObject());
+ }
+ }
+ }
+
+ private static void write(Connection connection, Entry entry, JsonObject payload) throws SQLException {
+ payload.addProperty("owner", entry.original.owner);
+ payload.addProperty("whitelisted", entry.original.whitelisted);
+ JsonArray whitelist = new JsonArray();
+ entry.original.whitelist.forEach(whitelist::add);
+ payload.add("whitelist", whitelist);
+ try (PreparedStatement update = connection.prepareStatement("UPDATE vehicles SET owner = ?, payload_json = ?,"
+ + " revision = revision + 1, updated_at = ? WHERE uuid = ? AND deleted = 0")) {
+ update.setString(1, entry.original.owner);
+ update.setString(2, GSON.toJson(payload));
+ update.setLong(3, System.currentTimeMillis());
+ update.setString(4, entry.vehicle);
+ update.executeUpdate();
+ }
+ }
+
+ /**
+ * Claim, build and handover fees whose charge completed at or after {@code since} and
+ * was not refunded. Older journal entries without a time use the file's time.
+ */
+ static List refunds(Path journal, long since) throws IOException {
+ List saved = new ArrayList<>();
+ List times = new ArrayList<>();
+ if (Files.isDirectory(journal)) {
+ try (Stream files = Files.list(journal)) {
+ for (Path file : files.filter(path -> path.toString().endsWith(".json")).sorted().toList()) {
+ saved.add(JsonParser.parseString(Files.readString(file)).getAsJsonObject());
+ times.add(Files.getLastModifiedTime(file).toMillis());
+ }
+ }
+ }
+ Set refunded = new HashSet<>();
+ for (JsonObject value : saved) {
+ JsonObject changes = value.getAsJsonObject("intent").getAsJsonObject("changes");
+ if (applied(value) && "refund".equals(text(changes, "action"))) {
+ refunded.add(changes.getAsJsonObject("receipt").get("id").getAsString());
+ }
+ }
+ List out = new ArrayList<>();
+ for (int i = 0; i < saved.size(); i++) {
+ JsonObject changes = saved.get(i).getAsJsonObject("intent").getAsJsonObject("changes");
+ String scope = text(changes, "scope");
+ if (!applied(saved.get(i)) || !"charge".equals(text(changes, "action")) || scope == null
+ || !(scope.startsWith("claim:") || scope.startsWith("build:") || scope.startsWith("handover:"))) {
+ continue;
+ }
+ long at = changes.has("at") ? changes.get("at").getAsLong() : times.get(i);
+ JsonObject receipt = changes.getAsJsonObject("receipt");
+ String id = receipt.get("id").getAsString();
+ if (at < since || refunded.contains(id)) continue;
+ JsonObject payer = receipt.getAsJsonObject("payer");
+ out.add(new Refund(id, scope, text(payer, "characterId"), text(payer, "ownerId"), text(receipt, "factionId"),
+ receipt.get("cents").getAsLong(), at));
+ }
+ return out;
+ }
+
+ private static boolean applied(JsonObject saved) {
+ return "APPLIED".equals(text(saved, "completed"));
+ }
+
+ private static String text(JsonObject object, String key) {
+ return object != null && object.has(key) && !object.get(key).isJsonNull() ? object.get(key).getAsString() : null;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWrites.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWrites.java
new file mode 100644
index 00000000..d4382c3f
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnerWrites.java
@@ -0,0 +1,97 @@
+package net.tfminecraft.simplefactions.vehicles.owners;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.logging.Level;
+
+import net.tfminecraft.simplefactions.SimpleFactions;
+import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Reason;
+import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result;
+
+/**
+ * Every owner value SimpleFactions writes goes through here, and so through
+ * VehicleFramework's {@code setOwnership}. Without a usable VehicleFramework
+ * every write answers {@link Result#UNAVAILABLE}, so callers fail closed.
+ */
+public final class VehicleOwnerWrites {
+ private static volatile VehicleOwnerApi api;
+
+ private VehicleOwnerWrites() {}
+
+ /** The VehicleFramework 2.13 API, or null for an older VehicleFramework (rule 11 probe). */
+ public static VehicleOwnerApi probe() {
+ return probe("net.tfminecraft.vehicleframework.api.VehicleFrameworkApi");
+ }
+
+ static VehicleOwnerApi probe(String apiClass) {
+ try {
+ Class> type = Class.forName(apiClass);
+ type.getMethod("ownershipIdentity");
+ type.getMethod("listOwnershipMeta");
+ type.getMethod("readOwnershipMeta", UUID.class);
+ type.getMethod("setOwnership", UUID.class,
+ Class.forName("net.tfminecraft.vehicleframework.api.OwnerChange"),
+ Class.forName("net.tfminecraft.vehicleframework.api.SystemReason"));
+ return new VfOwnerApi();
+ } catch (ReflectiveOperationException | LinkageError missing) {
+ return null;
+ }
+ }
+
+ public static void use(VehicleOwnerApi next) {
+ api = next;
+ }
+
+ public static VehicleOwnerApi api() {
+ return api;
+ }
+
+ /** Whether owner writes can succeed: the API is present and in characters mode. */
+ public static boolean ready() {
+ VehicleOwnerApi current = api;
+ if (current == null) return false;
+ try {
+ return current.characters();
+ } catch (RuntimeException | LinkageError error) {
+ return false;
+ }
+ }
+
+ public static Result write(String vehicle, String owner, Reason reason) {
+ return write(vehicle, owner, null, reason);
+ }
+
+ public static Result write(String vehicle, String owner, List whitelist, Reason reason) {
+ VehicleOwnerApi current = api;
+ if (current == null) return Result.UNAVAILABLE;
+ try {
+ return current.write(vehicle, owner, whitelist, reason);
+ } catch (RuntimeException | LinkageError error) {
+ log("Could not set the owner of vehicle " + vehicle + " to " + owner + " (" + reason + ")", error);
+ return Result.SAVE_FAILED;
+ }
+ }
+
+ /** The vehicle's ownership fields; empty when it is gone or cannot be read. */
+ public static Optional read(String vehicle) {
+ VehicleOwnerApi current = api;
+ if (current == null || vehicle == null) return Optional.empty();
+ try {
+ return current.read(vehicle);
+ } catch (RuntimeException | LinkageError error) {
+ log("Could not read the owner of vehicle " + vehicle, error);
+ return Optional.empty();
+ }
+ }
+
+ /** The current owner value, or null when the vehicle is gone or cannot be read. */
+ public static String currentOwner(String vehicle) {
+ return read(vehicle).map(OwnerMeta::currentOwner).orElse(null);
+ }
+
+ static void log(String message, Throwable error) {
+ SimpleFactions plugin = SimpleFactions.getInstance();
+ if (plugin != null) plugin.getLogger().log(Level.WARNING, message, error);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java
new file mode 100644
index 00000000..cf547e99
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipGate.java
@@ -0,0 +1,286 @@
+package net.tfminecraft.simplefactions.vehicles.owners;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import org.bukkit.entity.Player;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+
+import net.tfminecraft.denareconomy.api.Result;
+import net.tfminecraft.simplefactions.economy.Money;
+import net.tfminecraft.simplefactions.events.FactionLeaderChangedEvent;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.identity.Members;
+import net.tfminecraft.simplefactions.utils.Formatter;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeConfirmations;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeMessages;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore;
+import net.tfminecraft.simplefactions.vehicles.fees.VehiclePayments;
+import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord;
+import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry;
+import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimListener;
+import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimService;
+import net.tfminecraft.tlibs.character.CharacterRef;
+import net.tfminecraft.vehicleframework.api.OwnershipReason;
+import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangeEvent;
+import net.tfminecraft.vehicleframework.events.VehicleOwnershipChangedEvent;
+import net.tfminecraft.vehicleframework.events.VehicleOwnershipEditCheckEvent;
+import net.tfminecraft.vehicleframework.events.VehiclePreInteractEvent;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+
+/**
+ * SimpleFactions' side of VehicleFramework's player ownership changes. Vetoes come first
+ * (faction vehicles, slot limits, then the claim fee), and the payer registry changes only
+ * after VehicleFramework reports the write, once per operation. Bukkit only logs listener
+ * exceptions, so every veto handler refuses on its own errors.
+ */
+public final class VehicleOwnershipGate implements Listener {
+ public static final String PAYMENT_UNAVAILABLE = "§cVehicle payment needs a living character or staff recovery.";
+ public static final String FAILED = "§cVehicle ownership could not be checked, so nothing changed.";
+ public static final String HELD = "§cThis vehicle is held for staff; ask them to assign its owner.";
+ static final int REMEMBERED_OPERATIONS = 1024;
+
+ private final PlayerVehicleRegistry registry;
+ private final VehicleRegistryClaimService claims;
+ private final VehicleFeeStore store;
+ private final VehicleFeeConfirmations confirmations;
+ private final Runnable saver;
+ private final Logger logger;
+ /** Claim fees charged under an operation, refunded if VehicleFramework reports the write failed. */
+ private final Map charges = new HashMap<>();
+ private final Set recorded = new LinkedHashSet<>();
+
+ public VehicleOwnershipGate(PlayerVehicleRegistry registry, VehicleRegistryClaimService claims,
+ VehicleFeeStore store, VehicleFeeConfirmations confirmations, Runnable saver, Logger logger) {
+ this.registry = registry;
+ this.claims = claims;
+ this.store = store;
+ this.confirmations = confirmations;
+ this.saver = saver;
+ this.logger = logger;
+ }
+
+ /** Faction vehicles are changed only through SimpleFactions; claims need a payer and a free slot. */
+ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
+ public void onChange(VehicleOwnershipChangeEvent event) {
+ try {
+ if (event.isStaff()) return;
+ ActiveVehicle vehicle = event.getVehicle();
+ if (VehicleQuarantine.held(vehicle.getUUID())) {
+ refuse(event, event.getPlayer(), HELD);
+ return;
+ }
+ if (LeaderStamps.isFactionRow(row(vehicle))) {
+ refuse(event, event.getPlayer(), VehicleRegistryClaimListener.FACTION_VEHICLE);
+ return;
+ }
+ if (event.getReason() != OwnershipReason.CLAIM) return;
+ VehiclePayments payments = VehiclePayments.current();
+ if (event.getActor().isEmpty() || payments == null || !payments.ready()
+ || payments.blockedVehicle(vehicle.getUUID())) {
+ refuse(event, event.getPlayer(), PAYMENT_UNAVAILABLE);
+ return;
+ }
+ VehicleRegistryClaimListener.handleClaimResult(event, event.getPlayer(), vehicle.getId(),
+ claims.tryRegisterOnClaim(event.getPlayer(), vehicle));
+ } catch (RuntimeException | LinkageError error) {
+ fail(event, event.getPlayer(), error);
+ }
+ }
+
+ /**
+ * Claiming a vehicle someone else last owned is a transfer: the first click shows the fee,
+ * the second pays it under VehicleFramework's operation ID. Own vehicles and staff are free.
+ */
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onClaimFee(VehicleOwnershipChangeEvent event) {
+ try {
+ if (event.getReason() != OwnershipReason.CLAIM || event.isStaff()) return;
+ Player player = event.getPlayer();
+ ActiveVehicle vehicle = event.getVehicle();
+ CharacterRef actor = event.getActor().orElseThrow();
+ String lastOwner = store.getLastOwner(vehicle.getUUID());
+ if (lastOwner == null || lastOwner.equals(Members.key(actor))) return;
+ Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, lastOwner, vehicle.getId());
+ if (quote == null) return;
+ String scope = "claim:" + vehicle.getUUID();
+ long now = System.currentTimeMillis();
+ String target = scope + ":" + actor.characterId() + ":"
+ + quote.faction().getOrCreateMainGuild().getIncarnationId();
+ if (!confirmations.confirm(player.getUniqueId(), target, quote.amount(), now)) {
+ confirmations.ask(player.getUniqueId(), target, quote.amount(), now);
+ refuse(event, player, VehicleFeeMessages.claimConfirm(quote, Members.display(lastOwner)));
+ return;
+ }
+ Result paid = VehiclePayments.current()
+ .charge(scope, "sf:vehicle:claim:" + event.getOperationId(), actor, quote);
+ if (paid != Result.APPLIED) {
+ refuse(event, player, VehicleFeeMessages.claimUnaffordable(quote));
+ return;
+ }
+ charges.put(event.getOperationId(), scope);
+ player.sendMessage(VehicleFeeMessages.claimPaid(quote));
+ } catch (RuntimeException | LinkageError error) {
+ fail(event, event.getPlayer(), error);
+ }
+ }
+
+ /** Records a claim or takeover as the new owner's payer row, drops it on a clear, and refunds failed claims. */
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onChanged(VehicleOwnershipChangedEvent event) {
+ if (event.getReason().isSystem() || !remember(event.getOperationId())) return;
+ String scope = charges.remove(event.getOperationId());
+ try {
+ VehiclePayments payments = VehiclePayments.current();
+ if (!event.isSuccess()) {
+ if (scope != null) refund(payments, scope, event);
+ return;
+ }
+ ActiveVehicle vehicle = event.getVehicle().orElseThrow();
+ switch (event.getReason()) {
+ case CLAIM, TAKEOVER -> recordOwner(event, vehicle, scope, payments);
+ case CLEAR -> forgetOwner(event, vehicle, payments);
+ default -> {
+ // Whitelist and ticket changes do not touch the registry.
+ }
+ }
+ } catch (RuntimeException | LinkageError error) {
+ logger.log(Level.SEVERE, "Could not record ownership change " + event.getOperationId()
+ + "; the upkeep reconcile lists the vehicle", error);
+ }
+ }
+
+ private void recordOwner(VehicleOwnershipChangedEvent event, ActiveVehicle vehicle, String scope,
+ VehiclePayments payments) {
+ CharacterRef owner = event.getTarget().or(event::getActor).orElse(null);
+ if (owner == null || payments == null
+ || !payments.personal(vehicle.getUUID(), vehicle.getId(), owner.characterId(), owner.ownerId())) {
+ logger.warning("Vehicle " + vehicle.getUUID() + " changed owner but its payer row could not be"
+ + " written; the upkeep reconcile adds it");
+ return;
+ }
+ String member = Members.key(owner);
+ if (scope != null) {
+ payments.complete(scope, member);
+ } else {
+ store.setLastOwner(vehicle.getUUID(), member);
+ saver.run();
+ }
+ audit(event, vehicle);
+ }
+
+ private void forgetOwner(VehicleOwnershipChangedEvent event, ActiveVehicle vehicle, VehiclePayments payments) {
+ if (row(vehicle) != null && (payments == null || !payments.replaceRow(vehicle.getUUID(), null))) {
+ logger.warning("Vehicle " + vehicle.getUUID() + " was cleared but its payer row could not be removed");
+ }
+ // The last owner stays, so a friend claiming the released vehicle pays the transfer fee.
+ audit(event, vehicle);
+ }
+
+ /** The guild bank may hold less than the fee by now; a short refund is logged and the player told. */
+ private void refund(VehiclePayments payments, String scope, VehicleOwnershipChangedEvent event) {
+ var receipt = payments == null ? null : payments.receipt(scope);
+ var refund = receipt == null ? null : payments.refund(scope);
+ if (refund == null || refund.result() != Result.APPLIED) {
+ logger.severe("Claim fee " + scope + " was charged but the claim failed, and the refund did not go"
+ + " through; staff must recover it");
+ return;
+ }
+ double refunded = refund.amount();
+ double shortfall = Formatter.formatDouble(Money.denars(receipt.cents()) - refunded);
+ if (shortfall > 0.0) {
+ logger.warning("Transfer fee refund short by " + shortfall + " for " + receipt.payer().characterId()
+ + " from faction " + receipt.factionId());
+ }
+ event.getPlayer().ifPresent(player -> {
+ if (shortfall <= 0.0) {
+ player.sendMessage(VehicleFeeMessages.claimRefunded());
+ return;
+ }
+ if (refunded > 0.0) player.sendMessage(VehicleFeeMessages.claimRefundedPart(refunded));
+ player.sendMessage(VehicleFeeMessages.claimRefundShort(shortfall));
+ });
+ }
+
+ private void audit(VehicleOwnershipChangedEvent event, ActiveVehicle vehicle) {
+ if (!event.isStaff()) return;
+ logger.info("[vehicle audit] " + event.getPlayer().map(Player::getName).orElse("console") + " "
+ + event.getReason() + " " + vehicle.getUUID() + " (" + vehicle.getId() + "): "
+ + event.getPrevious().owner() + " -> " + event.getCurrent().owner());
+ }
+
+ /** Edit check: a faction vehicle's leader may open its menu but use no control, and a stale stamp hides it. */
+ @EventHandler
+ public void onEditCheck(VehicleOwnershipEditCheckEvent event) {
+ try {
+ if (event.isStaff()) return;
+ PlayerVehicleRecord record = row(event.getVehicle());
+ if (!LeaderStamps.isFactionRow(record)) return;
+ if (LeaderStamps.isStale(record, event.getVehicle().getOwnerData().getOwner())) {
+ event.deny();
+ LeaderStamps.scheduleRestamp(record);
+ } else if (!event.isMenu()) {
+ event.deny();
+ }
+ } catch (RuntimeException | LinkageError error) {
+ event.deny();
+ }
+ }
+
+ /** Backstop before every other vehicle click handler: a stale faction stamp is refused and fixed. */
+ @EventHandler(priority = EventPriority.LOWEST)
+ public void onPreInteract(VehiclePreInteractEvent event) {
+ try {
+ ActiveVehicle vehicle = event.getVehicle();
+ if (vehicle == null || vehicle.getOwnerData() == null) return;
+ PlayerVehicleRecord record = row(vehicle);
+ if (LeaderStamps.isStale(record, vehicle.getOwnerData().getOwner())) {
+ event.setCancelled(true);
+ LeaderStamps.scheduleRestamp(record);
+ }
+ } catch (RuntimeException | LinkageError error) {
+ event.setCancelled(true);
+ }
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onLeaderChanged(FactionLeaderChangedEvent event) {
+ LeaderStamps.onLeaderChanged(event.getFaction(), registry);
+ }
+
+ private PlayerVehicleRecord row(ActiveVehicle vehicle) {
+ return registry.getByVehicleUuid(vehicle.getUUID()).orElse(null);
+ }
+
+ private boolean remember(UUID operation) {
+ if (!recorded.add(operation)) return false;
+ if (recorded.size() > REMEMBERED_OPERATIONS) {
+ Iterator oldest = recorded.iterator();
+ oldest.next();
+ oldest.remove();
+ }
+ return true;
+ }
+
+ private static void refuse(Cancellable event, Player player, String message) {
+ event.setCancelled(true);
+ if (player != null && message != null) player.sendMessage(message);
+ }
+
+ private void fail(Cancellable event, Player player, Throwable error) {
+ event.setCancelled(true);
+ logger.log(Level.SEVERE, "Vehicle ownership check failed; the change was refused", error);
+ if (player != null) player.sendMessage(FAILED);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServices.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServices.java
new file mode 100644
index 00000000..520a0b6f
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleOwnershipServices.java
@@ -0,0 +1,46 @@
+package net.tfminecraft.simplefactions.vehicles.owners;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.BooleanSupplier;
+
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.ServicePriority;
+import org.bukkit.plugin.ServicesManager;
+
+import net.tfminecraft.vehicleframework.api.QuarantinedVehicle;
+import net.tfminecraft.vehicleframework.api.VehicleOwnershipAuthority;
+import net.tfminecraft.vehicleframework.api.VehicleQuarantineSource;
+
+/** The two VehicleFramework services SimpleFactions provides; only loaded once the API probe passed. */
+public final class VehicleOwnershipServices {
+ private VehicleOwnershipServices() {}
+
+ /** Lists held-back vehicles for {@code /vf admin quarantine}, even when the vehicle stage stopped early. */
+ public static void registerQuarantine(ServicesManager services, Plugin plugin, VehicleQuarantine quarantine) {
+ services.register(VehicleQuarantineSource.class, () -> quarantined(quarantine), plugin, ServicePriority.Normal);
+ }
+
+ /** Registered last in the vehicle stage; VehicleFramework refuses non-staff changes until then. */
+ public static void registerAuthority(ServicesManager services, Plugin plugin, BooleanSupplier ready) {
+ services.register(VehicleOwnershipAuthority.class, new VehicleOwnershipAuthority() {
+ @Override
+ public boolean ready() {
+ return ready.getAsBoolean();
+ }
+ }, plugin, ServicePriority.Normal);
+ }
+
+ static List quarantined(VehicleQuarantine quarantine) {
+ List out = new ArrayList<>();
+ for (VehicleQuarantine.Entry entry : quarantine.list()) {
+ try {
+ out.add(new QuarantinedVehicle(UUID.fromString(entry.vehicle()), entry.owner(), entry.reason()));
+ } catch (IllegalArgumentException notUuid) {
+ // VehicleFramework IDs are UUIDs; a stray registry key cannot be shown by ID.
+ }
+ }
+ return out;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantine.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantine.java
new file mode 100644
index 00000000..53d702a3
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleQuarantine.java
@@ -0,0 +1,108 @@
+package net.tfminecraft.simplefactions.vehicles.owners;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import net.tfminecraft.simplefactions.database.DurableJson;
+
+/**
+ * Vehicles held back for staff: the owner migration's quarantine, vehicles stamped
+ * {@code char_none} at construction because payer and constructor disagreed, and what
+ * the reconciles found. Shown by {@code /vf admin quarantine}.
+ */
+public final class VehicleQuarantine {
+ /** One listed vehicle; {@code owner} is its owner value when it was listed. */
+ public record Entry(String vehicle, String owner, String reason) {}
+
+ private static final class Stored {
+ List entries = new ArrayList<>();
+ }
+
+ private static volatile VehicleQuarantine active;
+
+ private final Path runtimeFile;
+ private List migration = List.of();
+ private List runtime = List.of();
+ private List findings = List.of();
+
+ public VehicleQuarantine(Path folder) {
+ this.runtimeFile = folder.resolve("vehicles/quarantine.json");
+ }
+
+ public static void use(VehicleQuarantine quarantine) {
+ active = quarantine;
+ }
+
+ /** The running server's list, or null before the vehicle stage. */
+ public static VehicleQuarantine current() {
+ return active;
+ }
+
+ public void load(List migrationEntries) throws IOException {
+ migration = List.copyOf(migrationEntries);
+ if (Files.exists(runtimeFile)) {
+ Stored stored = DurableJson.read(runtimeFile, Stored.class);
+ runtime = stored.entries == null ? List.of() : List.copyOf(stored.entries);
+ }
+ }
+
+ /** Lists a vehicle for staff and saves the list. */
+ public void add(Entry entry) throws IOException {
+ List next = new ArrayList<>(runtime);
+ next.add(entry);
+ Stored stored = new Stored();
+ stored.entries = next;
+ DurableJson.write(runtimeFile, stored);
+ runtime = List.copyOf(next);
+ }
+
+ public void setFindings(List next) {
+ findings = List.copyOf(next);
+ }
+
+ public List findings() {
+ return findings;
+ }
+
+ /**
+ * Held-back vehicles that still carry the owner they were listed with (staff have not
+ * since given them an owner), then the reconcile findings.
+ */
+ public List list() {
+ List out = new ArrayList<>();
+ for (Entry entry : concat(migration, runtime)) {
+ if (stillHeld(entry)) out.add(entry);
+ }
+ out.addAll(findings);
+ return out;
+ }
+
+ /**
+ * Whether the running list holds this vehicle back: SimpleFactions then leaves its owner
+ * to staff, and players cannot change it.
+ */
+ public static boolean held(String vehicle) {
+ VehicleQuarantine quarantine = active;
+ if (quarantine == null || vehicle == null) return false;
+ for (Entry entry : concat(quarantine.migration, quarantine.runtime)) {
+ if (vehicle.equals(entry.vehicle()) && stillHeld(entry)) return true;
+ }
+ return false;
+ }
+
+ /** Listed until staff give it an owner; one whose owner could not be read stays listed. */
+ private static boolean stillHeld(Entry entry) {
+ if (entry.owner() == null) return true;
+ return Objects.equals(VehicleOwnerWrites.currentOwner(entry.vehicle()), entry.owner());
+ }
+
+ private static List concat(List first, List second) {
+ List all = new ArrayList<>(first);
+ all.addAll(second);
+ return all;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcile.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcile.java
new file mode 100644
index 00000000..2ec089ee
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VehicleUpkeepReconcile.java
@@ -0,0 +1,100 @@
+package net.tfminecraft.simplefactions.vehicles.owners;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.logging.Logger;
+
+import net.tfminecraft.simplefactions.vehicles.berth.VehicleSlotGuard;
+import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode;
+import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord;
+import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry;
+
+/**
+ * Keeps VehicleFramework's owners and the payer registry in step, at boot and daily. A
+ * character-owned vehicle without a row gets a personal row, so its owner pays upkeep;
+ * everything else that disagrees is listed for staff and left alone.
+ */
+public final class VehicleUpkeepReconcile {
+ /** Writes a personal row; false when it could not be saved. */
+ public interface Rows {
+ boolean personal(String vehicle, String type, UUID character, UUID account);
+ }
+
+ private final VehicleOwnerApi api;
+ private final PlayerVehicleRegistry registry;
+ private final Rows rows;
+ private final Predicate isLeader;
+ private final Function> accountOf;
+ private final Logger logger;
+
+ public VehicleUpkeepReconcile(VehicleOwnerApi api, PlayerVehicleRegistry registry, Rows rows,
+ Predicate isLeader, Function> accountOf, Logger logger) {
+ this.api = api;
+ this.registry = registry;
+ this.rows = rows;
+ this.isLeader = isLeader;
+ this.accountOf = accountOf;
+ this.logger = logger;
+ }
+
+ /** Runs once; returns what staff should look at. Throws when VehicleFramework cannot be read. */
+ public List run() {
+ List findings = new ArrayList<>();
+ Set seen = new HashSet<>();
+ for (OwnerMeta meta : api.list()) {
+ seen.add(meta.vehicle());
+ String owner = meta.currentOwner();
+ PlayerVehicleRecord record = registry.getByVehicleUuid(meta.vehicle()).orElse(null);
+ UUID character = OwnerTags.characterId(owner).orElse(null);
+ if (record == null) {
+ if (character != null) unregistered(meta, owner, character, findings);
+ } else if (record.getMode() == OwnershipMode.PERSONAL) {
+ if (!OwnerTags.names(owner, record.getPlayerUuid())) {
+ findings.add(finding(meta.vehicle(), owner, "personal registry row names character "
+ + record.getPlayerUuid() + ", the vehicle names another owner"));
+ }
+ } else if (!LeaderStamps.expected(record).equals(owner)) {
+ findings.add(finding(meta.vehicle(), owner,
+ "faction vehicle should carry " + LeaderStamps.expected(record)));
+ }
+ }
+ for (PlayerVehicleRecord record : registry.getAll()) {
+ if (!seen.contains(record.getVehicleUuid())) {
+ findings.add(finding(record.getVehicleUuid(), null, "registry row has no vehicle"));
+ } else if (record.getMode() == OwnershipMode.PERSONAL && VehicleSlotGuard.accountOf(record) == null) {
+ findings.add(finding(record.getVehicleUuid(), null,
+ "personal row's account is unknown, so it counts against every claimer"));
+ }
+ }
+ return findings;
+ }
+
+ private void unregistered(OwnerMeta meta, String owner, UUID character, List findings) {
+ if (isLeader.test(character)) {
+ findings.add(finding(meta.vehicle(), owner, "owner is a faction leader but the vehicle has no registry row"));
+ return;
+ }
+ UUID account = accountOf.apply(character).orElse(null);
+ if (account == null) {
+ findings.add(finding(meta.vehicle(), owner, "owner character is unknown and the vehicle has no registry row"));
+ return;
+ }
+ if (rows.personal(meta.vehicle(), meta.typeId(), character, account)) {
+ logger.info("Vehicle " + meta.vehicle() + " (" + meta.typeId() + ") had no payer row; "
+ + "its owner character " + character + " now pays its upkeep");
+ } else {
+ findings.add(finding(meta.vehicle(), owner, "could not save a payer row for its owner"));
+ }
+ }
+
+ private VehicleQuarantine.Entry finding(String vehicle, String owner, String reason) {
+ logger.warning("Vehicle " + vehicle + ": " + reason);
+ return new VehicleQuarantine.Entry(vehicle, owner, "reconcile: " + reason);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VfOwnerApi.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VfOwnerApi.java
new file mode 100644
index 00000000..7b1874b4
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/owners/VfOwnerApi.java
@@ -0,0 +1,59 @@
+package net.tfminecraft.simplefactions.vehicles.owners;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import net.tfminecraft.vehicleframework.api.OwnerChange;
+import net.tfminecraft.vehicleframework.api.OwnershipIdentity;
+import net.tfminecraft.vehicleframework.api.OwnershipMeta;
+import net.tfminecraft.vehicleframework.api.OwnershipValues;
+import net.tfminecraft.vehicleframework.api.SystemReason;
+import net.tfminecraft.vehicleframework.api.VehicleFrameworkApi;
+
+/** The only class that calls VehicleFrameworkApi; loaded once the probe has found it. */
+final class VfOwnerApi implements VehicleOwnerApi {
+
+ @Override
+ public boolean characters() {
+ return VehicleFrameworkApi.ownershipIdentity() == OwnershipIdentity.CHARACTERS;
+ }
+
+ @Override
+ public List list() {
+ return VehicleFrameworkApi.listOwnershipMeta().stream().map(VfOwnerApi::meta).toList();
+ }
+
+ @Override
+ public Optional read(String vehicle) {
+ UUID id = uuid(vehicle);
+ return id == null ? Optional.empty() : VehicleFrameworkApi.readOwnershipMeta(id).map(VfOwnerApi::meta);
+ }
+
+ @Override
+ public Result write(String vehicle, String owner, List whitelist, Reason reason) {
+ UUID id = uuid(vehicle);
+ if (id == null) return Result.NOT_FOUND;
+ OwnerChange change = whitelist == null
+ ? new OwnerChange(owner, null) : OwnerChange.seeded(owner, whitelist);
+ return Result.valueOf(VehicleFrameworkApi.setOwnership(id, change, SystemReason.valueOf(reason.name())).name());
+ }
+
+ static OwnerMeta meta(OwnershipMeta meta) {
+ return new OwnerMeta(meta.vehicleId().toString(), meta.typeId(), meta.storedOwnerColumn(),
+ meta.stored().map(VfOwnerApi::fields), meta.live().map(VfOwnerApi::fields));
+ }
+
+ static OwnerFields fields(OwnershipValues values) {
+ return new OwnerFields(values.owner(), values.whitelist(), values.whitelisted(), values.ticketsEnabled(),
+ values.ticketId());
+ }
+
+ private static UUID uuid(String vehicle) {
+ try {
+ return vehicle == null ? null : UUID.fromString(vehicle);
+ } catch (IllegalArgumentException ignored) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolService.java
index 9c440620..04ed8acb 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolService.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/pool/FactionVehiclePoolService.java
@@ -14,7 +14,8 @@
import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode;
import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord;
import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry;
-import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries;
+import net.tfminecraft.simplefactions.vehicles.owners.OwnerTags;
+import net.tfminecraft.simplefactions.vehicles.owners.VehicleOwnerApi.Result;
import net.tfminecraft.vehicleframework.data.OwnerData;
import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
@@ -133,7 +134,7 @@ public CanAddResult canAdd(Faction faction, PoolTarget vehicle) {
OwnerData ownerData = vehicle.getOwnerData();
String owner = ownerData == null ? null : ownerData.getOwner();
- if (!VehicleOwnershipQueries.isPlayerOwner(owner)) {
+ if (!OwnerTags.isCharacter(owner)) {
return CanAddResult.NOT_OWNED;
}
@@ -181,7 +182,16 @@ public boolean tryRegister(Faction faction, PoolTarget vehicle, UUID originalOwn
registry.register(previous);
return false;
}
- ownerSync.applyLeaderOwner(vehicle.getOwnerData(), faction);
+ if (InstallationVehicleOwnerSync.stampForFaction(vehicle.getVehicleUuid(), faction) != Result.OK) {
+ // The registry went first, so put the personal row back before reporting the failure.
+ registry.unregister(vehicle.getVehicleUuid());
+ registry.register(previous);
+ if (plugin != null && !plugin.saveVehicleRegistry()) {
+ plugin.getLogger().severe("Vehicle " + vehicle.getVehicleUuid() + " was restored in memory after"
+ + " its pool stamp failed, but the registry could not be saved.");
+ }
+ return false;
+ }
return true;
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRecord.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRecord.java
index 9b097657..2ac9fd5f 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRecord.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/PlayerVehicleRecord.java
@@ -9,6 +9,8 @@ public final class PlayerVehicleRecord {
private final OwnershipMode mode;
private final String installationId;
private final String factionId;
+ /** The account that played {@code playerUuid} when the row was written; null for older rows. */
+ private final UUID accountUuid;
public PlayerVehicleRecord(
UUID playerUuid,
@@ -26,12 +28,24 @@ public PlayerVehicleRecord(
OwnershipMode mode,
String installationId,
String factionId) {
+ this(playerUuid, vehicleUuid, vehicleTypeId, mode, installationId, factionId, null);
+ }
+
+ public PlayerVehicleRecord(
+ UUID playerUuid,
+ String vehicleUuid,
+ String vehicleTypeId,
+ OwnershipMode mode,
+ String installationId,
+ String factionId,
+ UUID accountUuid) {
this.playerUuid = playerUuid;
this.vehicleUuid = vehicleUuid;
this.vehicleTypeId = vehicleTypeId;
this.mode = mode;
this.installationId = installationId;
this.factionId = factionId;
+ this.accountUuid = accountUuid;
}
public UUID getPlayerUuid() {
@@ -57,4 +71,12 @@ public String getInstallationId() {
public String getFactionId() {
return factionId;
}
+
+ public UUID getAccountUuid() {
+ return accountUuid;
+ }
+
+ public PlayerVehicleRecord withAccount(UUID account) {
+ return new PlayerVehicleRecord(playerUuid, vehicleUuid, vehicleTypeId, mode, installationId, factionId, account);
+ }
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccounts.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccounts.java
new file mode 100644
index 00000000..7c95d535
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/registry/RegistryAccounts.java
@@ -0,0 +1,51 @@
+package net.tfminecraft.simplefactions.vehicles.registry;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Function;
+
+/**
+ * Personal rows from before the account was cached get it filled in, so slot limits
+ * keep counting them per account: from the character's account, else from the account
+ * the owner migrations matched the vehicle's old owner name to. Rows nothing resolves
+ * keep no account and count against every claimer.
+ */
+public final class RegistryAccounts {
+ private RegistryAccounts() {}
+
+ /** Fills missing accounts and saves the registry when anything changed; returns how many were filled. */
+ public static int fill(Path folder, PlayerVehicleRegistry registry, Function> accountOf,
+ List