diff --git a/pom.xml b/pom.xml
index f97e70d9..cf4b160b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,7 +16,7 @@
2.0.1
2.0.0
2.0.1
- 2.0.4
+ 2.1.0
diff --git a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java
index f34a7028..f6b43c83 100644
--- a/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java
+++ b/src/main/java/net/tfminecraft/simplefactions/SimpleFactions.java
@@ -86,6 +86,12 @@
import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimListener;
import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryClaimService;
import net.tfminecraft.simplefactions.vehicles.registry.VehicleRegistryPersistence;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeConfirmations;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeStore;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleReclaimFeeListener;
+import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverListener;
+import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverService;
+import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverSessionManager;
import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenanceDecayTask;
import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePayListener;
import net.tfminecraft.simplefactions.vehicles.maintenance.VehicleMaintenancePayService;
@@ -219,6 +225,16 @@ public class SimpleFactions extends JavaPlugin{
new VehicleMaintenancePayListener(
vehicleMaintenancePaySessionManager,
vehicleMaintenancePayService);
+ private final VehicleFeeStore vehicleFeeStore = new VehicleFeeStore();
+ private final VehicleFeeConfirmations vehicleFeeConfirmations = new VehicleFeeConfirmations();
+ private final VehicleHandoverSessionManager vehicleHandoverSessionManager = new VehicleHandoverSessionManager();
+ private final VehicleHandoverService vehicleHandoverService =
+ new VehicleHandoverService(vehicleRegistry, vehicleFeeStore, this::saveVehicleFees);
+ private final VehicleHandoverListener vehicleHandoverListener = new VehicleHandoverListener(
+ vehicleHandoverSessionManager, vehicleHandoverService, vehicleFeeConfirmations);
+ private final VehicleReclaimFeeListener vehicleReclaimFeeListener =
+ new VehicleReclaimFeeListener(vehicleFeeStore, vehicleFeeConfirmations, this::saveVehicleFees);
+ private boolean registrationFeeRegistered;
private boolean vehicleIntegrationRegistered = false;
private boolean constructionFreezeRegistered;
private final PlayerEconomyManager playerEconomyManager = new PlayerEconomyManager();
@@ -249,6 +265,8 @@ public void onEnable() {
new File(getDataFolder(), "Cache"),
vehicleMaintenanceStore);
vehicleMaintenancePersistence.load();
+ vehicleFeeStore.bind(new File(getDataFolder(), "Cache"));
+ vehicleFeeStore.load();
registerVehicleIntegrationHooks();
if (Cache.mapEnabled && !getServer().getPluginManager().isPluginEnabled("TFMCWeb")) {
getLogger().severe(
@@ -528,6 +546,50 @@ public FactionVehicleGiveService getFactionVehicleGiveService() {
return factionVehicleGiveService;
}
+ public VehicleHandoverSessionManager getVehicleHandoverSessionManager() {
+ return vehicleHandoverSessionManager;
+ }
+
+ public VehicleHandoverService getVehicleHandoverService() {
+ return vehicleHandoverService;
+ }
+
+ public void saveVehicleFees() {
+ vehicleFeeStore.save();
+ }
+
+ /** Remembers who a vehicle was just handed to, for the transfer fee on a later release. */
+ public void recordVehicleOwner(String vehicleUuid, String playerName) {
+ vehicleFeeStore.setLastOwner(vehicleUuid, playerName);
+ vehicleFeeStore.save();
+ }
+
+ /**
+ * Records the current owner of every personal vehicle, so a vehicle released before
+ * anyone built or claimed it through SimpleFactions still has a last owner.
+ */
+ public void recordVehicleOwners() {
+ if (!getServer().getPluginManager().isPluginEnabled("VehicleFramework")) {
+ return;
+ }
+ try {
+ for (net.tfminecraft.vehicleframework.data.OwnedVehicleSummary vehicle
+ : net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries.allPersonalVehicles(vehicleRegistry)) {
+ String owner = net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries
+ .playerNameFromOwner(vehicle.getOwner());
+ if (owner != null) {
+ vehicleFeeStore.setLastOwner(vehicle.getUuid(), owner);
+ }
+ }
+ for (net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord record : vehicleRegistry.getAll()) {
+ vehicleFeeStore.forgetVehicle(record.getVehicleUuid());
+ }
+ vehicleFeeStore.save();
+ } catch (RuntimeException | LinkageError e) {
+ getLogger().warning("Could not record vehicle owners for transfer fees: " + e);
+ }
+ }
+
public InstallationVehicleUnberthService getInstallationVehicleUnberthService() {
return installationVehicleUnberthService;
}
@@ -549,6 +611,7 @@ public boolean saveVehicleRegistry() {
if (vehicleMaintenancePersistence != null) {
vehicleMaintenancePersistence.save();
}
+ vehicleFeeStore.save();
return saved;
}
@@ -584,11 +647,31 @@ public void onPluginEnable(PluginEnableEvent event) {
|| "VehicleFramework".equalsIgnoreCase(event.getPlugin().getName())) {
registerVehicleIntegration();
registerConstructionFreeze();
+ registerRegistrationFee();
}
}
}, this);
registerVehicleIntegration();
registerConstructionFreeze();
+ registerRegistrationFee();
+ }
+
+ private void registerRegistrationFee() {
+ if (registrationFeeRegistered || !getServer().getPluginManager().isPluginEnabled("VFBuilders")
+ || !getServer().getPluginManager().isPluginEnabled("VehicleFramework")) {
+ return;
+ }
+ registrationFeeRegistered = true;
+ try {
+ Class.forName("net.tfminecraft.vfbuilders.events.VehicleConstructionCancelEvent");
+ } catch (ClassNotFoundException | LinkageError e) {
+ // The listener needs the confirm click and cancel event from VFBuilders 2.1.0.
+ getLogger().warning("VFBuilders is older than 2.1.0, so vehicle registration fees are off; "
+ + "update it to charge them");
+ return;
+ }
+ getServer().getPluginManager().registerEvents(new net.tfminecraft.simplefactions.vehicles.fees
+ .VehicleRegistrationFeeListener(vehicleFeeStore, vehicleFeeConfirmations, this::saveVehicleFees), this);
}
private void registerConstructionFreeze() {
@@ -622,7 +705,11 @@ private void registerVehicleIntegration() {
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;
+ // VehicleFramework loads its vehicles as it enables, so wait before reading owners.
+ getServer().getScheduler().runTaskLater(this, this::recordVehicleOwners, 200L);
if (getServer().getPluginManager().isPluginEnabled("VFBuilders")) {
getLogger().info("[SimpleFactions] VFBuilders vehicle integration enabled");
} else {
diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java
index 9a2f251d..75b396d2 100644
--- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java
+++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java
@@ -164,6 +164,7 @@ public void loadFactions() {
data.laws,
data.governmentData
);
+ f.getVehicleFeeHandler().load(data.vehicleFees, data.vehicleTypeFees);
if (data.lawChangedAt != null) {
for (Map.Entry entry : data.lawChangedAt.entrySet()) {
LawGroup group = f.getLawHandler().getGroup(entry.getKey());
@@ -325,6 +326,12 @@ public void saveFaction(Faction f) {
data.dividendTax = f.getTaxHandler().getDividendTax();
data.tariffs = f.getTaxHandler().getTariffs();
data.specificTaxes = serializeSpecificTaxes(f.getTaxHandler());
+ data.vehicleFees = new HashMap<>(f.getVehicleFeeHandler().serializeRates());
+ data.vehicleTypeFees = new HashMap<>();
+ for (Map.Entry> entry
+ : f.getVehicleFeeHandler().serializeTypeRates().entrySet()) {
+ data.vehicleTypeFees.put(entry.getKey(), new HashMap<>(entry.getValue()));
+ }
data.capital = f.getCapital();
data.extraNodeCapacity = (double) f.getExtraNodeCapacity();
@@ -398,6 +405,7 @@ public void saveFaction(Faction f) {
gd.dividendPercent = g.getDividendPercent();
gd.dividendEligible = g.getDividendEligibleSnapshot();
gd.casinoProfit = g.getLedger().getCasinoProfit();
+ gd.vehicleFeeIncome = g.getLedger().getVehicleFeeIncome();
Map citizenTaxes = g.getLedger().getCitizenTaxesCopy();
gd.citizenTaxes = citizenTaxes.isEmpty() ? null : citizenTaxes;
gd.company = g.getCompany() != null ? g.getCompany().serialize() : null;
diff --git a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java
index d6c074e2..159fa0fb 100644
--- a/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java
+++ b/src/main/java/net/tfminecraft/simplefactions/database/FactionData.java
@@ -37,6 +37,12 @@ public class FactionData {
@SerializedName("specific taxes")
public HashMap> specificTaxes = new HashMap<>();
+ @SerializedName("vehicle fees")
+ public HashMap vehicleFees;
+
+ @SerializedName("vehicle type fees")
+ public HashMap> vehicleTypeFees;
+
public Integer capital;
@SerializedName("extra node capacity")
diff --git a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java
index fc554e68..1ebb6dfc 100644
--- a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java
+++ b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java
@@ -52,6 +52,7 @@ public class GuildData {
@SerializedName("casino profit")
public Double casinoProfit;
+ public Double vehicleFeeIncome;
@SerializedName("citizen taxes")
public Map citizenTaxes;
diff --git a/src/main/java/net/tfminecraft/simplefactions/database/ProposalData.java b/src/main/java/net/tfminecraft/simplefactions/database/ProposalData.java
index d1f1951d..2f3a6394 100644
--- a/src/main/java/net/tfminecraft/simplefactions/database/ProposalData.java
+++ b/src/main/java/net/tfminecraft/simplefactions/database/ProposalData.java
@@ -4,7 +4,7 @@
public class ProposalData {
public String proposer;
- public String type; // "law", "tax", or "political"
+ public String type; // "law", "tax", "fee", or "political"
// For law proposals
public String groupId;
@@ -20,6 +20,17 @@ public class ProposalData {
@SerializedName("new tax")
public Double newTax;
+ // For fee proposals
+ @SerializedName("fee kind")
+ public String feeKind;
+
+ /** Null for the general rate. */
+ @SerializedName("fee vehicle")
+ public String feeVehicle;
+
+ @SerializedName("new fee")
+ public Double newFee;
+
// For political action proposals
@SerializedName("action key")
public String actionKey;
diff --git a/src/main/java/net/tfminecraft/simplefactions/enums/Brackets.java b/src/main/java/net/tfminecraft/simplefactions/enums/Brackets.java
index b851e1c1..b71cac0b 100644
--- a/src/main/java/net/tfminecraft/simplefactions/enums/Brackets.java
+++ b/src/main/java/net/tfminecraft/simplefactions/enums/Brackets.java
@@ -6,7 +6,10 @@ public enum Brackets {
GUILD_TAX("Guild Tax"),
VASSAL_TAX("Vassal Tax"),
DIVIDEND_TAX("Dividend Tax"),
- TARIFFS("Tariffs");
+ TARIFFS("Tariffs"),
+ VEHICLE_TAX("Vehicle Tax (% of upkeep)"),
+ REGISTRATION_FEE("Registration Fee (x upkeep)"),
+ TRANSFER_FEE("Transfer Fee (x upkeep)");
private final String display;
diff --git a/src/main/java/net/tfminecraft/simplefactions/enums/Rules.java b/src/main/java/net/tfminecraft/simplefactions/enums/Rules.java
index c4d0da7d..7d1defb9 100644
--- a/src/main/java/net/tfminecraft/simplefactions/enums/Rules.java
+++ b/src/main/java/net/tfminecraft/simplefactions/enums/Rules.java
@@ -14,6 +14,9 @@ public enum Rules {
GUILD_TAX("Can Collect Guild Taxes", true), //Implemented
DIVIDEND_TAX("Can Collect Dividend Taxes", true), //Implemented
TARIFFS("Can Impose Tariffs", true), //Implemented
+ VEHICLE_TAX("Can Collect Vehicle Tax", true), //Implemented
+ REGISTRATION_FEE("Can Charge Vehicle Registration Fees", true), //Implemented
+ TRANSFER_FEE("Can Charge Vehicle Transfer Fees", true), //Implemented
VASSAL_VOTING_RIGHTS("Vassals Have Voting Rights", true), //Implemented
CAN_FAVOUR("Can Favour Guilds and Vassals", true),
CAN_REPRESS("Can Repress Guilds and Vassals", true),
diff --git a/src/main/java/net/tfminecraft/simplefactions/enums/SFGUI.java b/src/main/java/net/tfminecraft/simplefactions/enums/SFGUI.java
index f36643ab..1ab7a001 100644
--- a/src/main/java/net/tfminecraft/simplefactions/enums/SFGUI.java
+++ b/src/main/java/net/tfminecraft/simplefactions/enums/SFGUI.java
@@ -40,6 +40,9 @@ public enum SFGUI {
LAW_PROPOSAL_SELECT,
PROPOSALS,
SPECIFIC_TAX_PROPOSAL_VIEW,
+ FEE_PROPOSAL_VIEW,
+ FEE_CATEGORY_VIEW,
+ FEE_VEHICLE_VIEW,
LEDGER_VIEW,
COUNCIL_VIEW,
COUNCIL_SELECT,
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/Government.java b/src/main/java/net/tfminecraft/simplefactions/government/Government.java
index 12453097..c277300b 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/Government.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/Government.java
@@ -884,6 +884,11 @@ private ProposalData serializeProposal(Proposal p) {
data.taxTarget = tax.getTarget().name();
data.taxId = tax.getId();
data.newTax = tax.getNewTax();
+ } else if (p.isFeeProposal()) {
+ data.type = "fee";
+ data.feeKind = p.getFeeChange().getKind().name();
+ data.feeVehicle = p.getFeeChange().getVehicleTypeId();
+ data.newFee = p.getFeeChange().getNewRate();
} else if (p.isPoliticalActionProposal() && p.getPoliticalAction() != null) {
data.type = "political";
data.actionKey = p.getPoliticalAction().getAction().toString();
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java b/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java
index 9f26f280..9d0f5502 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java
@@ -10,6 +10,8 @@
import net.tfminecraft.simplefactions.loaders.LawLoader;
import net.tfminecraft.simplefactions.government.Government;
+import net.tfminecraft.simplefactions.government.proposal.FeeChange;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
import net.tfminecraft.simplefactions.government.proposal.Proposal;
import net.tfminecraft.simplefactions.government.proposal.TaxLawChange;
import net.tfminecraft.simplefactions.laws.Law;
@@ -30,6 +32,9 @@ public ProposalHandler(Government gov, boolean movement) {
}
private List proposals = new ArrayList<>();
+
+ // Stands in for the null vehicle type of a general fee in the saved form.
+ private static final String ALL_VEHICLES = "*";
public boolean canPropose(String member) {
if(movement) return true;
@@ -54,6 +59,10 @@ public boolean canBeProposed(Proposal proposal) {
LawGroup g = gov.getFaction().getLawHandler().getGroup(p.getLaw().getGroup());
if(g.getId().equalsIgnoreCase(group.getId())) return false;
}
+ } else if(proposal.isFeeProposal()) {
+ for(Proposal p : proposals) {
+ if(p.isFeeProposal() && p.getFeeChange().sameTarget(proposal.getFeeChange())) return false;
+ }
} else if(proposal.isTaxProposal()) {
TaxLawChange change = proposal.getTaxChange();
for(Proposal p : proposals) {
@@ -94,6 +103,10 @@ public List serializeProposals() {
} else if (p.isTaxProposal() && p.getTaxChange() != null) {
TaxLawChange tax = p.getTaxChange();
result.add(p.getProposer() + ":tax:" + tax.getTarget().name() + ":" + tax.getId() + ":" + tax.getNewTax());
+ } else if (p.isFeeProposal()) {
+ FeeChange fee = p.getFeeChange();
+ result.add(p.getProposer() + ":fee:" + fee.getKind().name() + ":"
+ + (fee.isGeneral() ? ALL_VEHICLES : fee.getVehicleTypeId()) + ":" + fee.getNewRate());
}
}
return result;
@@ -120,6 +133,23 @@ public void restoreProposals(net.tfminecraft.simplefactions.objects.Faction fact
}
}
}
+ } else if (s.startsWith("fee:")) {
+ // kind:type:rate, where the type may itself contain colons.
+ String body = s.substring(4);
+ int first = body.indexOf(':');
+ int last = body.lastIndexOf(':');
+ if (first > 0 && last > first) {
+ try {
+ FeeKind kind = FeeKind.valueOf(body.substring(0, first));
+ String typeField = body.substring(first + 1, last);
+ String type = ALL_VEHICLES.equals(typeField) ? null : typeField;
+ Proposal p = new Proposal(proposer, gov);
+ p.setFeeProposal(new FeeChange(kind, type, Double.parseDouble(body.substring(last + 1))));
+ proposals.add(p);
+ } catch (Exception e) {
+ // Skip malformed proposals
+ }
+ }
} else if (s.startsWith("tax:")) {
String[] parts = s.substring(4).split(":");
if (parts.length >= 3) {
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java
index ff4ca87f..13d4eed9 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/Movement.java
@@ -489,6 +489,12 @@ private Proposal deserializeProposal(Faction faction, ProposalData proposalData)
new net.tfminecraft.simplefactions.government.proposal.TaxLawChange(target, proposalData.taxId, proposalData.newTax);
p.setTaxProposal(tax);
return p;
+ } else if ("fee".equals(proposalData.type)) {
+ net.tfminecraft.simplefactions.government.proposal.FeeKind kind =
+ net.tfminecraft.simplefactions.government.proposal.FeeKind.valueOf(proposalData.feeKind);
+ p.setFeeProposal(new net.tfminecraft.simplefactions.government.proposal.FeeChange(
+ kind, proposalData.feeVehicle, proposalData.newFee == null ? 0.0 : proposalData.newFee));
+ return p;
} else if ("political".equals(proposalData.type)) {
Action action = Action.valueOf(proposalData.actionKey);
PoliticalAction politicalAction = new PoliticalAction(action);
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeService.java b/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeService.java
index ad11a4bd..9f417c8c 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeService.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/movement/MovementOutcomeService.java
@@ -111,7 +111,7 @@ private static boolean isLawOrTax(Cause cause) {
return true;
}
Proposal proposal = cause.getProposal();
- if (proposal != null && (proposal.isLawProposal() || proposal.isTaxProposal())) {
+ if (proposal != null && (proposal.isLawProposal() || proposal.isTaxProposal() || proposal.isFeeProposal())) {
return true;
}
Action fromProposal = actionOf(cause);
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeChange.java b/src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeChange.java
new file mode 100644
index 00000000..2ed70f6a
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeChange.java
@@ -0,0 +1,42 @@
+package net.tfminecraft.simplefactions.government.proposal;
+
+/** A proposed vehicle tax or fee rate, for every vehicle or for one vehicle type. */
+public class FeeChange {
+ private final FeeKind kind;
+ private final String vehicleTypeId;
+ private final double newRate;
+
+ /** A null vehicle type changes the general rate. */
+ public FeeChange(FeeKind kind, String vehicleTypeId, double newRate) {
+ this.kind = kind;
+ this.vehicleTypeId = vehicleTypeId == null || vehicleTypeId.isBlank() ? null : vehicleTypeId;
+ this.newRate = newRate;
+ }
+
+ public FeeKind getKind() {
+ return kind;
+ }
+
+ public String getVehicleTypeId() {
+ return vehicleTypeId;
+ }
+
+ public boolean isGeneral() {
+ return vehicleTypeId == null;
+ }
+
+ public double getNewRate() {
+ return newRate;
+ }
+
+ /** Two proposals clash when they would set the same rate. */
+ public boolean sameTarget(FeeChange other) {
+ if (other == null || other.kind != kind) {
+ return false;
+ }
+ if (vehicleTypeId == null) {
+ return other.vehicleTypeId == null;
+ }
+ return vehicleTypeId.equalsIgnoreCase(other.vehicleTypeId);
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeKind.java b/src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeKind.java
new file mode 100644
index 00000000..e4979e6f
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeKind.java
@@ -0,0 +1,75 @@
+package net.tfminecraft.simplefactions.government.proposal;
+
+import net.tfminecraft.simplefactions.enums.Brackets;
+import net.tfminecraft.simplefactions.enums.Rules;
+import net.tfminecraft.simplefactions.utils.Formatter;
+
+/**
+ * The charges in the vehicle tax law group. Every one is priced off the vehicle's
+ * daily upkeep: the tax as a percentage paid each day, the fees as a multiple paid once.
+ */
+public enum FeeKind {
+ VEHICLE_TAX("Vehicle Tax", Brackets.VEHICLE_TAX, Rules.VEHICLE_TAX, true),
+ REGISTRATION_FEE("Registration Fee", Brackets.REGISTRATION_FEE, Rules.REGISTRATION_FEE, false),
+ TRANSFER_FEE("Transfer Fee", Brackets.TRANSFER_FEE, Rules.TRANSFER_FEE, false);
+
+ private final String displayName;
+ private final Brackets bracket;
+ private final Rules rule;
+ private final boolean percent;
+
+ FeeKind(String displayName, Brackets bracket, Rules rule, boolean percent) {
+ this.displayName = displayName;
+ this.bracket = bracket;
+ this.rule = rule;
+ this.percent = percent;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public Brackets getBracket() {
+ return bracket;
+ }
+
+ public Rules getRule() {
+ return rule;
+ }
+
+ /** True when the rate is a percentage of upkeep, false when it is a multiple of it. */
+ public boolean isPercent() {
+ return percent;
+ }
+
+ /** The denars charged for a vehicle with this daily upkeep at this rate. */
+ public double amount(double rate, double upkeep) {
+ if (rate <= 0.0 || upkeep <= 0.0) {
+ return 0.0;
+ }
+ return Formatter.formatDouble(percent ? upkeep * rate / 100.0 : upkeep * rate);
+ }
+
+ /** "12.5%" for the tax, "1.5x upkeep" for the fees. */
+ public String formatRate(double rate) {
+ return percent ? rate + "%" : rate + "x upkeep";
+ }
+
+ public static FeeKind fromBracket(Brackets bracket) {
+ for (FeeKind kind : values()) {
+ if (kind.bracket == bracket) {
+ return kind;
+ }
+ }
+ return null;
+ }
+
+ public static FeeKind fromRule(Rules rule) {
+ for (FeeKind kind : values()) {
+ if (kind.rule == rule) {
+ return kind;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalText.java b/src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalText.java
new file mode 100644
index 00000000..f2139e33
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalText.java
@@ -0,0 +1,37 @@
+package net.tfminecraft.simplefactions.government.proposal;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import net.tfminecraft.simplefactions.objects.Faction;
+import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler;
+import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter;
+
+/** The lines that describe a fee proposal in books, menus and session reports. */
+public final class FeeProposalText {
+ private FeeProposalText() {}
+
+ public static String target(FeeChange fee) {
+ return fee.isGeneral() ? "All vehicles" : fee.getVehicleTypeId();
+ }
+
+ public static List lines(Faction f, FeeChange fee) {
+ List lines = new ArrayList<>();
+ FeeKind kind = fee.getKind();
+ lines.add(StringFormatter.formatHex("#b8ae61Fee: #c2bea7" + kind.getDisplayName()));
+ lines.add(StringFormatter.formatHex("#b8ae61Vehicle: #c2bea7" + target(fee)));
+ if (f == null) {
+ lines.add(StringFormatter.formatHex("#b8ae61Change: #c2bea7" + kind.formatRate(fee.getNewRate())));
+ return lines;
+ }
+ VehicleFeeHandler handler = f.getVehicleFeeHandler();
+ double old = handler.getRate(kind, fee.getVehicleTypeId());
+ lines.add(StringFormatter.formatHex("#b8ae61Change: #c2bea7" + kind.formatRate(old)
+ + " §7-> #c2bea7" + kind.formatRate(fee.getNewRate())));
+ if (!fee.isGeneral()) {
+ lines.add(StringFormatter.formatHex("#3f4040(#767a77General Rate: #928d7a"
+ + kind.formatRate(handler.getRate(kind)) + "#3f4040)"));
+ }
+ return lines;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java
index 50666448..4c76b266 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/proposal/Proposal.java
@@ -29,6 +29,7 @@ public class Proposal {
private Law law;
private TaxLawChange tax;
+ private FeeChange fee;
private PoliticalAction action;
private String target;
@@ -53,6 +54,8 @@ public void apply(Cause cause) {
} else if (isTaxProposal()) {
TaxTarget target = tax.getTarget();
gov.getFaction().getTaxHandler().setTaxRate(target, tax.getId(), tax.getNewTax());
+ } else if (isFeeProposal()) {
+ gov.getFaction().getVehicleFeeHandler().setRate(fee.getKind(), fee.getVehicleTypeId(), fee.getNewRate());
} else if (isPoliticalActionProposal()) {
gov.getFaction().applyPoliticalAction(cause, this);
}
@@ -119,6 +122,15 @@ public TaxLawChange getTaxChange() {
public void setTaxProposal(TaxLawChange tax) {
this.tax = tax;
}
+ public boolean isFeeProposal() {
+ return fee != null;
+ }
+ public FeeChange getFeeChange() {
+ return fee;
+ }
+ public void setFeeProposal(FeeChange fee) {
+ this.fee = fee;
+ }
public boolean isPoliticalActionProposal() {
return action != null;
}
@@ -126,7 +138,8 @@ public PoliticalAction getPoliticalAction() {
if(action == null) {
if(isLawProposal()) {
return new PoliticalAction(Action.LAW_CHANGE);
- } else if(isTaxProposal()) {
+ } else if(isTaxProposal() || isFeeProposal()) {
+ // Vehicle fees are taxes as far as movements and civil wars are concerned.
return new PoliticalAction(Action.TAX_CHANGE);
}
}
@@ -157,7 +170,8 @@ public ItemStack getAsBook(Player p) {
ItemStack item = new ItemStack(Material.WRITTEN_BOOK);
BookMeta meta = (BookMeta) item.getItemMeta();
- String title = isLawProposal() ? "Law Proposal" : isTaxProposal() ? "Tax Proposal" : "Political Action Proposal";
+ String title = isLawProposal() ? "Law Proposal" : isTaxProposal() ? "Tax Proposal"
+ : isFeeProposal() ? "Fee Proposal" : "Political Action Proposal";
if (title.length() > 32) title = title.substring(0, 32);
meta.setTitle(title);
meta.setAuthor(proposer != null ? proposer : "Unknown");
@@ -166,7 +180,7 @@ public ItemStack getAsBook(Player p) {
// First page: basic info
List first = new ArrayList<>();
- first.add(StringFormatter.formatHex(isLawProposal() ? "#93c9a7Law Proposal" : "#93c9a7Tax Proposal"));
+ first.add(StringFormatter.formatHex("#93c9a7" + title));
first.add("");
first.add(StringFormatter.formatHex("#85c265Proposed by: #c2bea7" + proposer));
@@ -210,6 +224,8 @@ public ItemStack getAsBook(Player p) {
double baseRate = f.getTaxRate(target, null, false);
first.add(StringFormatter.formatHex("#3f4040(#767a77Base Rate: #928d7a" + baseRate + "%#3f4040)"));
}
+ } else if (isFeeProposal()) {
+ first.addAll(FeeProposalText.lines(f, fee));
} else if(isPoliticalActionProposal()) {
Action action = getPoliticalAction().getAction();
first.add(StringFormatter.formatHex("#b8ae61Action: #c2bea7"+action.getDisplay()));
diff --git a/src/main/java/net/tfminecraft/simplefactions/government/session/SessionReport.java b/src/main/java/net/tfminecraft/simplefactions/government/session/SessionReport.java
index e78e6dcb..ce80132c 100644
--- a/src/main/java/net/tfminecraft/simplefactions/government/session/SessionReport.java
+++ b/src/main/java/net/tfminecraft/simplefactions/government/session/SessionReport.java
@@ -10,6 +10,8 @@
import net.tfminecraft.simplefactions.objects.Faction;
import net.tfminecraft.simplefactions.government.proposal.Proposal;
+import net.tfminecraft.simplefactions.government.proposal.FeeChange;
+import net.tfminecraft.simplefactions.government.proposal.FeeProposalText;
import net.tfminecraft.simplefactions.government.proposal.TaxLawChange;
import net.tfminecraft.simplefactions.government.proposal.TaxTarget;
import net.tfminecraft.simplefactions.managers.FactionManager;
@@ -132,6 +134,14 @@ public ItemStack generateReportBook() {
currentPage.append("Target: ").append(name).append("§r\n\n");
currentPage.append(String.format("%.0f", oldRate)).append("% §0→ ").append(taxChange.getNewTax()).append("%");
}
+ } else if (pr.proposal.isFeeProposal()) {
+ FeeChange fee = pr.proposal.getFeeChange();
+ double oldRate = faction.getVehicleFeeHandler().getRate(fee.getKind(), fee.getVehicleTypeId());
+ currentPage.append("Type: Fee\n");
+ currentPage.append("Fee: ").append(fee.getKind().getDisplayName()).append("\n");
+ currentPage.append("Vehicle: ").append(FeeProposalText.target(fee)).append("§r\n\n");
+ currentPage.append(fee.getKind().formatRate(oldRate)).append(" §0→ ")
+ .append(fee.getKind().formatRate(fee.getNewRate()));
}
currentPage.append("\n\n§6Votes\n");
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java
index 6b1a776e..88d182a5 100644
--- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java
@@ -216,6 +216,9 @@ public Guild(GuildData data, Faction host) {
this.wealth = 0.0;
this.wealthModifiers = Database.loadModifiers(data.wealthModifiers);
this.ledger = new Ledger(this);
+ if (data.vehicleFeeIncome != null) {
+ this.ledger.setVehicleFeeIncome(data.vehicleFeeIncome);
+ }
if (data.casinoProfit != null) {
this.ledger.setCasinoProfit(data.casinoProfit);
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java
index 22957a5f..4686c7af 100644
--- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java
@@ -33,6 +33,10 @@ public enum Cashflow {
// ended. Not money creation: it moves denars from players to the guild.
GAMBLING("#b58fc4Gambling", false, true),
+ // Vehicle tax and fees paid by members from their personal banks, banked as they are
+ // charged. Not money creation: it moves denars from players to the faction.
+ VEHICLE_FEES("#a6659fVehicle Taxes & Fees", false, false),
+
// Money creation
TRADE("#92d665Trade", true, true),
diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java
index 0a5ac986..cbfb6e7d 100644
--- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java
+++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java
@@ -50,6 +50,9 @@ public class Ledger {
// so a restart cannot quietly wipe a day of gambling income before it is taxed.
private double casinoProfit;
+ // Vehicle tax and fees collected today, already in the bank. Saved like casinoProfit.
+ private double vehicleFeeIncome;
+
// Dowsing registers this as the daily upkeep of the guild's active nodes; unset means no
// nodes plugin, so the line is 0. Settlement charges exactly what this reports.
private static ToDoubleFunction nodeUpkeepLookup = guild -> 0.0;
@@ -149,6 +152,23 @@ public void setCasinoProfit(double amount) {
casinoProfit = Math.max(0, amount);
}
+ /**
+ * Vehicle tax or a fee paid into this faction's bank (negative for a refund). The denars
+ * move when this is called; the ledger only shows them.
+ */
+ public void addVehicleFeeEntry(double amount) {
+ vehicleFeeIncome += amount;
+ }
+
+ public double getVehicleFeeIncome() {
+ return vehicleFeeIncome;
+ }
+
+ /** Seeded from disk at load, so the day's line survives a restart. */
+ public void setVehicleFeeIncome(double amount) {
+ vehicleFeeIncome = amount;
+ }
+
public double getIncome(Cashflow cashflow) {
double amount = 0;
if(skipsMoneyMovement()) return 0.0; //bankrupt guilds dont pay or receive money, they need to get our of bankrupcy first
@@ -267,6 +287,12 @@ public double getIncome(Cashflow cashflow) {
case GAMBLING:
amount = casinoProfit;
break;
+ case VEHICLE_FEES:
+ if (!guild.isBase()) {
+ return 0;
+ }
+ amount = vehicleFeeIncome;
+ break;
case TRADE: {
TradeBreakdown trade = guild.getTradeBreakdown();
amount = trade == null ? 0 : trade.getIncome();
@@ -446,6 +472,7 @@ public double getNetIncome() {
case CITIZENS:
case TARIFFS:
case GAMBLING:
+ case VEHICLE_FEES:
case GUILDS:
case VASSALS:
case TRIBUTES:
@@ -521,6 +548,7 @@ public double getDividendBase() {
case CITIZENS:
case TARIFFS:
case GAMBLING:
+ case VEHICLE_FEES:
case GUILDS:
case VASSALS:
case TRIBUTES:
@@ -780,6 +808,7 @@ public void populateDailyTransfers(DailyGuildTransfers buffer) {
interestPayments.clear();
// Taxed once, on the day it was won.
casinoProfit = 0;
+ vehicleFeeIncome = 0;
// Rebuilt from the persisted buckets by every pre-pass, so clearing them for a
// bankrupt hirer too keeps yesterday's bill from being paid twice.
mercenaryPayments.clear();
@@ -825,6 +854,9 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) {
// pay the guild twice. It is a tax base and a ledger line, nothing more.
case GAMBLING:
return;
+ // Banked by VehicleFeeService as each charge is paid.
+ case VEHICLE_FEES:
+ return;
// The faction share of military upkeep is withdrawn in Faction.newDay();
// only the company's slot upkeep settles here.
diff --git a/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java b/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java
index 32a0b975..c2686455 100644
--- a/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java
+++ b/src/main/java/net/tfminecraft/simplefactions/laws/LawEffect.java
@@ -16,6 +16,7 @@
import net.tfminecraft.simplefactions.enums.Region;
import net.tfminecraft.simplefactions.enums.Rules;
import net.tfminecraft.simplefactions.enums.Scope;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
public class LawEffect {
private Map rules = new LinkedHashMap<>();
@@ -206,6 +207,8 @@ public boolean affectsEconomy() {
if(hasBrackets()) {
for(Brackets b : brackets.keySet()) {
if(b == Brackets.CITIZEN_TAX || b == Brackets.DIVIDEND_TAX) continue;
+ // Vehicle fees are paid from personal banks, not guild income.
+ if(FeeKind.fromBracket(b) != null) continue;
return true;
}
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLoader.java b/src/main/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLoader.java
index a050b56c..726467e0 100644
--- a/src/main/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLoader.java
+++ b/src/main/java/net/tfminecraft/simplefactions/loaders/VehiclesConfigLoader.java
@@ -26,6 +26,7 @@ public final class VehiclesConfigLoader {
private static Set categoryIds = Set.of();
private static Map> typesByCategory = Map.of();
private static Map categoryByVehicleTypeId = Map.of();
+ private static Set feeExcludedCategories = Set.of();
private VehiclesConfigLoader() {}
@@ -67,6 +68,12 @@ public static void load(File vehiclesYaml) {
fail("vehicles.yml maintenance-interval-ticks must be >= 1");
}
+ Set excluded = new HashSet<>();
+ for (String category : config.getStringList("fee-excluded-categories")) {
+ excluded.add(category.toLowerCase(java.util.Locale.ROOT));
+ }
+ feeExcludedCategories = Collections.unmodifiableSet(excluded);
+
if (config.isConfigurationSection("upkeep")) {
fail("vehicles.yml uses legacy upkeep block; use categories...upkeep instead");
}
@@ -143,6 +150,12 @@ public static void load(File vehiclesYaml) {
categoryByVehicleTypeId = Collections.unmodifiableMap(typeToCategory);
}
+ /** VFBuilders blueprint categories left out of the vehicle fee proposal menu (staff-only ones). */
+ public static boolean isFeeExcludedCategory(String vfBuildersCategoryId) {
+ return vfBuildersCategoryId != null
+ && feeExcludedCategories.contains(vfBuildersCategoryId.toLowerCase(java.util.Locale.ROOT));
+ }
+
public static int getPersonalSlotLimit() {
return personalSlotLimit;
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java
index 39ff6622..edac8194 100644
--- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java
@@ -652,6 +652,12 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String
net.tfminecraft.simplefactions.vehicles.VehicleFactionCommands.armTake(p);
return true;
}
+ String handoverTarget = VehicleCommandRoute.handoverTarget(args);
+ if(handoverTarget != null) {
+ net.tfminecraft.simplefactions.vehicles.VehicleFactionCommands.armHandover(
+ p, handoverTarget.isBlank() ? null : handoverTarget);
+ return true;
+ }
String giveTarget = VehicleCommandRoute.giveTarget(args);
if(giveTarget != null) {
net.tfminecraft.simplefactions.vehicles.VehicleFactionCommands.armGive(
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java
index 165abaed..4abbabee 100644
--- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java
@@ -578,6 +578,7 @@ public void settleIncome() {
buffer.clear();
SimpleFactions.getInstance().getVehicleUpkeepService().processDailyUpkeep();
+ SimpleFactions.getInstance().recordVehicleOwners();
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java
index bce3377a..6cd05854 100644
--- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java
@@ -82,7 +82,12 @@
import net.tfminecraft.simplefactions.government.movement.Movement;
import net.tfminecraft.simplefactions.government.movement.cause.Cause;
import net.tfminecraft.simplefactions.government.proposal.Proposal;
+import net.tfminecraft.simplefactions.government.proposal.FeeChange;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
import net.tfminecraft.simplefactions.government.proposal.TaxLawChange;
+import net.tfminecraft.simplefactions.managers.inventory.FeeRateInput;
+import net.tfminecraft.simplefactions.managers.inventory.VehicleFeeView;
+import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler;
import net.tfminecraft.simplefactions.government.proposal.TaxTarget;
import net.tfminecraft.simplefactions.keys.Keys;
import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter;
@@ -95,6 +100,7 @@ public class InventoryManager implements Listener{
public HashMap campaignConfirmWar = new HashMap<>();
public HashMap installationConfirmFromCommand = new HashMap<>();
public HashMap taxChange = new HashMap<>();
+ public HashMap feeChange = new HashMap<>();
public HashMap loanPayments = new HashMap<>();
public HashMap dividendChange = new HashMap<>();
public HashMap slotChanges = new HashMap<>();
@@ -119,6 +125,12 @@ public void run() {
entry.getKey().sendMessage("§cTax change timed out.");
}
}
+ for(Map.Entry entry : ((HashMap) feeChange.clone()).entrySet()) {
+ if(entry.getValue().tick()) {
+ feeChange.remove(entry.getKey());
+ entry.getKey().sendMessage("§cFee change timed out.");
+ }
+ }
for(Map.Entry entry : ((HashMap) loanPayments.clone()).entrySet()) {
if(entry.getValue().tick()) {
loanPayments.remove(entry.getKey());
@@ -269,6 +281,7 @@ public void lawView(Player player, Faction f, Inventory i) {
//Government
public GovernmentView governmentView = new GovernmentView(this);
+ public VehicleFeeView vehicleFeeView = new VehicleFeeView(this);
public void governmentView(Player player, Faction f, Inventory i) {
governmentView.governmentView(player, f, i);
}
@@ -389,7 +402,7 @@ public void loanDetailView(Player p, Guild guild, Loan loan, boolean isTaken, In
}
public boolean chatTrigger(Player p) {
- return taxChange.containsKey(p) || loanPayments.containsKey(p) || dividendChange.containsKey(p)
+ return taxChange.containsKey(p) || feeChange.containsKey(p) || loanPayments.containsKey(p) || dividendChange.containsKey(p)
|| slotChanges.containsKey(p);
}
@@ -397,6 +410,10 @@ public void setChanging(Faction faction, Player p, TaxTarget target, String id)
taxChange.put(p, new TaxChange(faction, target, id));
}
+ public void setChangingFee(Faction faction, Player p, FeeKind kind, String vehicleTypeId) {
+ feeChange.put(p, new FeeRateInput(faction, kind, vehicleTypeId));
+ }
+
public boolean isPayingLoan(Player p) {
return loanPayments.containsKey(p);
}
@@ -434,6 +451,7 @@ public void setRate(AsyncPlayerChatEvent e) {
@Override
public void run() {
if(taxChange.containsKey(p)) taxChat(p, e);;
+ if(feeChange.containsKey(p)) feeChat(p, e);
if(loanPayments.containsKey(p)) loanPaymentChat(p, e);
if(dividendChange.containsKey(p)) dividendChat(p, e);
if(slotChanges.containsKey(p)) slotChat(p, e);
@@ -476,60 +494,102 @@ public void taxChat(Player p, AsyncPlayerChatEvent e) {
p.sendMessage("§4Type 'cancel' to cancel.");
return;
}
- Government gov = f.getGovernment();
- Proposal proposal = new Proposal(p.getName(), gov);
+ Proposal proposal = new Proposal(p.getName(), f.getGovernment());
TaxLawChange tax = new TaxLawChange(change.getTarget(), change.getId(), amount);
proposal.setTaxProposal(tax);
+ taxChange.remove(p);
+ submitRateProposal(p, f, proposal);
+ }
+
+ /**
+ * Applies a tax or fee change straight away for a leader with no council, otherwise
+ * proposes it to the council or adds it to the player's movement.
+ */
+ private void submitRateProposal(Player p, Faction f, Proposal proposal) {
+ Government gov = f.getGovernment();
if(!gov.hasCouncil() && f.isLeader(p.getName())) {
p.sendMessage("§aChange applied!");
proposal.apply(null);
p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f);
- taxChange.remove(p);
governmentView.governmentView(p, f, null);
return;
}
- if(gov.canBeProposed(proposal)) {
- if(gov.canPropose(p)) {
- gov.propose(proposal);
- p.sendTitle("", "§aProposal Added", 20, 80, 20);
- p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f);
- } else if(gov.canProposeOrStartMovement(p)) {
- Movement movement = gov.getMovementByMember(p.getName());
- if(movement != null) {
- if(!gov.canBeProposed(proposal)) {
- p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f);
- return;
- }
- movement.createCause(p.getName(), proposal);
- p.sendMessage("§aProposal added to your movement! Rally support for your proposal by sharing it with your faction and allies!");
- p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f);
- governmentView(p, f, null);
- return;
- }
- if (net.tfminecraft.simplefactions.war.civilwar.CivilWarHostMovementRules.blocksHostGuildStart(f, p.getName())) {
- p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f);
- p.sendMessage(net.tfminecraft.simplefactions.war.civilwar.CivilWarCopy.ONE_PROVINCE_HOST_GUILD);
- return;
- }
- gov.startMovement(p.getName(), proposal);
- p.sendTitle("", "§cMovement Started", 20, 80, 20);
+ if(!gov.canBeProposed(proposal)) {
+ p.sendMessage("§cThere is already a proposal active for this target.");
+ return;
+ }
+ if(gov.canPropose(p)) {
+ gov.propose(proposal);
+ p.sendTitle("", "§aProposal Added", 20, 80, 20);
+ p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f);
+ } else if(gov.canProposeOrStartMovement(p)) {
+ Movement movement = gov.getMovementByMember(p.getName());
+ if(movement != null) {
+ movement.createCause(p.getName(), proposal);
+ p.sendMessage("§aProposal added to your movement! Rally support for your proposal by sharing it with your faction and allies!");
p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f);
- p.sendMessage("§aMovement started! Rally support for your proposal by sharing it with your faction and allies!");
- } else {
- p.sendMessage("§cYou can no longer propose this change.");
- taxChange.remove(p);
+ governmentView(p, f, null);
return;
}
+ if (net.tfminecraft.simplefactions.war.civilwar.CivilWarHostMovementRules.blocksHostGuildStart(f, p.getName())) {
+ p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f);
+ p.sendMessage(net.tfminecraft.simplefactions.war.civilwar.CivilWarCopy.ONE_PROVINCE_HOST_GUILD);
+ return;
+ }
+ gov.startMovement(p.getName(), proposal);
+ p.sendTitle("", "§cMovement Started", 20, 80, 20);
+ p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f);
+ p.sendMessage("§aMovement started! Rally support for your proposal by sharing it with your faction and allies!");
} else {
- p.sendMessage("§cThere is already a proposal active for this target.");
- taxChange.remove(p);
+ p.sendMessage("§cYou can no longer propose this change.");
return;
}
- //something changed so you cant do anything anymore :)
- taxChange.remove(p);
governmentView.governmentView(p, f, null);
}
+ // Keep the existing legacy text representation, formatting, and exact-string comparisons. Retain Bukkit chat-event ordering and String message semantics for existing integrations.
+ @SuppressWarnings("deprecation")
+ public void feeChat(Player p, AsyncPlayerChatEvent e) {
+ FeeRateInput change = feeChange.get(p);
+ Faction f = change.getFaction();
+ if(f == null) {
+ feeChange.remove(p);
+ return;
+ }
+ if(e.getMessage().equalsIgnoreCase("cancel")) {
+ p.sendMessage("§cFee change cancelled.");
+ feeChange.remove(p);
+ governmentView.governmentView(p, f, null);
+ return;
+ }
+ FeeKind kind = change.getKind();
+ double amount;
+ try {
+ amount = Double.parseDouble(e.getMessage());
+ } catch (Exception ex) {
+ p.sendMessage(kind.isPercent()
+ ? "§cError inputting the amount, use the format §e15.5 §cfor 15.5% of upkeep (example)"
+ : "§cError inputting the amount, use the format §e1.5 §cfor 1.5 times upkeep (example)");
+ p.sendMessage("§4Type 'cancel' to cancel.");
+ return;
+ }
+ amount = Math.round(amount*100.0)/100.0;
+ VehicleFeeHandler handler = f.getVehicleFeeHandler();
+ if(amount > handler.getMax(kind)) {
+ p.sendMessage("§cThe maximum you can set for "+kind.getDisplayName()+" is §e"+kind.formatRate(handler.getMax(kind)));
+ p.sendMessage("§4Type 'cancel' to cancel.");
+ return;
+ } else if(amount < handler.getMin(kind)) {
+ p.sendMessage("§cThe minimum you can set for "+kind.getDisplayName()+" is §e"+kind.formatRate(handler.getMin(kind)));
+ p.sendMessage("§4Type 'cancel' to cancel.");
+ return;
+ }
+ Proposal proposal = new Proposal(p.getName(), f.getGovernment());
+ proposal.setFeeProposal(new FeeChange(kind, change.getVehicleTypeId(), amount));
+ feeChange.remove(p);
+ submitRateProposal(p, f, proposal);
+ }
+
// Retain Bukkit chat-event ordering and String message semantics for existing integrations.
@SuppressWarnings("deprecation")
public void loanPaymentChat(Player p, AsyncPlayerChatEvent e) {
@@ -1070,6 +1130,11 @@ public void clickButton(InventoryClickEvent e) {
case SPECIFIC_TAX_PROPOSAL_VIEW:
governmentView.taxProposalView(p, f, null);
break;
+ case FEE_PROPOSAL_VIEW:
+ case FEE_CATEGORY_VIEW:
+ case FEE_VEHICLE_VIEW:
+ vehicleFeeView.back(p, f, h);
+ break;
case PROPOSALS:
governmentView(p, f, null);
break;
@@ -1201,6 +1266,10 @@ public void clickButton(InventoryClickEvent e) {
|| h.getType() == SFGUI.FAVOUR_REPRESS_TYPE
|| h.getType() == SFGUI.FAVOUR_REPRESS_SELECT) {
governmentView.click(e, inv, p);
+ } else if(h.getType() == SFGUI.FEE_PROPOSAL_VIEW
+ || h.getType() == SFGUI.FEE_CATEGORY_VIEW
+ || h.getType() == SFGUI.FEE_VEHICLE_VIEW) {
+ vehicleFeeView.click(e, inv, p);
} else if(h.getType() == SFGUI.MOVEMENT_VIEW
|| h.getType() == SFGUI.MOVEMENT_LIST
|| h.getType() == SFGUI.CAUSES_VIEW
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/RequestManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/RequestManager.java
index cbae2c43..cbf8427d 100644
--- a/src/main/java/net/tfminecraft/simplefactions/managers/RequestManager.java
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/RequestManager.java
@@ -21,6 +21,7 @@
import net.tfminecraft.simplefactions.objects.request.RelocateRequest;
import net.tfminecraft.simplefactions.objects.request.Request;
import net.tfminecraft.simplefactions.objects.request.VehicleGiveConsentRequest;
+import net.tfminecraft.simplefactions.objects.request.VehicleHandoverRequest;
import net.tfminecraft.simplefactions.objects.request.VehicleTransferConsentRequest;
import net.tfminecraft.simplefactions.objects.request.WarRequest;
import net.tfminecraft.simplefactions.war.campaign.runtime.BattleAutoresolveService;
@@ -100,6 +101,11 @@ static void expireTimedOutRequests() {
plugin.getFactionVehicleGiveService()
.notifyExpired(giveRequest, entry.getKey());
}
+ } else if (request instanceof VehicleHandoverRequest handoverRequest) {
+ SimpleFactions plugin = SimpleFactions.getInstance();
+ if (plugin != null) {
+ plugin.getVehicleHandoverService().notifyExpired(handoverRequest, entry.getKey());
+ }
} else if (request instanceof WarRequest warRequest) {
WarManager.declineCallToArms(entry.getKey(), warRequest, false);
}
@@ -152,6 +158,11 @@ public static void accept(Player p) {
if (plugin != null) {
plugin.getFactionVehicleGiveService().acceptRequest(p);
}
+ } else if(req instanceof VehicleHandoverRequest) {
+ SimpleFactions plugin = SimpleFactions.getInstance();
+ if (plugin != null) {
+ plugin.getVehicleHandoverService().acceptRequest(p);
+ }
}
requests.remove(p);
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FeeRateInput.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FeeRateInput.java
new file mode 100644
index 00000000..e75e8e33
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/FeeRateInput.java
@@ -0,0 +1,35 @@
+package net.tfminecraft.simplefactions.managers.inventory;
+
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.objects.Faction;
+
+/** A player typing a new vehicle fee rate in chat. A null vehicle type means the general rate. */
+public class FeeRateInput {
+ private final Faction faction;
+ private final FeeKind kind;
+ private final String vehicleTypeId;
+ private int time;
+
+ public FeeRateInput(Faction faction, FeeKind kind, String vehicleTypeId) {
+ this.faction = faction;
+ this.kind = kind;
+ this.vehicleTypeId = vehicleTypeId;
+ }
+
+ public boolean tick() {
+ time++;
+ return time == 30;
+ }
+
+ public Faction getFaction() {
+ return faction;
+ }
+
+ public FeeKind getKind() {
+ return kind;
+ }
+
+ public String getVehicleTypeId() {
+ return vehicleTypeId;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java
index aaaeb4f9..b0fbe05a 100644
--- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentCreator.java
@@ -579,7 +579,8 @@ public ItemStack createStartCouncilButton(Player p, Faction f) {
public ItemStack createCurrentProposalItem(Player p, Faction f, Proposal proposal) {
ItemStack item = new ItemStack(Material.BOOK);
ItemMeta m = item.getItemMeta();
- m.setDisplayName(StringFormatter.formatHex(proposal.isLawProposal() ? "#93c9a7Law Proposal" : "#93c9a7Tax Proposal"));
+ m.setDisplayName(StringFormatter.formatHex(proposal.isLawProposal() ? "#93c9a7Law Proposal"
+ : proposal.isFeeProposal() ? "#93c9a7Fee Proposal" : "#93c9a7Tax Proposal"));
List lore = new ArrayList();
lore.add(StringFormatter.formatHex("#85c265Proposed by: #c2bea7"+proposal.getProposer()));
LoreWriter.applyProposalLore(proposal, lore, p, f, m);
@@ -605,6 +606,13 @@ public ItemStack createProposalTypeItem(String type) {
lore.add(StringFormatter.formatHex("#b8ae61Create a proposal to change"));
lore.add(StringFormatter.formatHex("#b8ae61the tax rate in your faction."));
m.setLore(lore);
+ } else if(type.equalsIgnoreCase("fee")) {
+ m.setDisplayName(StringFormatter.formatHex("#93c9a7Vehicle Fee Proposal"));
+ List lore = new ArrayList();
+ lore.add(StringFormatter.formatHex("#b8ae61Create a proposal to change the"));
+ lore.add(StringFormatter.formatHex("#b8ae61vehicle tax or fees, for every"));
+ lore.add(StringFormatter.formatHex("#b8ae61vehicle or just one."));
+ m.setLore(lore);
} else if(type.equalsIgnoreCase("political")) {
m.setDisplayName(StringFormatter.formatHex("#93c9a7Political Proposal"));
List lore = new ArrayList();
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java
index cb4dc2a2..3b202b9e 100644
--- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GovernmentView.java
@@ -135,6 +135,8 @@ public void proposalView(Player player, Faction f, Inventory i) {
i.clear();
i.setItem(0, creator.createProposalTypeItem("law"));
i.setItem(1, creator.createProposalTypeItem("tax"));
+ if(VehicleFeeView.anyChargeable(f))
+ i.setItem(3, creator.createProposalTypeItem("fee"));
if((!f.getGovernment().canPropose(player) && f.getGovernment().canProposeOrStartMovement(player))
|| (f.getGovernment().canPropose(player) && CouncilPeaceQueries.isParticipatingInAny(f)))
i.setItem(2, creator.createProposalTypeItem("political"));
@@ -386,6 +388,9 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) {
} else if(slot == 2) {
politicalProposalView(p, f, null);
p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f);
+ } else if(slot == 3 && VehicleFeeView.anyChargeable(f)) {
+ inv.vehicleFeeView.feeProposalView(p, f, null);
+ p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f);
}
} else if (h.getType() == SFGUI.TAX_PROPOSAL_VIEW) {
e.setCancelled(true);
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/PlayerLedgerCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/PlayerLedgerCreator.java
index 9daf5c20..61b72816 100644
--- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/PlayerLedgerCreator.java
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/PlayerLedgerCreator.java
@@ -71,6 +71,8 @@ public List buildLore(PlayerLedger ledger, UUID playerUuid) {
for (PlayerCashflow cashflow : PlayerCashflow.values()) {
double value = cashflow == PlayerCashflow.VEHICLE_UPKEEP
? VehicleUpkeepProjection.displayVehicleExpense(ledger, playerUuid)
+ : cashflow == PlayerCashflow.VEHICLE_TAX
+ ? VehicleUpkeepProjection.displayVehicleTax(ledger, playerUuid)
: ledger.getAmount(cashflow);
if (value >= 0) {
continue;
diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java
new file mode 100644
index 00000000..df64cbe1
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java
@@ -0,0 +1,369 @@
+package net.tfminecraft.simplefactions.managers.inventory;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.bukkit.Material;
+import org.bukkit.Sound;
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.ItemMeta;
+import org.bukkit.persistence.PersistentDataType;
+
+import net.tfminecraft.simplefactions.SimpleFactions;
+import net.tfminecraft.simplefactions.enums.SFGUI;
+import net.tfminecraft.simplefactions.government.Government;
+import net.tfminecraft.simplefactions.government.proposal.FeeChange;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.government.proposal.Proposal;
+import net.tfminecraft.simplefactions.keys.Keys;
+import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader;
+import net.tfminecraft.simplefactions.managers.FactionManager;
+import net.tfminecraft.simplefactions.managers.InventoryManager;
+import net.tfminecraft.simplefactions.managers.holder.SFInventoryHolder;
+import net.tfminecraft.simplefactions.objects.Bracket;
+import net.tfminecraft.simplefactions.objects.Faction;
+import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler;
+import net.tfminecraft.simplefactions.utils.Formatter;
+import net.tfminecraft.simplefactions.vehicles.fees.VfBuildersCatalog;
+import net.tfminecraft.tlibs.objects.api.subapi.StringFormatter;
+
+/**
+ * Fee proposal menus: pick the tax or fee, then all vehicles or a VFBuilders category,
+ * then a vehicle in it, then type the new rate in chat.
+ */
+public class VehicleFeeView {
+ private static final String GENERAL = "*";
+
+ private final InventoryManager inv;
+
+ public VehicleFeeView(InventoryManager inv) {
+ this.inv = inv;
+ }
+
+ /** Whether the faction's vehicle tax law allows any charge to be proposed. */
+ public static boolean anyChargeable(Faction f) {
+ for (FeeKind kind : FeeKind.values()) {
+ if (f.getVehicleFeeHandler().canCharge(kind)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void feeProposalView(Player player, Faction f, Inventory i) {
+ boolean open = i == null;
+ if (open) {
+ i = SimpleFactions.plugin.getServer().createInventory(
+ new SFInventoryHolder(f.getId(), SFGUI.FEE_PROPOSAL_VIEW), 9, "§7Select Vehicle Fee");
+ }
+ i.clear();
+ int x = 0;
+ for (FeeKind kind : FeeKind.values()) {
+ if (!f.getVehicleFeeHandler().canCharge(kind)) {
+ continue;
+ }
+ i.setItem(x, kindItem(f, kind));
+ x++;
+ }
+ i.setItem(8, inv.createBackButton(SFGUI.FEE_PROPOSAL_VIEW));
+ if (open) {
+ player.openInventory(i);
+ }
+ }
+
+ public void feeCategoryView(Player player, Faction f, FeeKind kind, Inventory i) {
+ boolean open = i == null;
+ if (open) {
+ i = SimpleFactions.plugin.getServer().createInventory(
+ new SFInventoryHolder(f.getId(), SFGUI.FEE_CATEGORY_VIEW, kind.name()), 54, "§7Select Vehicles");
+ }
+ i.clear();
+ i.setItem(0, generalItem(player, f, kind));
+ int x = 1;
+ for (VfBuildersCatalog.Category category : VfBuildersCatalog.categories()) {
+ if (x >= 53) {
+ break;
+ }
+ i.setItem(x, categoryItem(f, kind, category));
+ x++;
+ }
+ i.setItem(53, inv.createBackButton(SFGUI.FEE_CATEGORY_VIEW));
+ if (open) {
+ player.openInventory(i);
+ }
+ }
+
+ public void feeVehicleView(Player player, Faction f, FeeKind kind, String categoryId, Inventory i) {
+ VfBuildersCatalog.Category category = VfBuildersCatalog.category(categoryId);
+ if (category == null) {
+ feeCategoryView(player, f, kind, null);
+ return;
+ }
+ boolean open = i == null;
+ if (open) {
+ i = SimpleFactions.plugin.getServer().createInventory(
+ new SFInventoryHolder(f.getId(), SFGUI.FEE_VEHICLE_VIEW, kind.name() + ":" + category.id()),
+ 54, "§7Select Vehicle");
+ }
+ i.clear();
+ int x = 0;
+ for (VfBuildersCatalog.Entry entry : category.vehicles()) {
+ if (x >= 53) {
+ break;
+ }
+ i.setItem(x, vehicleItem(player, f, kind, entry));
+ x++;
+ }
+ i.setItem(53, inv.createBackButton(SFGUI.FEE_VEHICLE_VIEW));
+ if (open) {
+ player.openInventory(i);
+ }
+ }
+
+ /** Where the back button of each fee menu leads. */
+ public void back(Player player, Faction f, SFInventoryHolder h) {
+ switch (h.getType()) {
+ case FEE_PROPOSAL_VIEW -> inv.governmentView.proposalView(player, f, null);
+ case FEE_CATEGORY_VIEW -> feeProposalView(player, f, null);
+ case FEE_VEHICLE_VIEW -> {
+ FeeKind kind = parseKind(h.getSecondaryId());
+ if (kind == null) {
+ feeProposalView(player, f, null);
+ } else {
+ feeCategoryView(player, f, kind, null);
+ }
+ }
+ default -> {
+ }
+ }
+ }
+
+ public void click(InventoryClickEvent e, Inventory inventory, Player p) {
+ e.setCancelled(true);
+ if (!(inventory.getHolder() instanceof SFInventoryHolder h)) {
+ return;
+ }
+ ItemStack item = e.getCurrentItem();
+ if (item == null || item.getItemMeta() == null) {
+ return;
+ }
+ Faction f = FactionManager.getByString(h.getId());
+ if (f == null) {
+ return;
+ }
+ String key = item.getItemMeta().getPersistentDataContainer().get(Keys.STRING_KEY, PersistentDataType.STRING);
+ if (key == null) {
+ return;
+ }
+ switch (h.getType()) {
+ case FEE_PROPOSAL_VIEW -> {
+ FeeKind kind = parseKind(key);
+ if (kind != null) {
+ feeCategoryView(p, f, kind, null);
+ p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f);
+ }
+ }
+ case FEE_CATEGORY_VIEW -> {
+ FeeKind kind = parseKind(h.getSecondaryId());
+ if (kind == null) {
+ return;
+ }
+ if (GENERAL.equals(key)) {
+ startInput(p, f, kind, null);
+ } else {
+ feeVehicleView(p, f, kind, key, null);
+ p.playSound(p, Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f);
+ }
+ }
+ case FEE_VEHICLE_VIEW -> {
+ String secondary = h.getSecondaryId();
+ FeeKind kind = secondary == null ? null : parseKind(secondary.split(":", 2)[0]);
+ if (kind != null) {
+ startInput(p, f, kind, key);
+ }
+ }
+ default -> {
+ }
+ }
+ }
+
+ // Keep the existing legacy text representation, formatting, and exact-string comparisons.
+ @SuppressWarnings("deprecation")
+ private void startInput(Player p, Faction f, FeeKind kind, String vehicleTypeId) {
+ if (!canPropose(p, f, kind, vehicleTypeId)) {
+ p.playSound(p, Sound.ENTITY_VILLAGER_NO, 1f, 1f);
+ p.sendMessage("§cAnother proposal is active for this fee, or you cannot propose right now.");
+ return;
+ }
+ inv.setChangingFee(f, p, kind, vehicleTypeId);
+ String target = vehicleTypeId == null ? "all vehicles" : vehicleTypeId;
+ String unit = kind.isPercent() ? "a percentage of upkeep" : "a multiple of upkeep";
+ p.sendTitle("§a" + kind.getDisplayName(), "§eType a new rate for " + target + " §ein chat.", 20, 40, 20);
+ p.sendMessage(StringFormatter.formatHex("§eType the new " + kind.getDisplayName() + " for " + target
+ + " as " + unit + " §7(" + rangeText(f.getVehicleFeeHandler(), kind) + "§7)§e, or §ccancel§e."));
+ p.playSound(p, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f);
+ p.closeInventory();
+ }
+
+ private static boolean canPropose(Player p, Faction f, FeeKind kind, String vehicleTypeId) {
+ Government gov = f.getGovernment();
+ Proposal proposal = new Proposal(p.getName(), gov);
+ proposal.setFeeProposal(new FeeChange(kind, vehicleTypeId, 0.0));
+ return gov.canProposeOrStartMovement(p) && gov.canBeProposed(proposal);
+ }
+
+ // Items
+
+ // Keep the existing legacy text representation, formatting, and exact-string comparisons.
+ @SuppressWarnings("deprecation")
+ private ItemStack kindItem(Faction f, FeeKind kind) {
+ VehicleFeeHandler handler = f.getVehicleFeeHandler();
+ Material material = switch (kind) {
+ case VEHICLE_TAX -> Material.GOLD_INGOT;
+ case REGISTRATION_FEE -> Material.WRITABLE_BOOK;
+ case TRANSFER_FEE -> Material.PAPER;
+ };
+ ItemStack item = new ItemStack(material);
+ ItemMeta m = item.getItemMeta();
+ m.setDisplayName(StringFormatter.formatHex("#93c9a7" + kind.getDisplayName()));
+ List lore = new ArrayList<>();
+ lore.addAll(describe(kind));
+ lore.add("");
+ lore.add(StringFormatter.formatHex("#525d5dGeneral Rate: #e3d5a1" + kind.formatRate(handler.getRate(kind))));
+ lore.add(StringFormatter.formatHex("#525d5dAllowed: " + rangeText(handler, kind)));
+ int own = handler.getTypeRates(kind).size();
+ if (own > 0) {
+ lore.add(StringFormatter.formatHex("#525d5dVehicles with their own rate: #e3d5a1" + own));
+ }
+ lore.add(StringFormatter.formatHex("#28ed70Click to choose vehicles"));
+ m.setLore(lore);
+ m.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, kind.name());
+ item.setItemMeta(m);
+ return item;
+ }
+
+ private static List describe(FeeKind kind) {
+ List lore = new ArrayList<>();
+ switch (kind) {
+ case VEHICLE_TAX -> {
+ lore.add(StringFormatter.formatHex("#b8ae61Charged daily with vehicle upkeep,"));
+ lore.add(StringFormatter.formatHex("#b8ae61as a percentage of it."));
+ }
+ case REGISTRATION_FEE -> {
+ lore.add(StringFormatter.formatHex("#b8ae61Charged once when a member starts"));
+ lore.add(StringFormatter.formatHex("#b8ae61building a vehicle, as a multiple"));
+ lore.add(StringFormatter.formatHex("#b8ae61of its daily upkeep."));
+ }
+ case TRANSFER_FEE -> {
+ lore.add(StringFormatter.formatHex("#b8ae61Charged once to a member who hands"));
+ lore.add(StringFormatter.formatHex("#b8ae61a vehicle to another player, as a"));
+ lore.add(StringFormatter.formatHex("#b8ae61multiple of its daily upkeep."));
+ }
+ }
+ lore.add(StringFormatter.formatHex("#767a77Paid from members' banks. The leader is exempt."));
+ return lore;
+ }
+
+ // Keep the existing legacy text representation, formatting, and exact-string comparisons.
+ @SuppressWarnings("deprecation")
+ private ItemStack generalItem(Player p, Faction f, FeeKind kind) {
+ VehicleFeeHandler handler = f.getVehicleFeeHandler();
+ ItemStack item = new ItemStack(Material.GOLD_BLOCK);
+ ItemMeta m = item.getItemMeta();
+ m.setDisplayName(StringFormatter.formatHex("#93c9a7All Vehicles"));
+ List lore = new ArrayList<>();
+ lore.add(StringFormatter.formatHex("#525d5dGeneral Rate: #e3d5a1" + kind.formatRate(handler.getRate(kind))));
+ lore.add(StringFormatter.formatHex("#525d5dAllowed: " + rangeText(handler, kind)));
+ lore.add(StringFormatter.formatHex("#767a77Applies to every vehicle without its own rate."));
+ lore.add(proposeLine(p, f, kind, null));
+ m.setLore(lore);
+ m.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, GENERAL);
+ item.setItemMeta(m);
+ return item;
+ }
+
+ // Keep the existing legacy text representation, formatting, and exact-string comparisons.
+ @SuppressWarnings("deprecation")
+ private ItemStack categoryItem(Faction f, FeeKind kind, VfBuildersCatalog.Category category) {
+ ItemStack item = category.icon() != null ? category.icon() : new ItemStack(Material.CHEST);
+ ItemMeta m = item.getItemMeta();
+ if (!m.hasDisplayName()) {
+ m.setDisplayName(StringFormatter.formatHex("#93c9a7" + Formatter.formatName(category.id())));
+ }
+ List lore = new ArrayList<>();
+ lore.add(StringFormatter.formatHex("#525d5dVehicles: #e3d5a1" + category.vehicles().size()));
+ int own = 0;
+ for (VfBuildersCatalog.Entry entry : category.vehicles()) {
+ if (f.getVehicleFeeHandler().hasTypeRate(kind, entry.vehicleTypeId())) {
+ own++;
+ }
+ }
+ if (own > 0) {
+ lore.add(StringFormatter.formatHex("#525d5dWith their own rate: #e3d5a1" + own));
+ }
+ lore.add(StringFormatter.formatHex("#28ed70Click to view vehicles"));
+ m.setLore(lore);
+ m.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, category.id());
+ item.setItemMeta(m);
+ return item;
+ }
+
+ // Keep the existing legacy text representation, formatting, and exact-string comparisons.
+ @SuppressWarnings("deprecation")
+ private ItemStack vehicleItem(Player p, Faction f, FeeKind kind, VfBuildersCatalog.Entry entry) {
+ VehicleFeeHandler handler = f.getVehicleFeeHandler();
+ String typeId = entry.vehicleTypeId();
+ ItemStack item = entry.icon() != null ? entry.icon() : new ItemStack(Material.MINECART);
+ ItemMeta m = item.getItemMeta();
+ if (!m.hasDisplayName()) {
+ m.setDisplayName(StringFormatter.formatHex("#93c9a7" + Formatter.formatName(typeId)));
+ }
+ double upkeep = VehiclesConfigLoader.getUpkeep(typeId);
+ double rate = handler.getRate(kind, typeId);
+ List lore = new ArrayList<>();
+ lore.add(StringFormatter.formatHex("#525d5dUpkeep: #e3d5a1" + Formatter.formatMoney(upkeep) + "d"));
+ if (handler.hasTypeRate(kind, typeId)) {
+ lore.add(StringFormatter.formatHex("#525d5dOwn Rate: #e3d5a1" + kind.formatRate(rate)));
+ lore.add(StringFormatter.formatHex("#3f4040(#767a77General Rate: #928d7a"
+ + kind.formatRate(handler.getRate(kind)) + "#3f4040)"));
+ } else {
+ lore.add(StringFormatter.formatHex("#525d5dRate: #e3d5a1" + kind.formatRate(rate) + " §8(general)"));
+ }
+ lore.add(StringFormatter.formatHex("#525d5dCharge: #e3d5a1"
+ + Formatter.formatMoney(kind.amount(handler.getChargedRate(kind, typeId), upkeep)) + "d"
+ + (kind.isPercent() ? " per day" : "")));
+ lore.add(proposeLine(p, f, kind, typeId));
+ m.setLore(lore);
+ m.getPersistentDataContainer().set(Keys.STRING_KEY, PersistentDataType.STRING, typeId);
+ item.setItemMeta(m);
+ return item;
+ }
+
+ private static String proposeLine(Player p, Faction f, FeeKind kind, String vehicleTypeId) {
+ return canPropose(p, f, kind, vehicleTypeId)
+ ? StringFormatter.formatHex("#28ed70Click to propose a change")
+ : StringFormatter.formatHex("#89504eAnother proposal is active for this target.");
+ }
+
+ private static String rangeText(VehicleFeeHandler handler, FeeKind kind) {
+ Bracket bracket = handler.getBracket(kind);
+ if (bracket == null) {
+ return "#e3d5a1none";
+ }
+ return "#e3d5a1" + kind.formatRate(handler.getMin(kind)) + " §7- #e3d5a1" + kind.formatRate(handler.getMax(kind));
+ }
+
+ private static FeeKind parseKind(String name) {
+ if (name == null) {
+ return null;
+ }
+ try {
+ return FeeKind.valueOf(name);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java
index a2b57c32..8f871d5e 100644
--- a/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java
+++ b/src/main/java/net/tfminecraft/simplefactions/objects/Faction.java
@@ -44,6 +44,8 @@
import net.tfminecraft.simplefactions.objects.handler.LawHandler;
import net.tfminecraft.simplefactions.objects.handler.ProvinceHandler;
import net.tfminecraft.simplefactions.objects.handler.TaxHandler;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler;
import net.tfminecraft.simplefactions.rest.BannerFetcher;
import net.tfminecraft.simplefactions.SimpleFactions;
import net.tfminecraft.simplefactions.tiers.Tier;
@@ -103,6 +105,8 @@ public class Faction {
private TaxHandler taxHandler;
+ // Built before any constructor body runs, so lawHandler.apply() can set its brackets.
+ private final VehicleFeeHandler vehicleFeeHandler = new VehicleFeeHandler(this);
private Tier tier;
@@ -422,6 +426,10 @@ public Military getMilitary() {
return military;
}
+ public VehicleFeeHandler getVehicleFeeHandler() {
+ return vehicleFeeHandler;
+ }
+
public TaxHandler getTaxHandler() {
return taxHandler;
}
@@ -1009,10 +1017,15 @@ public void applyLaw(Law law, LawGroup group) {
// --- existing tax logic ---
if (effect.hasBrackets()) {
for (Map.Entry entry : effect.getBrackets().entrySet()) {
- taxHandler.applyBracket(
- BracketToTaxTarget.convert(entry.getKey()),
- entry.getValue()
- );
+ FeeKind feeKind = FeeKind.fromBracket(entry.getKey());
+ if (feeKind != null) {
+ vehicleFeeHandler.applyBracket(feeKind, entry.getValue());
+ continue;
+ }
+ TaxTarget target = BracketToTaxTarget.convert(entry.getKey());
+ if (target != null) {
+ taxHandler.applyBracket(target, entry.getValue());
+ }
}
}
@@ -1042,6 +1055,12 @@ public void applyLaw(Law law, LawGroup group) {
if (!value)
taxHandler.applyBracket(TaxTarget.TARIFFS, new Bracket(0, 0));
break;
+ case VEHICLE_TAX:
+ case REGISTRATION_FEE:
+ case TRANSFER_FEE:
+ if (!value)
+ vehicleFeeHandler.applyBracket(FeeKind.fromRule(rule), new Bracket(0, 0));
+ break;
default:
break;
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandler.java b/src/main/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandler.java
new file mode 100644
index 00000000..e0584816
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandler.java
@@ -0,0 +1,205 @@
+package net.tfminecraft.simplefactions.objects.handler;
+
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.objects.Bracket;
+import net.tfminecraft.simplefactions.objects.Faction;
+
+/**
+ * A faction's vehicle tax and fee rates. Each kind has a general rate and optional
+ * per-vehicle-type rates, all held inside the bracket of the current vehicle tax law.
+ */
+public class VehicleFeeHandler {
+ private final Faction f;
+ private final Map rates = new EnumMap<>(FeeKind.class);
+ private final Map> typeRates = new EnumMap<>(FeeKind.class);
+ private final Map brackets = new EnumMap<>(FeeKind.class);
+
+ public VehicleFeeHandler(Faction f) {
+ this.f = f;
+ }
+
+ public double getRate(FeeKind kind) {
+ return rates.getOrDefault(kind, 0.0);
+ }
+
+ /** The type's own rate if it has one, otherwise the general rate. */
+ public double getRate(FeeKind kind, String vehicleTypeId) {
+ Double specific = getTypeRate(kind, vehicleTypeId);
+ return specific != null ? specific : getRate(kind);
+ }
+
+ public boolean hasTypeRate(FeeKind kind, String vehicleTypeId) {
+ return getTypeRate(kind, vehicleTypeId) != null;
+ }
+
+ private Double getTypeRate(FeeKind kind, String vehicleTypeId) {
+ if (vehicleTypeId == null) {
+ return null;
+ }
+ Map map = typeRates.get(kind);
+ return map == null ? null : map.get(key(vehicleTypeId));
+ }
+
+ public Map getTypeRates(FeeKind kind) {
+ Map map = typeRates.get(kind);
+ return map == null ? new HashMap<>() : new HashMap<>(map);
+ }
+
+ /** A null vehicle type sets the general rate. The rate is clamped into the bracket. */
+ public void setRate(FeeKind kind, String vehicleTypeId, double rate) {
+ double clamped = clamp(kind, rate);
+ if (vehicleTypeId == null) {
+ rates.put(kind, clamped);
+ dropTypeRatesEqualToGeneral(kind);
+ return;
+ }
+ if (Double.compare(clamped, getRate(kind)) == 0) {
+ removeTypeRate(kind, vehicleTypeId);
+ return;
+ }
+ typeRates.computeIfAbsent(kind, k -> new HashMap<>()).put(key(vehicleTypeId), clamped);
+ }
+
+ private void removeTypeRate(FeeKind kind, String vehicleTypeId) {
+ Map map = typeRates.get(kind);
+ if (map == null) {
+ return;
+ }
+ map.remove(key(vehicleTypeId));
+ if (map.isEmpty()) {
+ typeRates.remove(kind);
+ }
+ }
+
+ public void applyBracket(FeeKind kind, Bracket bracket) {
+ if (bracket == null) {
+ return;
+ }
+ brackets.put(kind, bracket);
+ rates.put(kind, clampTo(getRate(kind), bracket));
+ Map map = typeRates.get(kind);
+ if (map != null) {
+ map.replaceAll((type, rate) -> clampTo(rate, bracket));
+ }
+ dropTypeRatesEqualToGeneral(kind);
+ }
+
+ private void dropTypeRatesEqualToGeneral(FeeKind kind) {
+ Map map = typeRates.get(kind);
+ if (map == null) {
+ return;
+ }
+ double general = getRate(kind);
+ map.values().removeIf(rate -> Double.compare(rate, general) == 0);
+ if (map.isEmpty()) {
+ typeRates.remove(kind);
+ }
+ }
+
+ public Bracket getBracket(FeeKind kind) {
+ return brackets.get(kind);
+ }
+
+ /** Whether the current law lets the faction charge this at all. */
+ public boolean canCharge(FeeKind kind) {
+ Bracket bracket = brackets.get(kind);
+ return bracket != null && bracket.getMax() > 0.0 && f.hasFactionRule(kind.getRule());
+ }
+
+ public double getMin(FeeKind kind) {
+ Bracket bracket = brackets.get(kind);
+ return canCharge(kind) ? bracket.getMin() : 0.0;
+ }
+
+ public double getMax(FeeKind kind) {
+ Bracket bracket = brackets.get(kind);
+ return canCharge(kind) ? bracket.getMax() : 0.0;
+ }
+
+ /** The rate actually charged: 0 unless the law allows the charge. */
+ public double getChargedRate(FeeKind kind, String vehicleTypeId) {
+ if (!canCharge(kind)) {
+ return 0.0;
+ }
+ return clamp(kind, getRate(kind, vehicleTypeId));
+ }
+
+ private double clamp(FeeKind kind, double rate) {
+ Bracket bracket = brackets.get(kind);
+ if (bracket == null) {
+ return Math.max(0.0, rate);
+ }
+ return clampTo(rate, bracket);
+ }
+
+ private static double clampTo(double rate, Bracket bracket) {
+ return Math.max(bracket.getMin(), Math.min(bracket.getMax(), rate));
+ }
+
+ private static String key(String vehicleTypeId) {
+ return vehicleTypeId.toLowerCase(Locale.ROOT);
+ }
+
+ // Persistence
+
+ public Map serializeRates() {
+ Map out = new HashMap<>();
+ for (Map.Entry entry : rates.entrySet()) {
+ out.put(entry.getKey().name(), entry.getValue());
+ }
+ return out;
+ }
+
+ public Map> serializeTypeRates() {
+ Map> out = new HashMap<>();
+ for (Map.Entry> entry : typeRates.entrySet()) {
+ out.put(entry.getKey().name(), new HashMap<>(entry.getValue()));
+ }
+ return out;
+ }
+
+ /** Loads saved rates and re-clamps them, since the law's bracket may have changed in config. */
+ public void load(Map savedRates, Map> savedTypeRates) {
+ if (savedRates != null) {
+ for (Map.Entry entry : savedRates.entrySet()) {
+ FeeKind kind = parse(entry.getKey());
+ if (kind != null && entry.getValue() != null) {
+ rates.put(kind, entry.getValue());
+ }
+ }
+ }
+ if (savedTypeRates != null) {
+ for (Map.Entry> entry : savedTypeRates.entrySet()) {
+ FeeKind kind = parse(entry.getKey());
+ if (kind == null || entry.getValue() == null) {
+ continue;
+ }
+ for (Map.Entry typeEntry : entry.getValue().entrySet()) {
+ if (typeEntry.getKey() != null && typeEntry.getValue() != null) {
+ typeRates.computeIfAbsent(kind, k -> new HashMap<>())
+ .put(key(typeEntry.getKey()), typeEntry.getValue());
+ }
+ }
+ }
+ }
+ for (Map.Entry entry : new EnumMap<>(brackets).entrySet()) {
+ applyBracket(entry.getKey(), entry.getValue());
+ }
+ }
+
+ private static FeeKind parse(String name) {
+ if (name == null) {
+ return null;
+ }
+ try {
+ return FeeKind.valueOf(name);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java
new file mode 100644
index 00000000..bfdcc014
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java
@@ -0,0 +1,53 @@
+package net.tfminecraft.simplefactions.objects.request;
+
+import java.util.UUID;
+
+import net.tfminecraft.simplefactions.guild.Guild;
+import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader;
+
+/** A player offering one of their personal vehicles to another player. */
+public final class VehicleHandoverRequest extends Request {
+ private final String vehicleUuid;
+ private final String vehicleTypeId;
+ private final UUID ownerUuid;
+ private final String ownerName;
+ private final UUID recipientUuid;
+
+ /** The sender guild is null when the owner is in no faction. */
+ public VehicleHandoverRequest(
+ Guild sender,
+ 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.time = System.currentTimeMillis()
+ + InstallationConfigLoader.getTransferRequestTimeoutSeconds() * 1000L;
+ }
+
+ public String getVehicleUuid() {
+ return vehicleUuid;
+ }
+
+ public String getVehicleTypeId() {
+ return vehicleTypeId;
+ }
+
+ public UUID getOwnerUuid() {
+ return ownerUuid;
+ }
+
+ public String getOwnerName() {
+ return ownerName;
+ }
+
+ public UUID getRecipientUuid() {
+ return recipientUuid;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/player/income/PlayerCashflow.java b/src/main/java/net/tfminecraft/simplefactions/player/income/PlayerCashflow.java
index d2c03d93..ef0eb4fd 100644
--- a/src/main/java/net/tfminecraft/simplefactions/player/income/PlayerCashflow.java
+++ b/src/main/java/net/tfminecraft/simplefactions/player/income/PlayerCashflow.java
@@ -5,7 +5,9 @@ public enum PlayerCashflow {
CITIZEN_TAX("#94b572Citizen Tax"),
DIVIDEND_PAYOUT("#c49e5cDividend Payout"),
WAGES("#c9a05eWages"),
- VEHICLE_UPKEEP("#a6659fVehicles");
+ VEHICLE_UPKEEP("#a6659fVehicles"),
+ VEHICLE_TAX("#a6659fVehicle Tax"),
+ VEHICLE_FEES("#a6659fVehicle Fees");
private final String display;
diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/BracketToTaxTarget.java b/src/main/java/net/tfminecraft/simplefactions/utils/BracketToTaxTarget.java
index 8e5aefbf..e7aa77f8 100644
--- a/src/main/java/net/tfminecraft/simplefactions/utils/BracketToTaxTarget.java
+++ b/src/main/java/net/tfminecraft/simplefactions/utils/BracketToTaxTarget.java
@@ -19,6 +19,7 @@ public static TaxTarget convert(Brackets bracket) {
default:
break;
}
- return TaxTarget.CITIZENS;
+ // Vehicle fee brackets have no tax target; VehicleFeeHandler applies them.
+ return null;
}
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java b/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java
index a44ee635..0371231b 100644
--- a/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java
+++ b/src/main/java/net/tfminecraft/simplefactions/utils/LoreWriter.java
@@ -17,6 +17,7 @@
import net.tfminecraft.simplefactions.enums.Rules;
import net.tfminecraft.simplefactions.enums.Scope;
import net.tfminecraft.simplefactions.government.movement.Action;
+import net.tfminecraft.simplefactions.government.proposal.FeeProposalText;
import net.tfminecraft.simplefactions.government.proposal.Proposal;
import net.tfminecraft.simplefactions.government.proposal.TaxLawChange;
import net.tfminecraft.simplefactions.government.proposal.TaxTarget;
@@ -86,6 +87,8 @@ public static void applyProposalLore(Proposal proposal, List lore, Playe
} else {
EconomicImpact.applyTaxImpact(lore, p, f, target, taxChange.getId(), taxChange.getNewTax(), false, meta, false);
}
+ } else if(proposal.isFeeProposal()) {
+ lore.addAll(FeeProposalText.lines(f, proposal.getFeeChange()));
} else if(proposal.isPoliticalActionProposal()) {
Action action = proposal.getPoliticalAction().getAction();
lore.add(StringFormatter.formatHex("#b8ae61Action: #c2bea7"+action.getDisplay()));
diff --git a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java
index 6315b3bb..202689a4 100644
--- a/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java
+++ b/src/main/java/net/tfminecraft/simplefactions/utils/TabCompletion.java
@@ -236,7 +236,7 @@ else if(cmd.getName().equalsIgnoreCase("faction")
return net.tfminecraft.simplefactions.vehicles.VehicleFactionCommands.VehicleTabCompletions.subcommands(
args.length >= 2 ? args[1] : "");
}
- if(args.length == 3 && args[1].equalsIgnoreCase("give")) {
+ if(args.length == 3 && (args[1].equalsIgnoreCase("give") || args[1].equalsIgnoreCase("handover"))) {
List names = new ArrayList<>();
for(Player online : Bukkit.getOnlinePlayers()) {
names.add(online.getName());
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java
index 2e546268..f16c73f5 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleFactionCommands.java
@@ -25,6 +25,8 @@
import net.tfminecraft.simplefactions.SimpleFactions;
import net.tfminecraft.simplefactions.utils.Permissions;
import net.tfminecraft.simplefactions.installation.Installation;
+import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverMessages;
+import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverSessionManager;
public final class VehicleFactionCommands {
private VehicleFactionCommands() {}
@@ -75,6 +77,7 @@ public static void armTake(Player player) {
long timeoutMillis = InstallationConfigLoader.getTransferRequestTimeoutSeconds() * 1000L;
plugin.getVehicleTransferSessionManager().clear(player.getUniqueId());
plugin.getVehicleMaintenancePaySessionManager().clear(player.getUniqueId());
+ plugin.getVehicleHandoverSessionManager().clear(player.getUniqueId());
plugin.getVehicleReleaseSessionManager().put(
player.getUniqueId(),
new VehicleReleaseSession(Kind.TAKE, System.currentTimeMillis() + timeoutMillis));
@@ -104,6 +107,7 @@ public static void armGive(Player player, String targetName) {
long timeoutMillis = InstallationConfigLoader.getTransferRequestTimeoutSeconds() * 1000L;
plugin.getVehicleTransferSessionManager().clear(player.getUniqueId());
plugin.getVehicleMaintenancePaySessionManager().clear(player.getUniqueId());
+ plugin.getVehicleHandoverSessionManager().clear(player.getUniqueId());
plugin.getVehicleReleaseSessionManager().put(
player.getUniqueId(),
new VehicleReleaseSession(
@@ -114,9 +118,38 @@ public static void armGive(Player player, String targetName) {
player.sendMessage(FactionVehicleReleaseMessages.giveArmed(target.getName()));
}
+ /** Any player can hand one of their personal vehicles to another online player. */
+ public static void armHandover(Player player, String targetName) {
+ if (targetName == null || targetName.isBlank()) {
+ player.sendMessage(VehicleHandoverMessages.usage());
+ return;
+ }
+ if (targetName.equalsIgnoreCase(player.getName())) {
+ player.sendMessage(VehicleHandoverMessages.self());
+ return;
+ }
+ Player target = Bukkit.getPlayerExact(targetName);
+ if (target == null || !target.isOnline()) {
+ player.sendMessage(VehicleHandoverMessages.recipientOffline(targetName));
+ return;
+ }
+ SimpleFactions plugin = SimpleFactions.getInstance();
+ long timeoutMillis = InstallationConfigLoader.getTransferRequestTimeoutSeconds() * 1000L;
+ clearOtherVehicleSessions(plugin, player);
+ plugin.getVehicleTransferSessionManager().clear(player.getUniqueId());
+ plugin.getVehicleHandoverSessionManager().put(
+ player.getUniqueId(),
+ new VehicleHandoverSessionManager.Session(
+ target.getName(),
+ target.getUniqueId(),
+ System.currentTimeMillis() + timeoutMillis));
+ player.sendMessage(VehicleHandoverMessages.armed(target.getName()));
+ }
+
private static void clearOtherVehicleSessions(SimpleFactions plugin, Player player) {
plugin.getVehicleMaintenancePaySessionManager().clear(player.getUniqueId());
plugin.getVehicleReleaseSessionManager().clear(player.getUniqueId());
+ plugin.getVehicleHandoverSessionManager().clear(player.getUniqueId());
}
private static void armPoolTransfer(Player player) {
@@ -142,6 +175,7 @@ public static void armMaintenancePay(Player player, PaymentSource source) {
long timeoutMillis = InstallationConfigLoader.getTransferRequestTimeoutSeconds() * 1000L;
plugin.getVehicleTransferSessionManager().clear(player.getUniqueId());
plugin.getVehicleReleaseSessionManager().clear(player.getUniqueId());
+ plugin.getVehicleHandoverSessionManager().clear(player.getUniqueId());
plugin.getVehicleMaintenancePaySessionManager().put(
player.getUniqueId(),
new VehicleMaintenancePaySession(System.currentTimeMillis() + timeoutMillis, source));
@@ -220,6 +254,20 @@ public static boolean isTake(String[] args) {
&& args[1].equalsIgnoreCase("take");
}
+ /**
+ * Target name for a handover command, empty string if the name is missing,
+ * or null if args are not a handover command.
+ */
+ public static String handoverTarget(String[] args) {
+ if (args == null
+ || args.length < 2
+ || !args[0].equalsIgnoreCase("vehicle")
+ || !args[1].equalsIgnoreCase("handover")) {
+ return null;
+ }
+ return args.length >= 3 ? args[2] : "";
+ }
+
/**
* Target name for a give command, empty string if the name is missing,
* or null if args are not a give command.
@@ -239,7 +287,7 @@ public static final class VehicleTabCompletions {
private VehicleTabCompletions() {}
public static List subcommands(String prefix) {
- return filter(List.of("transfer", "take", "give", "maintenance"), prefix);
+ return filter(List.of("transfer", "take", "give", "handover", "maintenance"), prefix);
}
public static List maintenanceActions(String prefix) {
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleIntegrationListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleIntegrationListener.java
index 8cd1f3d0..6ffd6b0f 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleIntegrationListener.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/VehicleIntegrationListener.java
@@ -96,7 +96,7 @@ private static String resolveOwnerEntry(UUID constructorUuid, Player onlineConst
return "player_" + constructorUuid;
}
- private static String resolveVehicleTypeId(Blueprint blueprint) {
+ public static String resolveVehicleTypeId(Blueprint blueprint) {
Vehicle vehicle = blueprint.getVehicle();
if (vehicle != null && vehicle.getId() != null && !vehicle.getId().isEmpty()) {
return vehicle.getId();
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 3a7375ae..42361fc3 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleGiveService.java
@@ -3,6 +3,7 @@
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
+import net.tfminecraft.simplefactions.SimpleFactions;
import net.tfminecraft.simplefactions.managers.FactionManager;
import net.tfminecraft.simplefactions.managers.RequestManager;
import net.tfminecraft.simplefactions.objects.Faction;
@@ -72,6 +73,10 @@ public void acceptRequest(Player recipient) {
}
return;
}
+ SimpleFactions plugin = SimpleFactions.getInstance();
+ if (plugin != null) {
+ plugin.recordVehicleOwner(req.getVehicleUuid(), recipient.getName());
+ }
recipient.sendMessage(FactionVehicleReleaseMessages.giveSuccessRecipient(req.getLeaderName()));
notifyLeader(req, FactionVehicleReleaseMessages.giveSuccessLeader(recipient.getName()));
}
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 6254f3b0..2a13563e 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseListener.java
@@ -5,6 +5,7 @@
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
+import net.tfminecraft.simplefactions.SimpleFactions;
import net.tfminecraft.simplefactions.managers.FactionManager;
import net.tfminecraft.simplefactions.objects.Faction;
import net.tfminecraft.simplefactions.vehicles.berth.FactionVehicleReleaseService.Outcome;
@@ -59,6 +60,10 @@ public void onVehiclePreInteract(VehiclePreInteractEvent event) {
}
Outcome outcome = releaseService.take(faction, leader.getName(), vehicle.getUUID());
+ SimpleFactions plugin = SimpleFactions.getInstance();
+ if (outcome.status() == Status.OK && plugin != null) {
+ plugin.recordVehicleOwner(vehicle.getUUID(), leader.getName());
+ }
String message = FactionVehicleReleaseMessages.forTake(outcome);
if (message != null) {
leader.sendMessage(message);
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 66aac0d5..23b86ead 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/berth/FactionVehicleReleaseService.java
@@ -185,7 +185,7 @@ private static Outcome outcome(Status status, CanBuildResult slotFailure, String
return new Outcome(status, slotFailure, vehicleTypeId);
}
- static boolean assignFrameworkOwner(String vehicleUuid, String playerName) {
+ public static boolean assignFrameworkOwner(String vehicleUuid, String playerName) {
if (vehicleUuid == null || vehicleUuid.isBlank() || playerName == null || playerName.isBlank()) {
return false;
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeConfirmations.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeConfirmations.java
new file mode 100644
index 00000000..aa6f2712
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeConfirmations.java
@@ -0,0 +1,40 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Remembers the charge a player was last shown, so repeating the same action within the
+ * window confirms it. Anything else in between asks again.
+ */
+public final class VehicleFeeConfirmations {
+ public static final long WINDOW_MILLIS = 30_000L;
+
+ private record Pending(String target, double amount, long expiresAtMillis) {}
+
+ private final Map pending = new HashMap<>();
+
+ /** True, and cleared, when this player was shown this charge and has not let it lapse. */
+ public boolean confirm(UUID playerUuid, String target, double amount, long nowMillis) {
+ Pending found = playerUuid == null ? null : pending.get(playerUuid);
+ if (found == null || nowMillis >= found.expiresAtMillis()
+ || !found.target().equals(target) || Double.compare(found.amount(), amount) != 0) {
+ return false;
+ }
+ pending.remove(playerUuid);
+ return true;
+ }
+
+ public void ask(UUID playerUuid, String target, double amount, long nowMillis) {
+ if (playerUuid != null && target != null) {
+ pending.put(playerUuid, new Pending(target, amount, nowMillis + WINDOW_MILLIS));
+ }
+ }
+
+ public void clear(UUID playerUuid) {
+ if (playerUuid != null) {
+ pending.remove(playerUuid);
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java
new file mode 100644
index 00000000..87769e3a
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java
@@ -0,0 +1,66 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import net.tfminecraft.simplefactions.utils.Formatter;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote;
+
+public final class VehicleFeeMessages {
+ private VehicleFeeMessages() {}
+
+ private static String amount(double amount) {
+ return "§e" + Formatter.formatMoney(amount) + "d";
+ }
+
+ private static String charge(Quote quote) {
+ return amount(quote.amount()) + " §7(" + quote.kind().formatRate(quote.rate()) + ", "
+ + quote.faction().getName() + "§7)";
+ }
+
+ public static String registrationConfirm(Quote quote, String vehicleTypeId) {
+ return "§6Registering a §e" + vehicleTypeId + " §6costs " + charge(quote)
+ + "§6. Left-click again to confirm and pay from your bank.";
+ }
+
+ public static String registrationPaid(Quote quote) {
+ return "§aPaid a registration fee of " + amount(quote.amount()) + "§a.";
+ }
+
+ public static String registrationUnaffordable(Quote quote) {
+ return "§cYou cannot afford the registration fee of " + amount(quote.amount())
+ + " §cfrom your bank.";
+ }
+
+ public static String registrationRefunded(double amount) {
+ return "§aYour construction was cancelled, so " + amount(amount)
+ + " §aof registration fee was refunded to your bank.";
+ }
+
+ public static String registrationRefundShort(double amount) {
+ return "§c" + amount(amount) + " §cof your registration fee could not be refunded. Ask staff to look into it.";
+ }
+
+ public static String claimConfirm(Quote quote, String lastOwner) {
+ return "§6This vehicle was last owned by §e" + lastOwner + "§6, so claiming it is a transfer. "
+ + "The transfer fee is " + charge(quote) + "§6. Right-click again to confirm and pay from your bank.";
+ }
+
+ public static String claimUnaffordable(Quote quote) {
+ return "§cYou cannot afford the transfer fee of " + amount(quote.amount()) + " §cfrom your bank.";
+ }
+
+ public static String claimPaid(Quote quote) {
+ return "§aPaid a transfer fee of " + amount(quote.amount()) + "§a.";
+ }
+
+ 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.";
+ }
+
+ public static String handoverUnaffordable(Quote quote) {
+ return "§cYou cannot afford the transfer fee of " + amount(quote.amount()) + " §cfrom your bank.";
+ }
+
+ public static String handoverPaid(Quote quote) {
+ return "§aPaid a transfer fee of " + amount(quote.amount()) + "§a.";
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java
new file mode 100644
index 00000000..18605077
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java
@@ -0,0 +1,124 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import java.util.UUID;
+import java.util.function.Function;
+
+import net.tfminecraft.simplefactions.SimpleFactions;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader;
+import net.tfminecraft.simplefactions.managers.FactionManager;
+import net.tfminecraft.simplefactions.objects.Bank;
+import net.tfminecraft.simplefactions.objects.Faction;
+import net.tfminecraft.simplefactions.player.income.PlayerCashflow;
+import net.tfminecraft.simplefactions.player.income.PlayerLedger;
+import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank;
+import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank;
+
+/**
+ * Prices and collects the vehicle tax law's charges. The payer's own faction sets the rate
+ * and receives the money; players with no faction and faction leaders pay nothing.
+ */
+public final class VehicleFeeService {
+ /** A priced charge. Only built when there is something to pay. */
+ public record Quote(FeeKind kind, String vehicleTypeId, Faction faction, double rate, double amount) {}
+
+ private static final Function MEMBER_FACTION =
+ name -> FactionManager.factions == null ? null : FactionManager.getByMember(name);
+ private static Function factionLookup = MEMBER_FACTION;
+ private static PlayerBank playerBank = DenarEconomyPlayerBank.INSTANCE;
+
+ private VehicleFeeService() {}
+
+ public static void setForTests(Function lookup, PlayerBank bank) {
+ factionLookup = lookup == null ? MEMBER_FACTION : lookup;
+ playerBank = bank == null ? DenarEconomyPlayerBank.INSTANCE : bank;
+ }
+
+ /** The charge this player owes for this vehicle type, or null when they owe nothing. */
+ public static Quote quote(FeeKind kind, String payerName, String vehicleTypeId) {
+ if (kind == null || payerName == null || payerName.isBlank() || vehicleTypeId == null) {
+ return null;
+ }
+ Faction faction = factionLookup.apply(payerName);
+ if (faction == null || isLeader(faction, payerName)) {
+ return null;
+ }
+ double rate = faction.getVehicleFeeHandler().getChargedRate(kind, vehicleTypeId);
+ double amount = kind.amount(rate, VehiclesConfigLoader.getUpkeep(vehicleTypeId));
+ if (amount <= 0.0) {
+ return null;
+ }
+ return new Quote(kind, vehicleTypeId, faction, rate, amount);
+ }
+
+ private static boolean isLeader(Faction faction, String playerName) {
+ return faction.getLeader() != null && faction.getLeader().equalsIgnoreCase(playerName);
+ }
+
+ /**
+ * Takes the quote from the payer's bank into the faction bank. Returns false, moving
+ * nothing, when the payer cannot cover it.
+ */
+ public static boolean collect(UUID payerUuid, Quote quote) {
+ if (payerUuid == null || quote == null) {
+ return false;
+ }
+ if (quote.faction().getBank() == null || !playerBank.withdrawFromBank(payerUuid, quote.amount())) {
+ return false;
+ }
+ credit(payerUuid, quote);
+ return true;
+ }
+
+ /** Pays an already withdrawn quote into the faction bank and records it on both ledgers. */
+ public static void credit(UUID payerUuid, Quote quote) {
+ Bank bank = quote.faction().getBank();
+ if (bank != null) {
+ bank.deposit(quote.amount());
+ }
+ quote.faction().getOrCreateMainGuild().getLedger().addVehicleFeeEntry(quote.amount());
+ recordPlayer(payerUuid, quote.kind(), -quote.amount());
+ }
+
+ /**
+ * Pays a collected charge back from the faction bank, as far as the bank covers it.
+ * Returns the denars refunded.
+ */
+ public static double refund(UUID payerUuid, String factionId, FeeKind kind, double amount) {
+ if (payerUuid == null || amount <= 0.0) {
+ return 0.0;
+ }
+ Faction faction = factionId == null ? null : FactionManager.getByString(factionId);
+ Bank bank = faction == null ? null : faction.getBank();
+ Double wealth = bank == null ? null : bank.getWealth();
+ if (wealth == null || wealth <= 0.0) {
+ return 0.0;
+ }
+ double refunded = Math.min(amount, wealth);
+ if (!playerBank.depositToBank(payerUuid, refunded)) {
+ return 0.0;
+ }
+ bank.withdraw(refunded);
+ faction.getOrCreateMainGuild().getLedger().addVehicleFeeEntry(-refunded);
+ recordPlayer(payerUuid, kind, refunded);
+ return refunded;
+ }
+
+ public static UUID resolve(String playerName) {
+ return playerBank.resolve(playerName);
+ }
+
+ public static double bankBalance(UUID playerUuid) {
+ return playerUuid == null ? 0.0 : playerBank.getBankBalance(playerUuid);
+ }
+
+ private static void recordPlayer(UUID payerUuid, FeeKind kind, double amount) {
+ if (SimpleFactions.getInstance() == null) {
+ return;
+ }
+ PlayerLedger ledger = SimpleFactions.getPlayerEconomyManager().getLedger(payerUuid);
+ if (ledger != null) {
+ ledger.add(kind == FeeKind.VEHICLE_TAX ? PlayerCashflow.VEHICLE_TAX : PlayerCashflow.VEHICLE_FEES, amount);
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java
new file mode 100644
index 00000000..b5970dc1
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java
@@ -0,0 +1,153 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Writer;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+/**
+ * State the vehicle fees need across restarts: who last owned each personal vehicle, so
+ * releasing one for a friend to claim is charged as a transfer, and the registration fees
+ * paid for constructions still in progress, so a cancelled build can be refunded.
+ */
+public final class VehicleFeeStore {
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+ /** A registration fee paid for a construction that has not finished yet. */
+ public record PaidBuild(UUID payerUuid, String factionId, double amount) {}
+
+ private final Map lastOwners = new HashMap<>();
+ private final Map paidBuilds = new HashMap<>();
+ private File file;
+
+ public String getLastOwner(String vehicleUuid) {
+ return vehicleUuid == null ? null : lastOwners.get(vehicleUuid);
+ }
+
+ public void setLastOwner(String vehicleUuid, String playerName) {
+ if (vehicleUuid == null || playerName == null || playerName.isBlank()) {
+ return;
+ }
+ lastOwners.put(vehicleUuid, playerName);
+ }
+
+ public void forgetVehicle(String vehicleUuid) {
+ if (vehicleUuid != null) {
+ lastOwners.remove(vehicleUuid);
+ }
+ }
+
+ public void putPaidBuild(String stationKey, PaidBuild build) {
+ if (stationKey != null && build != null) {
+ paidBuilds.put(stationKey, build);
+ }
+ }
+
+ public PaidBuild takePaidBuild(String stationKey) {
+ return stationKey == null ? null : paidBuilds.remove(stationKey);
+ }
+
+ // Persistence
+
+ public void bind(File cacheFolder) {
+ this.file = new File(cacheFolder, "vehicle_fees.json");
+ }
+
+ public void load() {
+ if (file == null || !file.exists()) {
+ return;
+ }
+ try (Reader reader = new FileReader(file)) {
+ Data data = GSON.fromJson(reader, Data.class);
+ if (data == null) {
+ return;
+ }
+ lastOwners.clear();
+ if (data.lastOwners != null) {
+ lastOwners.putAll(data.lastOwners);
+ }
+ paidBuilds.clear();
+ if (data.paidBuilds != null) {
+ for (Map.Entry entry : data.paidBuilds.entrySet()) {
+ PaidBuild build = entry.getValue() == null ? null : entry.getValue().toBuild();
+ if (build != null) {
+ paidBuilds.put(entry.getKey(), build);
+ }
+ }
+ }
+ } catch (IOException | RuntimeException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public void save() {
+ if (file == null || (lastOwners.isEmpty() && paidBuilds.isEmpty() && !file.exists())) {
+ return;
+ }
+ Data data = new Data();
+ data.lastOwners = new HashMap<>(lastOwners);
+ data.paidBuilds = new HashMap<>();
+ for (Map.Entry entry : paidBuilds.entrySet()) {
+ data.paidBuilds.put(entry.getKey(), BuildData.from(entry.getValue()));
+ }
+ // Written beside the file and moved into place, so an interrupted save keeps the old file.
+ File temp = new File(file.getParentFile(), file.getName() + ".tmp");
+ try (Writer writer = new FileWriter(temp)) {
+ GSON.toJson(data, writer);
+ } catch (IOException e) {
+ e.printStackTrace();
+ return;
+ }
+ try {
+ try {
+ Files.move(temp.toPath(), file.toPath(),
+ StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException e) {
+ Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private static final class Data {
+ Map lastOwners;
+ Map paidBuilds;
+ }
+
+ private static final class BuildData {
+ String payerUuid;
+ String factionId;
+ double amount;
+
+ static BuildData from(PaidBuild build) {
+ BuildData data = new BuildData();
+ data.payerUuid = build.payerUuid().toString();
+ data.factionId = build.factionId();
+ data.amount = build.amount();
+ return data;
+ }
+
+ PaidBuild toBuild() {
+ if (payerUuid == null || amount <= 0.0) {
+ return null;
+ }
+ try {
+ return new PaidBuild(UUID.fromString(payerUuid), factionId, amount);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java
new file mode 100644
index 00000000..7184ff59
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java
@@ -0,0 +1,96 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import org.bukkit.entity.Player;
+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.utils.Permissions;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote;
+import net.tfminecraft.vehicleframework.data.VehicleRemovePayload;
+import net.tfminecraft.vehicleframework.enums.VehicleRemoveReason;
+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.
+ */
+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) {
+ 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;
+ }
+ String lastOwner = store.getLastOwner(vehicle.getUUID());
+ if (lastOwner == null || lastOwner.equalsIgnoreCase(player.getName()) || Permissions.isAdmin(player)) {
+ return;
+ }
+ Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, lastOwner, vehicle.getId());
+ if (quote == null) {
+ return;
+ }
+ long now = System.currentTimeMillis();
+ String target = "claim:" + vehicle.getUUID();
+ 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, lastOwner));
+ return;
+ }
+ if (!VehicleFeeService.collect(VehicleRegistrationFeeListener.payerUuid(player), quote)) {
+ 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;
+ }
+ store.setLastOwner(event.getVehicle().getUUID(), event.getPlayer().getName());
+ saver.run();
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onVehicleRemove(VehicleRemoveEvent event) {
+ if (event.getVehicle() == null || !isDestroyed(event.getPayload())) {
+ return;
+ }
+ if (store.getLastOwner(event.getVehicle().getUUID()) != null) {
+ store.forgetVehicle(event.getVehicle().getUUID());
+ saver.run();
+ }
+ }
+
+ /** Chunk unloads also fire VehicleRemoveEvent; only a vehicle that is gone for good is forgotten. */
+ static boolean isDestroyed(VehicleRemovePayload payload) {
+ if (payload == null) {
+ return false;
+ }
+ if (payload.isDeath()) {
+ return true;
+ }
+ VehicleRemoveReason reason = payload.getRemoveReason().orElse(null);
+ return reason == VehicleRemoveReason.PLAYER_DESTROY || reason == VehicleRemoveReason.ADMIN_KILL;
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java
new file mode 100644
index 00000000..b2b8864e
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java
@@ -0,0 +1,130 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+
+import net.tfminecraft.simplefactions.SimpleFactions;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+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.registry.VehicleOwnershipQueries;
+import net.tfminecraft.vfbuilders.core.ActiveStation;
+import net.tfminecraft.vfbuilders.core.Blueprint;
+import net.tfminecraft.vfbuilders.events.BeginVehicleConstructionEvent;
+import net.tfminecraft.vfbuilders.events.VehicleConstructEvent;
+import net.tfminecraft.vfbuilders.events.VehicleConstructionCancelEvent;
+
+/**
+ * Charges the registration fee when a build starts, which is where the builder becomes the
+ * owner. The first placement click shows the fee; a second click pays it and starts the build.
+ * Needs VFBuilders 2.1.0 or later for the confirm click and refunds.
+ */
+public final class VehicleRegistrationFeeListener implements Listener {
+ private final VehicleFeeStore store;
+ private final VehicleFeeConfirmations confirmations;
+ private final Runnable saver;
+
+ public VehicleRegistrationFeeListener(VehicleFeeStore store, VehicleFeeConfirmations confirmations, Runnable saver) {
+ this.store = store;
+ this.confirmations = confirmations;
+ this.saver = saver;
+ }
+
+ // After the slot-limit check (HIGH), so nobody pays for a build that is then refused.
+ @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
+ public void onBeginVehicleConstruction(BeginVehicleConstructionEvent event) {
+ Player player = event.getConstructor();
+ Blueprint blueprint = event.getBlueprint();
+ if (player == null || blueprint == null) {
+ return;
+ }
+ String vehicleTypeId = VehicleIntegrationListener.resolveVehicleTypeId(blueprint);
+ Quote quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, player.getName(), vehicleTypeId);
+ if (quote == null) {
+ return;
+ }
+ String stationKey = stationKey(event.getStation());
+ long now = System.currentTimeMillis();
+ String target = "build:" + stationKey + ":" + vehicleTypeId;
+ if (!confirmations.confirm(player.getUniqueId(), target, quote.amount(), now)) {
+ event.setCancelled(true);
+ event.setKeepPlacement(true);
+ confirmations.ask(player.getUniqueId(), target, quote.amount(), now);
+ player.sendMessage(VehicleFeeMessages.registrationConfirm(quote, vehicleTypeId));
+ return;
+ }
+ UUID payer = payerUuid(player);
+ if (!VehicleFeeService.collect(payer, quote)) {
+ event.setCancelled(true);
+ player.sendMessage(VehicleFeeMessages.registrationUnaffordable(quote));
+ return;
+ }
+ store.putPaidBuild(stationKey, new PaidBuild(payer, quote.faction().getId(), quote.amount()));
+ saver.run();
+ player.sendMessage(VehicleFeeMessages.registrationPaid(quote));
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onConstructionCancel(VehicleConstructionCancelEvent event) {
+ PaidBuild build = store.takePaidBuild(stationKey(event.getStation()));
+ if (build == null) {
+ return;
+ }
+ saver.run();
+ double refunded = VehicleFeeService.refund(
+ build.payerUuid(), build.factionId(), FeeKind.REGISTRATION_FEE, build.amount());
+ double shortfall = Formatter.formatDouble(build.amount() - refunded);
+ if (shortfall > 0.0 && SimpleFactions.getInstance() != null) {
+ // The faction bank may be short or the deposit may have failed; either way staff are told.
+ SimpleFactions.getInstance().getLogger().warning("Registration fee refund short by " + shortfall
+ + " for " + build.payerUuid() + " from faction " + build.factionId());
+ }
+ Player payer = Bukkit.getPlayer(build.payerUuid());
+ if (payer != null && payer.isOnline()) {
+ if (refunded > 0.0) {
+ payer.sendMessage(VehicleFeeMessages.registrationRefunded(refunded));
+ }
+ if (shortfall > 0.0) {
+ payer.sendMessage(VehicleFeeMessages.registrationRefundShort(shortfall));
+ }
+ }
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onVehicleConstruct(VehicleConstructEvent event) {
+ boolean changed = store.takePaidBuild(stationKey(event.getStation())) != null;
+ if (event.getVehicle() != null && event.getConstructorUuid() != null) {
+ String owner = event.getConstructor() != null
+ ? event.getConstructor().getName()
+ : VehicleOwnershipQueries.resolvePlayerName(event.getConstructorUuid());
+ if (owner != null) {
+ store.setLastOwner(event.getVehicle().getUUID(), owner);
+ changed = true;
+ }
+ }
+ if (changed) {
+ saver.run();
+ }
+ }
+
+ static UUID payerUuid(Player player) {
+ UUID resolved = VehicleFeeService.resolve(player.getName());
+ return resolved != null ? resolved : player.getUniqueId();
+ }
+
+ static String stationKey(ActiveStation station) {
+ Location loc = station == null ? null : station.getLocation();
+ if (loc == null || loc.getWorld() == null) {
+ return "unknown";
+ }
+ return loc.getWorld().getName() + ":" + loc.getBlockX() + ":" + loc.getBlockY() + ":" + loc.getBlockZ();
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VfBuildersCatalog.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VfBuildersCatalog.java
new file mode 100644
index 00000000..41b9ee78
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VfBuildersCatalog.java
@@ -0,0 +1,64 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.bukkit.Bukkit;
+import org.bukkit.inventory.ItemStack;
+
+import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader;
+import net.tfminecraft.simplefactions.vehicles.VehicleIntegrationListener;
+import net.tfminecraft.vfbuilders.core.Blueprint;
+import net.tfminecraft.vfbuilders.core.BlueprintCategory;
+import net.tfminecraft.vfbuilders.loaders.CategoryLoader;
+
+/**
+ * The vehicles players can build, grouped by VFBuilders blueprint category, for choosing
+ * which vehicle a fee proposal applies to. Categories listed in vehicles.yml under
+ * fee-excluded-categories are left out.
+ */
+public final class VfBuildersCatalog {
+ public record Entry(String vehicleTypeId, ItemStack icon) {}
+
+ public record Category(String id, ItemStack icon, List vehicles) {}
+
+ private VfBuildersCatalog() {}
+
+ public static List categories() {
+ List out = new ArrayList<>();
+ if (!Bukkit.getPluginManager().isPluginEnabled("VFBuilders")) {
+ return out;
+ }
+ for (BlueprintCategory category : CategoryLoader.get().values()) {
+ if (category == null || VehiclesConfigLoader.isFeeExcludedCategory(category.getId())) {
+ continue;
+ }
+ Map vehicles = new LinkedHashMap<>();
+ for (Blueprint blueprint : category.getBlueprints()) {
+ String typeId = VehicleIntegrationListener.resolveVehicleTypeId(blueprint);
+ if (typeId == null || !VehiclesConfigLoader.isKnownType(typeId)) {
+ continue;
+ }
+ ItemStack icon = blueprint.getItem() == null ? null : blueprint.getItem().clone();
+ vehicles.putIfAbsent(typeId.toLowerCase(), new Entry(typeId, icon));
+ }
+ if (vehicles.isEmpty()) {
+ continue;
+ }
+ ItemStack icon = category.getItem() == null ? null : category.getItem().clone();
+ out.add(new Category(category.getId(), icon, new ArrayList<>(vehicles.values())));
+ }
+ return out;
+ }
+
+ public static Category category(String id) {
+ for (Category category : categories()) {
+ if (category.id().equalsIgnoreCase(id)) {
+ return category;
+ }
+ }
+ return 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
new file mode 100644
index 00000000..9d7cc365
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java
@@ -0,0 +1,73 @@
+package net.tfminecraft.simplefactions.vehicles.handover;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+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.handover.VehicleHandoverService.Outcome;
+import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverService.Status;
+import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverSessionManager.Session;
+import net.tfminecraft.vehicleframework.events.VehiclePreInteractEvent;
+import net.tfminecraft.vehicleframework.vehicles.ActiveVehicle;
+
+/** The vehicle click after /faction vehicle handover. A transfer fee is shown before the offer goes out. */
+public final class VehicleHandoverListener implements Listener {
+ private final VehicleHandoverSessionManager sessions;
+ private final VehicleHandoverService service;
+ private final VehicleFeeConfirmations confirmations;
+
+ public VehicleHandoverListener(
+ VehicleHandoverSessionManager sessions,
+ VehicleHandoverService service,
+ VehicleFeeConfirmations confirmations) {
+ this.sessions = sessions;
+ this.service = service;
+ this.confirmations = confirmations;
+ }
+
+ @EventHandler
+ public void onVehiclePreInteract(VehiclePreInteractEvent event) {
+ Player owner = event.getPlayer();
+ Session session = owner == null ? null : sessions.get(owner.getUniqueId());
+ if (session == null) {
+ return;
+ }
+ event.setCancelled(true);
+ ActiveVehicle vehicle = event.getVehicle();
+ if (vehicle == null || vehicle.getUUID() == null) {
+ return;
+ }
+ Player recipient = Bukkit.getPlayerExact(session.recipientName());
+ if (recipient == null || !recipient.isOnline() || !recipient.getUniqueId().equals(session.recipientUuid())) {
+ owner.sendMessage(VehicleHandoverMessages.recipientOffline(session.recipientName()));
+ sessions.clear(owner.getUniqueId());
+ return;
+ }
+ Outcome outcome = service.evaluate(owner.getName(), recipient.getName(), vehicle.getUUID());
+ if (outcome.status() != Status.OK) {
+ owner.sendMessage(VehicleHandoverMessages.forOutcome(outcome, recipient.getName()));
+ if (outcome.status() != Status.NOT_OWNER) {
+ sessions.clear(owner.getUniqueId());
+ }
+ return;
+ }
+ Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, owner.getName(), outcome.vehicleTypeId());
+ if (quote != null) {
+ long now = System.currentTimeMillis();
+ String target = "handover:" + vehicle.getUUID() + ":" + recipient.getUniqueId();
+ if (!confirmations.confirm(owner.getUniqueId(), target, quote.amount(), now)) {
+ confirmations.ask(owner.getUniqueId(), target, quote.amount(), now);
+ owner.sendMessage(VehicleFeeMessages.handoverConfirm(quote, recipient.getName()));
+ return;
+ }
+ }
+ sessions.clear(owner.getUniqueId());
+ service.offer(owner, recipient, vehicle.getUUID(), outcome.vehicleTypeId());
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverMessages.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverMessages.java
new file mode 100644
index 00000000..5f167493
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverMessages.java
@@ -0,0 +1,62 @@
+package net.tfminecraft.simplefactions.vehicles.handover;
+
+import net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverService.Outcome;
+
+public final class VehicleHandoverMessages {
+ private VehicleHandoverMessages() {}
+
+ public static String usage() {
+ return "§cUsage: §e/faction vehicle handover ";
+ }
+
+ public static String self() {
+ return "§cYou already own your vehicles.";
+ }
+
+ public static String recipientOffline(String playerName) {
+ return "§c" + playerName + " must be online to receive a vehicle.";
+ }
+
+ public static String armed(String playerName) {
+ return "§aRight-click one of your personal vehicles to hand it over to " + playerName
+ + ". They must accept with /faction accept.";
+ }
+
+ public static String prompt(String ownerName, String vehicleTypeId) {
+ return "§e" + ownerName + " §awants to hand you their §e" + vehicleTypeId
+ + "§a. Type §e/faction accept §ato take it or §e/faction decline§a.";
+ }
+
+ public static String sent(String recipientName) {
+ return "§aHandover offered to " + recipientName + ". Waiting for them to accept.";
+ }
+
+ public static String expired() {
+ return "§cThe vehicle handover request expired.";
+ }
+
+ public static String successOwner(String recipientName) {
+ return "§aHanded the vehicle over to " + recipientName + ".";
+ }
+
+ public static String successRecipient(String ownerName) {
+ return "§aYou now own the vehicle " + ownerName + " handed over.";
+ }
+
+ public static String ownershipUnavailable() {
+ return "§cCould not hand over that vehicle. Try again while it is spawned.";
+ }
+
+ public static String forOutcome(Outcome outcome, String recipientName) {
+ if (outcome == null) {
+ return null;
+ }
+ return switch (outcome.status()) {
+ case OK -> null;
+ case NOT_OWNER -> "§cThat is not one of your personal vehicles.";
+ case IN_BATTLE -> "§cYou cannot hand over vehicles while your faction is in a battle.";
+ case UNKNOWN_TYPE -> "§cThis vehicle type is not registered for faction upkeep.";
+ case NO_ROOM -> "§c" + recipientName + " has reached their personal vehicle limit for that vehicle.";
+ };
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java
new file mode 100644
index 00000000..10494171
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java
@@ -0,0 +1,177 @@
+package net.tfminecraft.simplefactions.vehicles.handover;
+
+import java.util.UUID;
+import java.util.function.BiPredicate;
+
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.guild.Guild;
+import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader;
+import net.tfminecraft.simplefactions.managers.FactionManager;
+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;
+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.registry.PlayerVehicleRegistry;
+import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries;
+import net.tfminecraft.vehicleframework.data.OwnedVehicleSummary;
+
+/**
+ * Hands a personal vehicle from one player to another. The owner pays their faction's
+ * transfer fee when the recipient accepts; faction leaders and players with no faction
+ * pay nothing.
+ */
+public final class VehicleHandoverService {
+ public enum Status {
+ OK,
+ NOT_OWNER,
+ IN_BATTLE,
+ UNKNOWN_TYPE,
+ NO_ROOM
+ }
+
+ public record Outcome(Status status, CanBuildResult slotFailure, String vehicleTypeId) {}
+
+ private final PlayerVehicleRegistry registry;
+ private final VehicleFeeStore store;
+ private final Runnable saver;
+ private final BiPredicate ownerAssigner;
+
+ public VehicleHandoverService(PlayerVehicleRegistry registry, VehicleFeeStore store, Runnable saver) {
+ this(registry, store, saver, FactionVehicleReleaseService::assignFrameworkOwner);
+ }
+
+ VehicleHandoverService(
+ PlayerVehicleRegistry registry,
+ VehicleFeeStore store,
+ Runnable saver,
+ 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. */
+ public Outcome evaluate(String ownerName, String recipientName, String vehicleUuid) {
+ String typeId = personalVehicleType(ownerName, 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(ownerName))) {
+ return new Outcome(Status.IN_BATTLE, null, typeId);
+ }
+ CanBuildResult slot = VehicleSlotGuard.checkCanBuild(recipientName, 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)) {
+ return null;
+ }
+ for (OwnedVehicleSummary vehicle : VehicleOwnershipQueries.personalVehicles(ownerName, registry)) {
+ if (vehicleUuid.equals(vehicle.getUuid())) {
+ return vehicle.getTypeId();
+ }
+ }
+ return null;
+ }
+
+ public void offer(Player owner, Player recipient, String vehicleUuid, String vehicleTypeId) {
+ Guild guild = FactionManager.getGuildByMember(owner.getName());
+ VehicleHandoverRequest request = new VehicleHandoverRequest(
+ guild,
+ vehicleUuid,
+ vehicleTypeId,
+ owner.getUniqueId(),
+ owner.getName(),
+ recipient.getUniqueId());
+ RequestManager.addRequest(owner, recipient, request);
+ // addRequest refuses when the recipient is already considering another request.
+ if (RequestManager.getRequest(recipient) != request) {
+ return;
+ }
+ recipient.sendMessage(VehicleHandoverMessages.prompt(owner.getName(), vehicleTypeId));
+ owner.sendMessage(VehicleHandoverMessages.sent(recipient.getName()));
+ }
+
+ public void acceptRequest(Player recipient) {
+ if (!(RequestManager.getRequest(recipient) instanceof VehicleHandoverRequest req)) {
+ return;
+ }
+ if (!recipient.getUniqueId().equals(req.getRecipientUuid())) {
+ recipient.sendMessage("§cYou cannot accept this request.");
+ return;
+ }
+ if (req.timedOut()) {
+ notifyExpired(req, recipient);
+ return;
+ }
+ Outcome outcome = evaluate(req.getOwnerName(), recipient.getName(), req.getVehicleUuid());
+ if (outcome.status() != Status.OK) {
+ tellBoth(req, recipient, VehicleHandoverMessages.forOutcome(outcome, recipient.getName()));
+ return;
+ }
+ Quote quote = VehicleFeeService.quote(FeeKind.TRANSFER_FEE, req.getOwnerName(), outcome.vehicleTypeId());
+ UUID payer = null;
+ if (quote != null) {
+ payer = VehicleFeeService.resolve(req.getOwnerName());
+ if (payer == null) {
+ payer = req.getOwnerUuid();
+ }
+ if (!VehicleFeeService.collect(payer, quote)) {
+ tellBoth(req, recipient, VehicleFeeMessages.handoverUnaffordable(quote));
+ return;
+ }
+ }
+ if (!ownerAssigner.test(req.getVehicleUuid(), recipient.getName())) {
+ if (quote != null) {
+ VehicleFeeService.refund(payer, quote.faction().getId(), FeeKind.TRANSFER_FEE, quote.amount());
+ }
+ tellBoth(req, recipient, VehicleHandoverMessages.ownershipUnavailable());
+ return;
+ }
+ store.setLastOwner(req.getVehicleUuid(), recipient.getName());
+ saver.run();
+ recipient.sendMessage(VehicleHandoverMessages.successRecipient(req.getOwnerName()));
+ Player owner = Bukkit.getPlayer(req.getOwnerUuid());
+ if (owner != null && owner.isOnline()) {
+ if (quote != null) {
+ owner.sendMessage(VehicleFeeMessages.handoverPaid(quote));
+ }
+ owner.sendMessage(VehicleHandoverMessages.successOwner(recipient.getName()));
+ }
+ }
+
+ public void notifyExpired(VehicleHandoverRequest req, Player recipient) {
+ tellBoth(req, recipient, VehicleHandoverMessages.expired());
+ }
+
+ private static void tellBoth(VehicleHandoverRequest req, Player recipient, String message) {
+ if (message == null) {
+ return;
+ }
+ if (recipient != null && recipient.isOnline()) {
+ recipient.sendMessage(message);
+ }
+ Player owner = req.getOwnerUuid() == null ? null : Bukkit.getPlayer(req.getOwnerUuid());
+ if (owner != null && owner.isOnline()) {
+ owner.sendMessage(message);
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverSessionManager.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverSessionManager.java
new file mode 100644
index 00000000..72115c31
--- /dev/null
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverSessionManager.java
@@ -0,0 +1,33 @@
+package net.tfminecraft.simplefactions.vehicles.handover;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/** Owners who ran /faction vehicle handover and have not yet clicked a vehicle. */
+public final class VehicleHandoverSessionManager {
+ public record Session(String recipientName, UUID recipientUuid, long expiresAtMillis) {}
+
+ private final Map byOwnerUuid = new HashMap<>();
+
+ public void put(UUID ownerUuid, Session session) {
+ if (ownerUuid != null && session != null) {
+ byOwnerUuid.put(ownerUuid, session);
+ }
+ }
+
+ public Session get(UUID ownerUuid) {
+ Session session = ownerUuid == null ? null : byOwnerUuid.get(ownerUuid);
+ if (session != null && System.currentTimeMillis() >= session.expiresAtMillis()) {
+ byOwnerUuid.remove(ownerUuid);
+ return null;
+ }
+ return session;
+ }
+
+ public void clear(UUID ownerUuid) {
+ if (ownerUuid != null) {
+ byOwnerUuid.remove(ownerUuid);
+ }
+ }
+}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceMessages.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceMessages.java
index d1e22b75..87668169 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceMessages.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceMessages.java
@@ -60,7 +60,8 @@ public static String payUsage() {
public static String vehicleUsage() {
return "§cUsage: §e/faction vehicle transfer §7, §e/faction vehicle take§7, "
- + "§e/faction vehicle give §7, or §e/faction vehicle maintenance pay bank";
+ + "§e/faction vehicle give §7, §e/faction vehicle handover §7, "
+ + "or §e/faction vehicle maintenance pay bank";
}
public static String transferUsage() {
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 f8e0387f..f55b9c56 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepProjection.java
@@ -9,6 +9,9 @@
import net.tfminecraft.simplefactions.SimpleFactions;
import net.tfminecraft.simplefactions.player.income.PlayerCashflow;
import net.tfminecraft.simplefactions.player.income.PlayerLedger;
+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 {
@@ -34,6 +37,37 @@ public static double projectedDailyUpkeep(String playerName, PlayerVehicleRegist
return total;
}
+ /** Vehicle tax the player's faction will charge with tomorrow's upkeep. */
+ public static double projectedDailyTax(String playerName, PlayerVehicleRegistry registry) {
+ if (playerName == null || playerName.isBlank()) {
+ return 0.0;
+ }
+ double total = 0.0;
+ for (OwnedVehicleSummary vehicle :
+ VehicleOwnershipQueries.personalVehicles(playerName, registry)) {
+ VehicleFeeService.Quote quote =
+ VehicleFeeService.quote(FeeKind.VEHICLE_TAX, playerName, vehicle.getTypeId());
+ if (quote != null) {
+ total += quote.amount();
+ }
+ }
+ return Formatter.formatDouble(total);
+ }
+
+ /** Settled tax once the day has charged it, otherwise the projected tax. */
+ public static double displayVehicleTax(PlayerLedger ledger, UUID playerUuid) {
+ if (ledger == null) {
+ return 0.0;
+ }
+ double settled = ledger.getAmount(PlayerCashflow.VEHICLE_TAX);
+ if (settled != 0.0 || ledger.getAmount(PlayerCashflow.VEHICLE_UPKEEP) != 0.0 || playerUuid == null) {
+ return settled;
+ }
+ String playerName = VehicleOwnershipQueries.resolvePlayerName(playerUuid);
+ double projected = projectedDailyTax(playerName, SimpleFactions.getVehicleRegistry());
+ return projected > 0.0 ? -projected : 0.0;
+ }
+
public static double displayVehicleExpense(PlayerLedger ledger, UUID playerUuid) {
if (ledger == null) {
return 0.0;
@@ -56,6 +90,11 @@ public static double displayNetDaily(PlayerLedger ledger, UUID playerUuid) {
if (settledVehicle == 0.0 && displayVehicle < 0.0) {
net += displayVehicle;
}
+ double settledTax = ledger.getAmount(PlayerCashflow.VEHICLE_TAX);
+ double displayTax = displayVehicleTax(ledger, playerUuid);
+ if (settledTax == 0.0 && displayTax < 0.0) {
+ net += displayTax;
+ }
return net;
}
}
diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java
index 3c6f4ea4..03140b91 100644
--- a/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java
+++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepService.java
@@ -2,6 +2,9 @@
import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService;
import net.tfminecraft.simplefactions.vehicles.registry.OwnershipMode;
import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRecord;
@@ -67,7 +70,9 @@ public void processDailyUpkeep() {
markUnpaid(vehicle.getUuid(), vehicle.getTypeId(), null, upkeep, now);
continue;
}
- chargePlayer(playerUuid, upkeep, vehicle.getTypeId(), vehicle.getUuid(), now);
+ // Tax is charged with upkeep, so a player who misses one misses both.
+ Quote tax = VehicleFeeService.quote(FeeKind.VEHICLE_TAX, playerName, vehicle.getTypeId());
+ chargePlayer(playerUuid, upkeep, tax, vehicle.getTypeId(), vehicle.getUuid(), now);
}
chargeFactionVehicles(now);
}
@@ -118,7 +123,8 @@ public void warnBankShortfalls(Collection extends Player> players, int seconds
return;
}
for (Player player : players) {
- double upkeep = VehicleUpkeepProjection.projectedDailyUpkeep(player.getName(), registry);
+ double upkeep = VehicleUpkeepProjection.projectedDailyUpkeep(player.getName(), registry)
+ + VehicleUpkeepProjection.projectedDailyTax(player.getName(), registry);
if (upkeep <= 0.0) {
continue;
}
@@ -145,17 +151,22 @@ public void tickHourlyDecay() {
private void chargePlayer(
UUID playerUuid,
double upkeep,
+ Quote tax,
String vehicleTypeId,
String vehicleUuid,
long nowMillis) {
if (playerUuid == null || upkeep <= 0.0) {
return;
}
- if (!playerBank.withdrawFromBank(playerUuid, upkeep)) {
- markUnpaid(vehicleUuid, vehicleTypeId, playerUuid, upkeep, nowMillis);
+ double total = tax == null ? upkeep : Formatter.formatDouble(upkeep + tax.amount());
+ if (!playerBank.withdrawFromBank(playerUuid, total)) {
+ markUnpaid(vehicleUuid, vehicleTypeId, playerUuid, total, nowMillis);
return;
}
economyManager.getLedger(playerUuid).add(PlayerCashflow.VEHICLE_UPKEEP, -upkeep);
+ if (tax != null) {
+ VehicleFeeService.credit(playerUuid, tax);
+ }
maintenanceStore.clearUnpaid(vehicleUuid);
persistMaintenance();
}
diff --git a/src/main/resources/laws.yml b/src/main/resources/laws.yml
index 54f5da16..6ef6ea07 100644
--- a/src/main/resources/laws.yml
+++ b/src/main/resources/laws.yml
@@ -212,6 +212,84 @@ guild_tax:
guild_tax: "25-60"
dividend_tax: "45-80"
+vehicle_tax:
+ name: "#c9c99bVehicle Tax"
+ description:
+ - "#c9c99bVehicle taxes #d4c9aeare paid by"
+ - "#d4c9aemembers who own vehicles, on top"
+ - "#d4c9aeof upkeep, along with fees for"
+ - "#d4c9aebuilding and handing them over."
+ - "#d4c9aeThe faction leader is exempt."
+ laws:
+ none:
+ name: "#bdaea8None"
+ description:
+ - "#bdaea8No vehicle taxes #d4c9aeor fees"
+ - "#d4c9aeare charged."
+ cost: 10
+ upkeep: 0
+ compatibility:
+ - low 1
+ - medium 2
+ - high 3
+ effects:
+ faction:
+ rules:
+ - vehicle_tax false
+ - registration_fee false
+ - transfer_fee false
+ low:
+ name: "#7bc767Low"
+ description:
+ - "#7bc767Light charges #d4c9aeon vehicle"
+ - "#d4c9aeowners bring in a little income."
+ cost: 15
+ upkeep: 1
+ compatibility:
+ - none 1
+ - medium 1
+ - high 2
+ effects:
+ faction:
+ brackets:
+ vehicle_tax: "5-15"
+ registration_fee: "0-1"
+ transfer_fee: "0-0.5"
+ medium:
+ name: "#bfc267Medium"
+ description:
+ - "#bfc267Moderate charges #d4c9aemake"
+ - "#d4c9aevehicle owners pay their share."
+ cost: 20
+ upkeep: 2
+ compatibility:
+ - none 2
+ - low 1
+ - high 1
+ effects:
+ faction:
+ brackets:
+ vehicle_tax: "15-35"
+ registration_fee: "1-3"
+ transfer_fee: "0.5-1.5"
+ high:
+ name: "#d1775eHigh"
+ description:
+ - "#d1775eHeavy charges #d4c9aemake owning"
+ - "#d4c9aeand trading vehicles costly."
+ cost: 30
+ upkeep: 4
+ compatibility:
+ - none 3
+ - low 2
+ - medium 1
+ effects:
+ faction:
+ brackets:
+ vehicle_tax: "35-75"
+ registration_fee: "3-6"
+ transfer_fee: "1.5-3"
+
leadership:
name: "#c9c99bLeadership"
description:
diff --git a/src/main/resources/vehicles.yml b/src/main/resources/vehicles.yml
index 14edfd39..d583b3a9 100644
--- a/src/main/resources/vehicles.yml
+++ b/src/main/resources/vehicles.yml
@@ -7,6 +7,10 @@ maintenance-interval-ticks: 72000
default-upkeep: 4
default-per-person: 1
+# VFBuilders blueprint categories hidden from the vehicle fee proposal menu, such as
+# staff-only ones. Fees still apply to vehicles in them through the general rate.
+fee-excluded-categories: []
+
categories:
land_vehicles:
show-on-upcoming-battle-icon: true
diff --git a/src/test/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalPersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalPersistenceTest.java
new file mode 100644
index 00000000..9f94d217
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalPersistenceTest.java
@@ -0,0 +1,66 @@
+package net.tfminecraft.simplefactions.government.proposal;
+
+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.Mockito.mock;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import net.tfminecraft.simplefactions.government.Government;
+import net.tfminecraft.simplefactions.government.handler.ProposalHandler;
+import net.tfminecraft.simplefactions.objects.Faction;
+
+class FeeProposalPersistenceTest {
+ private final Government gov = mock(Government.class);
+
+ private Proposal fee(FeeKind kind, String type, double rate) {
+ Proposal proposal = new Proposal("Alice", gov);
+ proposal.setFeeProposal(new FeeChange(kind, type, rate));
+ return proposal;
+ }
+
+ @Test
+ void feeProposalsSurviveSaveAndLoad() {
+ ProposalHandler handler = new ProposalHandler(gov);
+ handler.propose(fee(FeeKind.VEHICLE_TAX, null, 12.5));
+ handler.propose(fee(FeeKind.TRANSFER_FEE, "cruiser", 2.0));
+ handler.propose(fee(FeeKind.REGISTRATION_FEE, "pack:ship", 1.5));
+
+ List saved = handler.serializeProposals();
+ ProposalHandler restored = new ProposalHandler(gov);
+ restored.restoreProposals(mock(Faction.class), saved);
+
+ assertEquals(3, restored.getProposals().size());
+ FeeChange colon = restored.getProposals().get(2).getFeeChange();
+ assertEquals("pack:ship", colon.getVehicleTypeId());
+ assertEquals(1.5, colon.getNewRate());
+ FeeChange general = restored.getProposals().get(0).getFeeChange();
+ assertEquals(FeeKind.VEHICLE_TAX, general.getKind());
+ assertNull(general.getVehicleTypeId());
+ assertEquals(12.5, general.getNewRate());
+ FeeChange cruiser = restored.getProposals().get(1).getFeeChange();
+ assertEquals(FeeKind.TRANSFER_FEE, cruiser.getKind());
+ assertEquals("cruiser", cruiser.getVehicleTypeId());
+ assertEquals(2.0, cruiser.getNewRate());
+ }
+
+ @Test
+ void onlyOneProposalPerFeeAndVehicle() {
+ ProposalHandler handler = new ProposalHandler(gov);
+ handler.propose(fee(FeeKind.REGISTRATION_FEE, "cruiser", 2.0));
+
+ assertFalse(handler.canBeProposed(fee(FeeKind.REGISTRATION_FEE, "Cruiser", 3.0)));
+ assertTrue(handler.canBeProposed(fee(FeeKind.REGISTRATION_FEE, null, 3.0)));
+ assertTrue(handler.canBeProposed(fee(FeeKind.TRANSFER_FEE, "cruiser", 3.0)));
+ }
+
+ @Test
+ void feeProposalsCountAsTaxChangesForMovements() {
+ assertEquals(net.tfminecraft.simplefactions.government.movement.Action.TAX_CHANGE,
+ fee(FeeKind.VEHICLE_TAX, null, 5).getPoliticalAction().getAction());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/VehicleFeeLedgerTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/VehicleFeeLedgerTest.java
new file mode 100644
index 00000000..6e907741
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/VehicleFeeLedgerTest.java
@@ -0,0 +1,35 @@
+package net.tfminecraft.simplefactions.guild.income;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.junit.jupiter.api.Test;
+
+import net.tfminecraft.simplefactions.guild.Guild;
+import net.tfminecraft.simplefactions.objects.Bank;
+import net.tfminecraft.simplefactions.objects.Faction;
+
+class VehicleFeeLedgerTest {
+ private static Ledger ledger(boolean base) {
+ Guild guild = mock(Guild.class);
+ when(guild.isBase()).thenReturn(base);
+ when(guild.getBank()).thenReturn(mock(Bank.class));
+ when(guild.getFaction()).thenReturn(mock(Faction.class));
+ Ledger ledger = new Ledger(guild);
+ when(guild.getLedger()).thenReturn(ledger);
+ return ledger;
+ }
+
+ @Test
+ void collectedFeesShowOnTheCapitalLedgerOnly() {
+ Ledger capital = ledger(true);
+ capital.addVehicleFeeEntry(30.0);
+ capital.addVehicleFeeEntry(-5.0);
+ assertEquals(25.0, capital.getIncome(Cashflow.VEHICLE_FEES));
+
+ Ledger guild = ledger(false);
+ guild.addVehicleFeeEntry(30.0);
+ assertEquals(0.0, guild.getIncome(Cashflow.VEHICLE_FEES));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/laws/VehicleTaxLawTest.java b/src/test/java/net/tfminecraft/simplefactions/laws/VehicleTaxLawTest.java
new file mode 100644
index 00000000..29ac1184
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/laws/VehicleTaxLawTest.java
@@ -0,0 +1,49 @@
+package net.tfminecraft.simplefactions.laws;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+import org.bukkit.configuration.ConfigurationSection;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.junit.jupiter.api.Test;
+
+import net.tfminecraft.simplefactions.enums.Brackets;
+import net.tfminecraft.simplefactions.enums.Scope;
+import net.tfminecraft.simplefactions.objects.Bracket;
+import net.tfminecraft.simplefactions.utils.BracketToTaxTarget;
+
+class VehicleTaxLawTest {
+ private static ConfigurationSection faction(String law) {
+ YamlConfiguration config = YamlConfiguration.loadConfiguration(new InputStreamReader(
+ VehicleTaxLawTest.class.getResourceAsStream("/laws.yml"), StandardCharsets.UTF_8));
+ return config.getConfigurationSection("vehicle_tax.laws." + law + ".effects.faction");
+ }
+
+ @Test
+ void shippedLawsSetAllThreeVehicleBrackets() {
+ for (String law : new String[] {"low", "medium", "high"}) {
+ LawEffect effect = new LawEffect(Scope.FACTION, faction(law));
+ for (Brackets bracket : new Brackets[] {
+ Brackets.VEHICLE_TAX, Brackets.REGISTRATION_FEE, Brackets.TRANSFER_FEE}) {
+ Bracket range = effect.getBrackets().get(bracket);
+ assertNotNull(range, law + " " + bracket);
+ }
+ // Paid from personal banks, so the law has no guild income preview.
+ assertFalse(effect.affectsEconomy(), law);
+ }
+ LawEffect low = new LawEffect(Scope.FACTION, faction("low"));
+ assertEquals(0.5, low.getBrackets().get(Brackets.TRANSFER_FEE).getMax());
+ }
+
+ @Test
+ void vehicleBracketsAreNotTaxTargets() {
+ assertNull(BracketToTaxTarget.convert(Brackets.VEHICLE_TAX));
+ assertNull(BracketToTaxTarget.convert(Brackets.REGISTRATION_FEE));
+ assertNull(BracketToTaxTarget.convert(Brackets.TRANSFER_FEE));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandlerTest.java b/src/test/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandlerTest.java
new file mode 100644
index 00000000..7e1b53ee
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandlerTest.java
@@ -0,0 +1,103 @@
+package net.tfminecraft.simplefactions.objects.handler;
+
+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.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import net.tfminecraft.simplefactions.enums.Rules;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.objects.Bracket;
+import net.tfminecraft.simplefactions.objects.Faction;
+
+class VehicleFeeHandlerTest {
+ private Faction faction;
+ private VehicleFeeHandler handler;
+
+ @BeforeEach
+ void setUp() {
+ faction = mock(Faction.class);
+ when(faction.hasFactionRule(any(Rules.class))).thenReturn(true);
+ handler = new VehicleFeeHandler(faction);
+ }
+
+ @Test
+ void nothingIsChargedBeforeALawSetsABracket() {
+ handler.setRate(FeeKind.REGISTRATION_FEE, null, 2.0);
+
+ assertFalse(handler.canCharge(FeeKind.REGISTRATION_FEE));
+ assertEquals(0.0, handler.getChargedRate(FeeKind.REGISTRATION_FEE, "ironclad"));
+ }
+
+ @Test
+ void bracketPullsTheGeneralRateInside() {
+ handler.applyBracket(FeeKind.VEHICLE_TAX, new Bracket(5, 15));
+ assertEquals(5.0, handler.getRate(FeeKind.VEHICLE_TAX));
+
+ handler.setRate(FeeKind.VEHICLE_TAX, null, 40.0);
+ assertEquals(15.0, handler.getRate(FeeKind.VEHICLE_TAX));
+
+ handler.applyBracket(FeeKind.VEHICLE_TAX, new Bracket(0, 10));
+ assertEquals(10.0, handler.getRate(FeeKind.VEHICLE_TAX));
+ }
+
+ @Test
+ void typeRateOverridesGeneralAndStaysInsideTheBracket() {
+ handler.applyBracket(FeeKind.TRANSFER_FEE, new Bracket(0, 3));
+ handler.setRate(FeeKind.TRANSFER_FEE, null, 1.0);
+ handler.setRate(FeeKind.TRANSFER_FEE, "Cruiser", 9.0);
+
+ assertEquals(3.0, handler.getChargedRate(FeeKind.TRANSFER_FEE, "cruiser"));
+ assertEquals(1.0, handler.getChargedRate(FeeKind.TRANSFER_FEE, "sloop"));
+ assertTrue(handler.hasTypeRate(FeeKind.TRANSFER_FEE, "CRUISER"));
+
+ handler.applyBracket(FeeKind.TRANSFER_FEE, new Bracket(0, 2));
+ assertEquals(2.0, handler.getRate(FeeKind.TRANSFER_FEE, "cruiser"));
+ }
+
+ @Test
+ void typeRateEqualToGeneralIsDropped() {
+ handler.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(0, 5));
+ handler.setRate(FeeKind.REGISTRATION_FEE, null, 1.0);
+ handler.setRate(FeeKind.REGISTRATION_FEE, "sloop", 2.0);
+ handler.setRate(FeeKind.REGISTRATION_FEE, "sloop", 1.0);
+ assertFalse(handler.hasTypeRate(FeeKind.REGISTRATION_FEE, "sloop"));
+
+ handler.setRate(FeeKind.REGISTRATION_FEE, "sloop", 2.0);
+ handler.setRate(FeeKind.REGISTRATION_FEE, null, 2.0);
+ assertFalse(handler.hasTypeRate(FeeKind.REGISTRATION_FEE, "sloop"));
+ }
+
+ @Test
+ void ruleSwitchedOffStopsTheCharge() {
+ handler.applyBracket(FeeKind.VEHICLE_TAX, new Bracket(5, 15));
+ when(faction.hasFactionRule(Rules.VEHICLE_TAX)).thenReturn(false);
+
+ assertFalse(handler.canCharge(FeeKind.VEHICLE_TAX));
+ assertEquals(0.0, handler.getMax(FeeKind.VEHICLE_TAX));
+ assertEquals(0.0, handler.getChargedRate(FeeKind.VEHICLE_TAX, "sloop"));
+ }
+
+ @Test
+ void savedRatesAreReclampedOnLoad() {
+ handler.applyBracket(FeeKind.VEHICLE_TAX, new Bracket(5, 15));
+ Map rates = new HashMap<>(Map.of("VEHICLE_TAX", 50.0, "UNKNOWN", 1.0));
+ Map> typeRates = new HashMap<>();
+ typeRates.put("VEHICLE_TAX", new HashMap<>(Map.of("sloop", 1.0, "cruiser", 12.0)));
+
+ handler.load(rates, typeRates);
+
+ assertEquals(15.0, handler.getRate(FeeKind.VEHICLE_TAX));
+ assertEquals(5.0, handler.getRate(FeeKind.VEHICLE_TAX, "sloop"));
+ assertEquals(12.0, handler.getRate(FeeKind.VEHICLE_TAX, "cruiser"));
+ assertEquals(Map.of("VEHICLE_TAX", 15.0), handler.serializeRates());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeConfirmationsTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeConfirmationsTest.java
new file mode 100644
index 00000000..e495d4e6
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeConfirmationsTest.java
@@ -0,0 +1,30 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+class VehicleFeeConfirmationsTest {
+ private final VehicleFeeConfirmations confirmations = new VehicleFeeConfirmations();
+ private final UUID player = UUID.randomUUID();
+
+ @Test
+ void secondActionWithinTheWindowConfirmsOnce() {
+ assertFalse(confirmations.confirm(player, "build:a", 10.0, 0));
+ confirmations.ask(player, "build:a", 10.0, 0);
+
+ assertTrue(confirmations.confirm(player, "build:a", 10.0, 1_000));
+ assertFalse(confirmations.confirm(player, "build:a", 10.0, 2_000));
+ }
+
+ @Test
+ void differentTargetPriceOrLapsedWindowAsksAgain() {
+ confirmations.ask(player, "build:a", 10.0, 0);
+ assertFalse(confirmations.confirm(player, "build:b", 10.0, 1_000));
+ assertFalse(confirmations.confirm(player, "build:a", 12.0, 1_000));
+ assertFalse(confirmations.confirm(player, "build:a", 10.0, VehicleFeeConfirmations.WINDOW_MILLIS));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java
new file mode 100644
index 00000000..55089c4d
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java
@@ -0,0 +1,174 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import net.tfminecraft.simplefactions.enums.Rules;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.guild.Guild;
+import net.tfminecraft.simplefactions.guild.income.Ledger;
+import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader;
+import net.tfminecraft.simplefactions.objects.Bank;
+import net.tfminecraft.simplefactions.objects.Bracket;
+import net.tfminecraft.simplefactions.objects.Faction;
+import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService.Quote;
+import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank;
+
+class VehicleFeeServiceTest {
+ private Path tempDir;
+ private Faction faction;
+ private VehicleFeeHandler handler;
+ private Bank factionBank;
+ private Ledger ledger;
+ private Guild guild;
+ private MapBank bank;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ tempDir = Files.createTempDirectory("sf-vehicle-fees-");
+ Path vehiclesYaml = tempDir.resolve("vehicles.yml");
+ Files.writeString(vehiclesYaml, """
+ categories:
+ ships:
+ cruiser:
+ upkeep: 40
+ size: 1
+ sloop:
+ upkeep: 8
+ size: 1
+ """);
+ VehiclesConfigLoader.load(vehiclesYaml.toFile());
+
+ faction = mock(Faction.class);
+ when(faction.hasFactionRule(any(Rules.class))).thenReturn(true);
+ when(faction.getLeader()).thenReturn("Leader");
+ when(faction.getId()).thenReturn("rome");
+ handler = new VehicleFeeHandler(faction);
+ when(faction.getVehicleFeeHandler()).thenReturn(handler);
+ factionBank = mock(Bank.class);
+ when(factionBank.getWealth()).thenReturn(1000.0);
+ when(faction.getBank()).thenReturn(factionBank);
+ guild = mock(Guild.class);
+ when(guild.isBase()).thenReturn(true);
+ ledger = new Ledger(guild);
+ when(guild.getLedger()).thenReturn(ledger);
+ when(faction.getOrCreateMainGuild()).thenReturn(guild);
+
+ bank = new MapBank();
+ Map members = new HashMap<>();
+ members.put("alice", faction);
+ members.put("leader", faction);
+ VehicleFeeService.setForTests(name -> members.get(name.toLowerCase()), bank);
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ VehicleFeeService.setForTests(null, null);
+ Files.walk(tempDir).sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
+ }
+
+ @Test
+ void taxIsAPercentageAndFeesAMultipleOfUpkeep() {
+ handler.applyBracket(FeeKind.VEHICLE_TAX, new Bracket(0, 50));
+ handler.setRate(FeeKind.VEHICLE_TAX, null, 25);
+ handler.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(0, 5));
+ handler.setRate(FeeKind.REGISTRATION_FEE, null, 1.5);
+
+ assertEquals(10.0, VehicleFeeService.quote(FeeKind.VEHICLE_TAX, "Alice", "cruiser").amount());
+ assertEquals(60.0, VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, "Alice", "cruiser").amount());
+ }
+
+ @Test
+ void leadersAndFactionlessPlayersOweNothing() {
+ handler.applyBracket(FeeKind.TRANSFER_FEE, new Bracket(1, 5));
+
+ assertNull(VehicleFeeService.quote(FeeKind.TRANSFER_FEE, "Leader", "cruiser"));
+ assertNull(VehicleFeeService.quote(FeeKind.TRANSFER_FEE, "Nomad", "cruiser"));
+ assertNotNull(VehicleFeeService.quote(FeeKind.TRANSFER_FEE, "Alice", "cruiser"));
+ }
+
+ @Test
+ void zeroRateOwesNothing() {
+ handler.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(0, 5));
+
+ assertNull(VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, "Alice", "cruiser"));
+ }
+
+ @Test
+ void collectMovesMoneyToTheFactionAndItsLedger() {
+ handler.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(2, 5));
+ UUID alice = UUID.randomUUID();
+ bank.balances.put(alice, 100.0);
+ Quote quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, "Alice", "cruiser");
+
+ assertTrue(VehicleFeeService.collect(alice, quote));
+
+ assertEquals(20.0, bank.balances.get(alice));
+ verify(factionBank).deposit(80.0);
+ assertEquals(80.0, ledger.getVehicleFeeIncome());
+ }
+
+ @Test
+ void collectMovesNothingWhenThePayerCannotCoverIt() {
+ handler.applyBracket(FeeKind.REGISTRATION_FEE, new Bracket(2, 5));
+ UUID alice = UUID.randomUUID();
+ bank.balances.put(alice, 50.0);
+ Quote quote = VehicleFeeService.quote(FeeKind.REGISTRATION_FEE, "Alice", "cruiser");
+
+ assertFalse(VehicleFeeService.collect(alice, quote));
+
+ assertEquals(50.0, bank.balances.get(alice));
+ verify(factionBank, never()).deposit(any());
+ assertEquals(0.0, ledger.getVehicleFeeIncome());
+ }
+
+ private static final class MapBank implements PlayerBank {
+ final Map balances = new HashMap<>();
+
+ @Override
+ public double getBankBalance(UUID playerUuid) {
+ return balances.getOrDefault(playerUuid, 0.0);
+ }
+
+ @Override
+ public boolean withdrawFromBank(UUID playerUuid, double amount) {
+ double balance = getBankBalance(playerUuid);
+ if (balance < amount) {
+ return false;
+ }
+ balances.put(playerUuid, balance - amount);
+ return true;
+ }
+
+ @Override
+ public boolean depositToBank(UUID playerUuid, double amount) {
+ balances.put(playerUuid, getBankBalance(playerUuid) + amount);
+ return true;
+ }
+
+ @Override
+ public UUID resolve(String playerName) {
+ return null;
+ }
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListenerTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListenerTest.java
new file mode 100644
index 00000000..fa18655f
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListenerTest.java
@@ -0,0 +1,24 @@
+package net.tfminecraft.simplefactions.vehicles.fees;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import net.tfminecraft.vehicleframework.data.VehicleRemovePayload;
+import net.tfminecraft.vehicleframework.enums.VehicleRemoveReason;
+
+class VehicleReclaimFeeListenerTest {
+ @Test
+ void chunkUnloadKeepsTheLastOwner() {
+ assertFalse(VehicleReclaimFeeListener.isDestroyed(VehicleRemovePayload.remove(VehicleRemoveReason.UNLOAD)));
+ assertFalse(VehicleReclaimFeeListener.isDestroyed(VehicleRemovePayload.remove(VehicleRemoveReason.GENERIC)));
+ assertFalse(VehicleReclaimFeeListener.isDestroyed(null));
+ }
+
+ @Test
+ void destroyedVehiclesAreForgotten() {
+ assertTrue(VehicleReclaimFeeListener.isDestroyed(VehicleRemovePayload.remove(VehicleRemoveReason.PLAYER_DESTROY)));
+ assertTrue(VehicleReclaimFeeListener.isDestroyed(VehicleRemovePayload.remove(VehicleRemoveReason.ADMIN_KILL)));
+ }
+}
diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverServiceTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverServiceTest.java
new file mode 100644
index 00000000..b5b7702f
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverServiceTest.java
@@ -0,0 +1,93 @@
+package net.tfminecraft.simplefactions.vehicles.handover;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+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;
+
+class VehicleHandoverServiceTest {
+ private Path tempDir;
+ private PlayerVehicleRegistry registry;
+ private VehicleHandoverService service;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ tempDir = Files.createTempDirectory("sf-vehicle-handover-");
+ Path vehiclesYaml = tempDir.resolve("vehicles.yml");
+ Files.writeString(vehiclesYaml, """
+ personal-slot-limit: 2
+ categories:
+ ships:
+ sloop:
+ upkeep: 8
+ size: 1
+ per-person: 1
+ gunboat:
+ upkeep: 3
+ size: 1
+ """);
+ VehiclesConfigLoader.load(vehiclesYaml.toFile());
+ registry = new PlayerVehicleRegistry();
+ service = new VehicleHandoverService(registry, new VehicleFeeStore(), () -> {}, (uuid, name) -> true);
+ }
+
+ @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"));
+
+ VehicleHandoverService.Outcome outcome = service.evaluate("Alice", "Bob", "v1");
+
+ assertEquals(Status.OK, outcome.status());
+ assertEquals("sloop", outcome.vehicleTypeId());
+ }
+
+ @Test
+ void someoneElsesVehicleIsRefused() {
+ VehicleOwnershipQueries.setSourceForTests(
+ new FakeOwnedInventory().add("v1", "sloop", "player_Carol"));
+
+ assertEquals(Status.NOT_OWNER, service.evaluate("Alice", "Bob", "v1").status());
+ }
+
+ @Test
+ void factionVehiclesOwnedByTheLeaderAreRefused() {
+ VehicleOwnershipQueries.setSourceForTests(
+ new FakeOwnedInventory().add("v1", "sloop", "player_Alice"));
+ registry.register(new PlayerVehicleRecord(
+ java.util.UUID.randomUUID(), "v1", "sloop", OwnershipMode.POOL, null, "rome"));
+
+ 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"));
+
+ 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/VehicleMaintenanceBankCommandTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceBankCommandTest.java
index ce733076..b11fc656 100644
--- a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceBankCommandTest.java
+++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleMaintenanceBankCommandTest.java
@@ -37,6 +37,8 @@ void ordinaryPlayerCanArmBankPayment() {
when(plugin.getVehicleMaintenancePaySessionManager()).thenReturn(sessions);
when(plugin.getVehicleTransferSessionManager()).thenReturn(transfers);
when(plugin.getVehicleReleaseSessionManager()).thenReturn(new VehicleReleaseSessionManager());
+ when(plugin.getVehicleHandoverSessionManager()).thenReturn(
+ new net.tfminecraft.simplefactions.vehicles.handover.VehicleHandoverSessionManager());
try (MockedStatic sf = mockStatic(SimpleFactions.class);
MockedStatic factions = mockStatic(FactionManager.class)) {
diff --git a/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java
new file mode 100644
index 00000000..b0d33fcd
--- /dev/null
+++ b/src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java
@@ -0,0 +1,162 @@
+package net.tfminecraft.simplefactions.vehicles.maintenance;
+
+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.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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Server;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+import net.tfminecraft.simplefactions.enums.Rules;
+import net.tfminecraft.simplefactions.government.proposal.FeeKind;
+import net.tfminecraft.simplefactions.guild.Guild;
+import net.tfminecraft.simplefactions.guild.income.Ledger;
+import net.tfminecraft.simplefactions.loaders.VehiclesConfigLoader;
+import net.tfminecraft.simplefactions.objects.Bank;
+import net.tfminecraft.simplefactions.objects.Bracket;
+import net.tfminecraft.simplefactions.objects.Faction;
+import net.tfminecraft.simplefactions.objects.handler.VehicleFeeHandler;
+import net.tfminecraft.simplefactions.player.PlayerEconomyManager;
+import net.tfminecraft.simplefactions.player.income.PlayerCashflow;
+import net.tfminecraft.simplefactions.vehicles.fees.VehicleFeeService;
+import net.tfminecraft.simplefactions.vehicles.maintenance.DenarEconomyPlayerBank.PlayerBank;
+import net.tfminecraft.simplefactions.vehicles.registry.FakeOwnedInventory;
+import net.tfminecraft.simplefactions.vehicles.registry.PlayerVehicleRegistry;
+import net.tfminecraft.simplefactions.vehicles.registry.VehicleOwnershipQueries;
+
+/** Vehicle tax is charged in the same withdrawal as upkeep. */
+class VehicleUpkeepTaxTest {
+ private Path tempDir;
+ private PlayerEconomyManager economyManager;
+ private MapBank bank;
+ private VehicleMaintenanceStore store;
+ private VehicleUpkeepService service;
+ private Bank factionBank;
+ private Ledger factionLedger;
+ private final UUID alice = UUID.randomUUID();
+
+ @BeforeEach
+ void setUp() throws IOException {
+ tempDir = Files.createTempDirectory("sf-vehicle-tax-");
+ Path vehiclesYaml = tempDir.resolve("vehicles.yml");
+ Files.writeString(vehiclesYaml, """
+ categories:
+ ships:
+ ironclad:
+ upkeep: 20
+ size: 1
+ """);
+ VehiclesConfigLoader.load(vehiclesYaml.toFile());
+
+ Faction faction = mock(Faction.class);
+ when(faction.hasFactionRule(any(Rules.class))).thenReturn(true);
+ when(faction.getLeader()).thenReturn("Leader");
+ VehicleFeeHandler handler = new VehicleFeeHandler(faction);
+ handler.applyBracket(FeeKind.VEHICLE_TAX, new Bracket(0, 100));
+ handler.setRate(FeeKind.VEHICLE_TAX, null, 50);
+ when(faction.getVehicleFeeHandler()).thenReturn(handler);
+ factionBank = mock(Bank.class);
+ when(faction.getBank()).thenReturn(factionBank);
+ Guild guild = mock(Guild.class);
+ when(guild.isBase()).thenReturn(true);
+ factionLedger = new Ledger(guild);
+ when(guild.getLedger()).thenReturn(factionLedger);
+ when(faction.getOrCreateMainGuild()).thenReturn(guild);
+
+ bank = new MapBank();
+ bank.names.put("alice", alice);
+ VehicleFeeService.setForTests(name -> name.equalsIgnoreCase("Alice") ? faction : null, bank);
+ economyManager = new PlayerEconomyManager();
+ store = new VehicleMaintenanceStore();
+ service = new VehicleUpkeepService(
+ new PlayerVehicleRegistry(), economyManager, bank, store, (uuid, fraction, min) -> true);
+ VehicleOwnershipQueries.setSourceForTests(
+ new FakeOwnedInventory().add("vehicle-1", "ironclad", "player_Alice"));
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ VehicleFeeService.setForTests(null, null);
+ VehicleOwnershipQueries.setSourceForTests(null);
+ Files.walk(tempDir).sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
+ }
+
+ @Test
+ void taxIsChargedWithUpkeepAndPaidToTheFaction() {
+ bank.balances.put(alice, 100.0);
+
+ runDay();
+
+ assertEquals(70.0, bank.balances.get(alice));
+ assertEquals(-20.0, economyManager.getLedger(alice).getAmount(PlayerCashflow.VEHICLE_UPKEEP));
+ verify(factionBank).deposit(10.0);
+ assertEquals(10.0, factionLedger.getVehicleFeeIncome());
+ assertFalse(store.isUnpaid("vehicle-1"));
+ }
+
+ @Test
+ void missingTheTaxMissesUpkeepToo() {
+ bank.balances.put(alice, 25.0);
+
+ runDay();
+
+ assertEquals(25.0, bank.balances.get(alice));
+ verify(factionBank, never()).deposit(any());
+ assertTrue(store.isUnpaid("vehicle-1"));
+ }
+
+ private void runDay() {
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class));
+ service.processDailyUpkeep();
+ }
+ }
+
+ private static final class MapBank implements PlayerBank {
+ final Map balances = new HashMap<>();
+ final Map names = new HashMap<>();
+
+ @Override
+ public double getBankBalance(UUID playerUuid) {
+ return balances.getOrDefault(playerUuid, 0.0);
+ }
+
+ @Override
+ public boolean withdrawFromBank(UUID playerUuid, double amount) {
+ double balance = getBankBalance(playerUuid);
+ if (balance < amount) {
+ return false;
+ }
+ balances.put(playerUuid, balance - amount);
+ return true;
+ }
+
+ @Override
+ public boolean depositToBank(UUID playerUuid, double amount) {
+ balances.put(playerUuid, getBankBalance(playerUuid) + amount);
+ return true;
+ }
+
+ @Override
+ public UUID resolve(String playerName) {
+ return playerName == null ? null : names.get(playerName.toLowerCase());
+ }
+ }
+}