From fd1d71cde438ce87a01a1e6412f7f0c0e1a6e492 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:40:49 +0000 Subject: [PATCH 1/4] Add a vehicle tax law group with registration and transfer fees A new vehicle_tax law group (none/low/medium/high) sets brackets for three charges, all priced off the vehicle's daily upkeep and paid from the member's own bank into their faction's bank. Faction leaders and players with no faction pay nothing. - Vehicle tax: a percentage of upkeep, charged in the same withdrawal as daily upkeep, so missing one misses both and the vehicle decays as before. - Registration fee: a multiple of upkeep, charged when a build starts at a VFBuilders station. The first placement click shows the fee and a second confirms it; a cancelled construction refunds it. - Transfer fee: a multiple of upkeep, paid by the owner when a new /faction vehicle handover is accepted. Releasing a vehicle for someone else to claim counts as a transfer and charges the claimant, while reclaiming your own vehicle (such as after a staff respawn) stays free. Rates can be set for every vehicle or per vehicle type through a new Vehicle Fee Proposal, which lists VFBuilders blueprint categories (fee-excluded-categories in vehicles.yml hides staff-only ones). Type rates stay inside the law's bracket. Collected fees show on the faction ledger as Vehicle Taxes & Fees and on the player ledger as Vehicle Tax and Vehicle Fees. Needs VFBuilders 2.1.0 for the confirm click and refunds; with an older VFBuilders, registration fees are off and a warning is logged. Co-Authored-By: Claude Opus 5.5 (1M context) --- pom.xml | 2 +- .../simplefactions/SimpleFactions.java | 87 +++++ .../simplefactions/database/Database.java | 8 + .../simplefactions/database/FactionData.java | 6 + .../simplefactions/database/GuildData.java | 1 + .../simplefactions/database/ProposalData.java | 13 +- .../simplefactions/enums/Brackets.java | 5 +- .../simplefactions/enums/Rules.java | 3 + .../simplefactions/enums/SFGUI.java | 3 + .../simplefactions/government/Government.java | 5 + .../government/handler/ProposalHandler.java | 26 ++ .../government/movement/Movement.java | 6 + .../movement/MovementOutcomeService.java | 2 +- .../government/proposal/FeeChange.java | 42 ++ .../government/proposal/FeeKind.java | 75 ++++ .../government/proposal/FeeProposalText.java | 37 ++ .../government/proposal/Proposal.java | 22 +- .../government/session/SessionReport.java | 10 + .../simplefactions/guild/Guild.java | 3 + .../simplefactions/guild/income/Cashflow.java | 4 + .../simplefactions/guild/income/Ledger.java | 32 ++ .../simplefactions/laws/LawEffect.java | 3 + .../loaders/VehiclesConfigLoader.java | 13 + .../managers/CommandManager.java | 6 + .../managers/FactionManager.java | 1 + .../managers/InventoryManager.java | 143 +++++-- .../managers/RequestManager.java | 11 + .../managers/inventory/FeeRateInput.java | 35 ++ .../managers/inventory/GovernmentCreator.java | 10 +- .../managers/inventory/GovernmentView.java | 5 + .../inventory/PlayerLedgerCreator.java | 2 + .../managers/inventory/VehicleFeeView.java | 369 ++++++++++++++++++ .../simplefactions/objects/Faction.java | 27 +- .../objects/handler/VehicleFeeHandler.java | 205 ++++++++++ .../request/VehicleHandoverRequest.java | 53 +++ .../player/income/PlayerCashflow.java | 4 +- .../utils/BracketToTaxTarget.java | 3 +- .../simplefactions/utils/LoreWriter.java | 3 + .../simplefactions/utils/TabCompletion.java | 2 +- .../vehicles/VehicleFactionCommands.java | 50 ++- .../vehicles/VehicleIntegrationListener.java | 2 +- .../berth/FactionVehicleGiveService.java | 5 + .../berth/FactionVehicleReleaseListener.java | 5 + .../berth/FactionVehicleReleaseService.java | 2 +- .../fees/VehicleFeeConfirmations.java | 40 ++ .../vehicles/fees/VehicleFeeMessages.java | 62 +++ .../vehicles/fees/VehicleFeeService.java | 124 ++++++ .../vehicles/fees/VehicleFeeStore.java | 137 +++++++ .../fees/VehicleReclaimFeeListener.java | 79 ++++ .../fees/VehicleRegistrationFeeListener.java | 117 ++++++ .../vehicles/fees/VfBuildersCatalog.java | 64 +++ .../handover/VehicleHandoverListener.java | 73 ++++ .../handover/VehicleHandoverMessages.java | 62 +++ .../handover/VehicleHandoverService.java | 177 +++++++++ .../VehicleHandoverSessionManager.java | 33 ++ .../VehicleMaintenanceMessages.java | 3 +- .../maintenance/VehicleUpkeepProjection.java | 39 ++ .../maintenance/VehicleUpkeepService.java | 19 +- src/main/resources/laws.yml | 78 ++++ src/main/resources/vehicles.yml | 4 + .../proposal/FeeProposalPersistenceTest.java | 62 +++ .../guild/income/VehicleFeeLedgerTest.java | 35 ++ .../laws/VehicleTaxLawTest.java | 49 +++ .../handler/VehicleFeeHandlerTest.java | 103 +++++ .../fees/VehicleFeeConfirmationsTest.java | 30 ++ .../vehicles/fees/VehicleFeeServiceTest.java | 174 +++++++++ .../handover/VehicleHandoverServiceTest.java | 93 +++++ .../VehicleMaintenanceBankCommandTest.java | 2 + .../maintenance/VehicleUpkeepTaxTest.java | 162 ++++++++ 69 files changed, 3112 insertions(+), 60 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeChange.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeKind.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalText.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/managers/inventory/FeeRateInput.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandler.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/objects/request/VehicleHandoverRequest.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeConfirmations.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeService.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VfBuildersCatalog.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverListener.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverMessages.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverService.java create mode 100644 src/main/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverSessionManager.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalPersistenceTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/income/VehicleFeeLedgerTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/laws/VehicleTaxLawTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/objects/handler/VehicleFeeHandlerTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeConfirmationsTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeServiceTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/handover/VehicleHandoverServiceTest.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/maintenance/VehicleUpkeepTaxTest.java 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..60e51038 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,19 @@ public void restoreProposals(net.tfminecraft.simplefactions.objects.Faction fact } } } + } else if (s.startsWith("fee:")) { + String[] parts = s.substring(4).split(":"); + if (parts.length >= 3) { + try { + FeeKind kind = FeeKind.valueOf(parts[0]); + String type = ALL_VEHICLES.equals(parts[1]) ? null : parts[1]; + Proposal p = new Proposal(proposer, gov); + p.setFeeProposal(new FeeChange(kind, type, Double.parseDouble(parts[2]))); + 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..e910a7ff --- /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("§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..095de07c --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java @@ -0,0 +1,62 @@ +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 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..faca221f --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java @@ -0,0 +1,137 @@ +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.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())); + } + try (Writer writer = new FileWriter(file)) { + GSON.toJson(data, writer); + } 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..3dd90ce9 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java @@ -0,0 +1,79 @@ +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.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 && store.getLastOwner(event.getVehicle().getUUID()) != null) { + store.forgetVehicle(event.getVehicle().getUUID()); + saver.run(); + } + } +} 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..4b20c9a4 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java @@ -0,0 +1,117 @@ +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.government.proposal.FeeKind; +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()); + Player payer = Bukkit.getPlayer(build.payerUuid()); + if (payer != null && payer.isOnline() && refunded > 0.0) { + payer.sendMessage(VehicleFeeMessages.registrationRefunded(refunded)); + } + } + + @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 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..d303bbb6 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalPersistenceTest.java @@ -0,0 +1,62 @@ +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)); + + List saved = handler.serializeProposals(); + ProposalHandler restored = new ProposalHandler(gov); + restored.restoreProposals(mock(Faction.class), saved); + + assertEquals(2, restored.getProposals().size()); + 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/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()); + } + } +} From 971dddcebd8dfa5d26a4f0ee1b13fc62016bb77d Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:55:22 +0000 Subject: [PATCH 2/4] Keep a vehicle's last owner across chunk unloads VehicleFramework fires VehicleRemoveEvent when a vehicle's chunk unloads, so forgetting the last owner on every removal let a released vehicle be claimed free once its chunk had unloaded. Only destroyed vehicles are forgotten now. Also formats the colours in the fee rate prompt. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../managers/inventory/VehicleFeeView.java | 4 ++-- .../fees/VehicleReclaimFeeListener.java | 19 ++++++++++++++- .../fees/VehicleReclaimFeeListenerTest.java | 24 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 src/test/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListenerTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java index e910a7ff..df64cbe1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/VehicleFeeView.java @@ -202,8 +202,8 @@ private void startInput(Player p, Faction f, FeeKind kind, String 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("§eType the new " + kind.getDisplayName() + " for " + target + " as " + unit - + " §7(" + rangeText(f.getVehicleFeeHandler(), kind) + "§7)§e, or §ccancel§e."); + 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(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java index 3dd90ce9..7184ff59 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleReclaimFeeListener.java @@ -8,6 +8,8 @@ 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; @@ -71,9 +73,24 @@ public void onClaimed(VehicleOwnerClaimedEvent event) { @EventHandler(priority = EventPriority.MONITOR) public void onVehicleRemove(VehicleRemoveEvent event) { - if (event.getVehicle() != null && store.getLastOwner(event.getVehicle().getUUID()) != null) { + 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/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))); + } +} From 0c680b5964dee148ef8bb83d6ee21acf2e8be960 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:08:56 +0000 Subject: [PATCH 3/4] Address review: colon-safe fee proposals, atomic fee store, short refunds - Saved fee proposals read the rate from the last field, so a vehicle type containing ':' survives a restart. - vehicle_fees.json is written to a temp file and moved into place. - When the faction bank cannot cover a registration refund, the payer is told how much is missing and a warning is logged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../government/handler/ProposalHandler.java | 14 +++++++++----- .../vehicles/fees/VehicleFeeMessages.java | 5 +++++ .../vehicles/fees/VehicleFeeStore.java | 18 +++++++++++++++++- .../fees/VehicleRegistrationFeeListener.java | 17 +++++++++++++++-- .../proposal/FeeProposalPersistenceTest.java | 6 +++++- 5 files changed, 51 insertions(+), 9 deletions(-) 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 60e51038..9d0f5502 100644 --- a/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java +++ b/src/main/java/net/tfminecraft/simplefactions/government/handler/ProposalHandler.java @@ -134,13 +134,17 @@ public void restoreProposals(net.tfminecraft.simplefactions.objects.Faction fact } } } else if (s.startsWith("fee:")) { - String[] parts = s.substring(4).split(":"); - if (parts.length >= 3) { + // 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(parts[0]); - String type = ALL_VEHICLES.equals(parts[1]) ? null : parts[1]; + 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(parts[2]))); + p.setFeeProposal(new FeeChange(kind, type, Double.parseDouble(body.substring(last + 1)))); proposals.add(p); } catch (Exception e) { // Skip malformed proposals diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java index 095de07c..b34821ad 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java @@ -34,6 +34,11 @@ public static String registrationRefunded(double 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 because your faction's " + + "bank is short. Ask your faction leader or staff."; + } + 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."; diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java index faca221f..b5970dc1 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeStore.java @@ -6,6 +6,9 @@ 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; @@ -98,8 +101,21 @@ public void save() { for (Map.Entry entry : paidBuilds.entrySet()) { data.paidBuilds.put(entry.getKey(), BuildData.from(entry.getValue())); } - try (Writer writer = new FileWriter(file)) { + // 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(); } diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java index 4b20c9a4..ce9f3a0b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java @@ -9,7 +9,9 @@ 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; @@ -79,9 +81,20 @@ public void onConstructionCancel(VehicleConstructionCancelEvent event) { 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 has spent it; there is nothing to hold it against, so staff are told instead. + SimpleFactions.getInstance().getLogger().warning("Registration fee refund short by " + shortfall + + " for " + build.payerUuid() + ": faction " + build.factionId() + " could not cover it"); + } Player payer = Bukkit.getPlayer(build.payerUuid()); - if (payer != null && payer.isOnline() && refunded > 0.0) { - payer.sendMessage(VehicleFeeMessages.registrationRefunded(refunded)); + if (payer != null && payer.isOnline()) { + if (refunded > 0.0) { + payer.sendMessage(VehicleFeeMessages.registrationRefunded(refunded)); + } + if (shortfall > 0.0) { + payer.sendMessage(VehicleFeeMessages.registrationRefundShort(shortfall)); + } } } diff --git a/src/test/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalPersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalPersistenceTest.java index d303bbb6..9f94d217 100644 --- a/src/test/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalPersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/government/proposal/FeeProposalPersistenceTest.java @@ -28,12 +28,16 @@ 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(2, restored.getProposals().size()); + 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()); From bce0b67a49f6ed039eb387c1337f4113c5f8284a Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:29:31 +0000 Subject: [PATCH 4/4] Describe a short registration refund without guessing the cause A refund can fall short because the faction bank is low or because the deposit to the player failed, so the message and log no longer blame the bank. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/vehicles/fees/VehicleFeeMessages.java | 3 +-- .../vehicles/fees/VehicleRegistrationFeeListener.java | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java index b34821ad..87769e3a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleFeeMessages.java @@ -35,8 +35,7 @@ public static String registrationRefunded(double amount) { } public static String registrationRefundShort(double amount) { - return "§c" + amount(amount) + " §cof your registration fee could not be refunded because your faction's " - + "bank is short. Ask your faction leader or staff."; + 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) { diff --git a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java index ce9f3a0b..b2b8864e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java +++ b/src/main/java/net/tfminecraft/simplefactions/vehicles/fees/VehicleRegistrationFeeListener.java @@ -83,9 +83,9 @@ public void onConstructionCancel(VehicleConstructionCancelEvent event) { 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 has spent it; there is nothing to hold it against, so staff are told instead. + // 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() + ": faction " + build.factionId() + " could not cover it"); + + " for " + build.payerUuid() + " from faction " + build.factionId()); } Player payer = Bukkit.getPlayer(build.payerUuid()); if (payer != null && payer.isOnline()) {