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