diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index bb5af7bd..9e34791e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -419,6 +419,7 @@ public boolean saveFactionChecked(Faction f) { gd.repressed = g.isRepressed(); gd.favoured = g.isFavoured(); gd.dividendPercent = g.getDividendPercent(); + gd.donation = g.getDonationAmount(); gd.dividendEligible = g.getDividendEligibleSnapshot(); gd.casinoProfit = g.getLedger().getCasinoProfit(); gd.vehicleFeeIncome = g.getLedger().getVehicleFeeIncome(); diff --git a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java index cd693ed8..37655c61 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java @@ -57,6 +57,8 @@ public class GuildData { public List pillageHits = new ArrayList<>(); public Double dividendPercent; + /** Denars this guild sends to the realm each day, before the fee. */ + public Double donation; public List dividendEligible = new ArrayList<>(); @SerializedName("casino profit") diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 7ae28722..765775a7 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -100,6 +100,8 @@ public class Guild { private boolean repressed = false; private final List pillageHits = new ArrayList<>(); private double dividendPercent = 0.0; + /** Denars sent to the realm each day. The fee is extra and is not stored here. */ + private double donationAmount = 0.0; private List dividendEligible = new ArrayList<>(); private MercenaryCompany company; @@ -235,6 +237,9 @@ public Guild(GuildData data, Faction host) { if (data.dividendPercent != null) { this.dividendPercent = clampDividendPercent(data.dividendPercent); } + if (data.donation != null && !isBase()) { + this.donationAmount = clampDonation(data.donation); + } if (data.dividendEligible != null) { this.dividendEligible = new ArrayList<>(data.dividendEligible); } else { @@ -1029,6 +1034,23 @@ public double setDividendPercent(double percent) { return dividendPercent; } + public double getDonationAmount() { + return donationAmount; + } + + /** {@code amount} is what the realm receives. Zero clears the pledge. Realm guilds cannot donate. */ + public double setDonationAmount(double amount) { + donationAmount = isBase() ? 0.0 : clampDonation(amount); + return donationAmount; + } + + private static double clampDonation(double amount) { + if (Double.isNaN(amount) || Double.isInfinite(amount) || amount <= 0) { + return 0.0; + } + return Formatter.formatDouble(amount); + } + public List getDividendEligibleSnapshot() { return new ArrayList<>(dividendEligible); } 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 4686c7af..223c5a03 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Cashflow.java @@ -18,6 +18,9 @@ public enum Cashflow { INTEREST("#63cf71Interest", false, true), TRIBUTE_PAYMENTS("#ab8568Tribute Payments", false, false), TRIBUTES("#ab8568Tribute Payments", false, true), + // Voluntary gift to the realm. Not gross income: the denars were already taxed where they were earned. + DONATION_PAYMENTS("#cfc97cDonations", false, false), + DONATIONS("#cfc97cDonations", false, false), OVERLORD_TAX("#b55e94Overlord Taxes", false, false), WAR_REPARATIONS("#8a433bWar Reparations", false, true), WAR_REPARATIONS_PAYMENT("#8a433bWar Reparations", false, false), @@ -47,7 +50,8 @@ public enum Cashflow { INSTALLATIONS("#706964Installations", true, false), VEHICLE_UPKEEP("#a6659fVehicle Upkeep", true, false), MILITARY_UPKEEP("#a6659fMilitary Upkeep", true, false), - NODES("#8a7a5cNodes", true, false); + NODES("#8a7a5cNodes", true, false), + DONATION_FEE("#cfc97cDonation Fee", true, false); private final String display; private final boolean affectsInflation; diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildDonation.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildDonation.java new file mode 100644 index 00000000..8ff759b2 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/GuildDonation.java @@ -0,0 +1,51 @@ +package net.tfminecraft.simplefactions.guild.income; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.utils.Formatter; + +/** + * A guild's daily gift to its realm treasury. The amount is what the realm receives. + * The sender also pays a fee on top, so a 100d gift costs 110d and the extra 10d is destroyed. + * That fee stops a guild from moving its whole balance into the realm for free. + */ +public final class GuildDonation { + + public static final double FEE_RATE = 0.10; + + private GuildDonation() {} + + /** Denars destroyed on top of {@code sent}. Zero when nothing is sent. */ + public static double fee(double sent) { + if (sent <= 0 || Double.isNaN(sent) || Double.isInfinite(sent)) { + return 0.0; + } + return Formatter.formatDouble(sent * FEE_RATE); + } + + /** Gift plus fee. This is what must be on hand. */ + public static double cost(double sent) { + if (sent <= 0 || Double.isNaN(sent) || Double.isInfinite(sent)) { + return 0.0; + } + return Formatter.formatDouble(sent + fee(sent)); + } + + public static void notifyCancelled(Guild guild, double sent) { + if (guild == null || sent <= 0) { + return; + } + String leaderName = guild.getLeader(); + if (leaderName == null || leaderName.isBlank() || Bukkit.getServer() == null) { + return; + } + Player leader = Bukkit.getPlayerExact(leaderName); + if (leader == null) { + return; + } + leader.sendMessage("§cDaily donation of §e" + Formatter.formatMoney(sent) + + "d §cwas cancelled. The guild could not cover the day."); + } +} 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 c9ef0f36..36974ff4 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -365,6 +365,23 @@ public double getIncome(Cashflow cashflow) { case WAGE_PAYMENTS: amount = -getAggregatedPendingWages(); break; + case DONATIONS: + if (!guild.isBase()) return 0; + if (f.getGuildHandler() == null || f.getGuildHandler().getGuilds() == null) break; + for (Guild other : f.getGuildHandler().getGuilds()) { + if (other == null || other.isBase() || other.getLedger() == null) continue; + if (other.getLedger().skipsMoneyMovement()) continue; + amount += Math.max(0, other.getDonationAmount()); + } + break; + case DONATION_PAYMENTS: + if (guild.isBase()) return 0; + amount = -Math.max(0, guild.getDonationAmount()); + break; + case DONATION_FEE: + if (guild.isBase()) return 0; + amount = -GuildDonation.fee(guild.getDonationAmount()); + break; case PENALTIES: if(!guild.isBase()) return 0; amount = -f.getPenalty(); @@ -484,6 +501,7 @@ public double getNetIncome() { case INTEREST: case MERCENARY_CONTRACT: case REFUNDS: + case DONATIONS: net += getIncome(cf); break; @@ -507,6 +525,8 @@ public double getNetIncome() { case MERCENARY_PAYMENTS: case REFUND_PAYMENTS: case WAGE_PAYMENTS: + case DONATION_PAYMENTS: + case DONATION_FEE: net += getIncome(cf); // already negative break; @@ -578,6 +598,8 @@ public double getDividendBase() { case MERCENARY_PAYMENTS: case REFUND_PAYMENTS: case WAGE_PAYMENTS: + case DONATION_PAYMENTS: + case DONATION_FEE: net += getIncome(cf); break; default: @@ -896,6 +918,9 @@ private double loanBudget(DailyGuildTransfers buffer) { } public void populateDailyTransfers(DailyGuildTransfers buffer) { + // Drop the gift before anything else is committed. Other costs may then still fit, + // so a guild that was short only because of the donation stays solvent. + cancelDonationIfUnaffordable(); if(guild.isBankrupt() && guild.getLoanHandler() != null && guild.getLoanHandler().getLoansTaken() != null) { for(Loan loan : guild.getLoanHandler().getLoansTaken()) { if(loan == null || !loan.isAutoPay()) continue; @@ -954,6 +979,37 @@ private void clearSettledContractBuckets() { company.clearPendingWages(); } + /** + * True when today's settlement, including the donation and its fee, would finish below zero. + * Dividends and loan payments are left out: both stop at the money that remains, so they + * cannot be what makes the guild insolvent. Lines already in the bank, or already withdrawn + * earlier today, are left out too. + */ + private boolean donationOverdraws() { + double net = getNetIncome(); + net -= getIncome(Cashflow.DIVIDEND_PAYOUT); + net -= getIncome(Cashflow.DIVIDEND_PAYMENT); + net -= getIncome(Cashflow.LOAN_PAYMENTS); + net -= getIncome(Cashflow.INTEREST_PAYMENTS); + net -= getIncome(Cashflow.GAMBLING); + net -= getIncome(Cashflow.VEHICLE_FEES); + net -= getIncome(Cashflow.INSTALLATIONS); + net -= getIncome(Cashflow.VEHICLE_UPKEEP); + return Formatter.formatDouble(wealthOf(guild) + net) < 0; + } + + private void cancelDonationIfUnaffordable() { + if (guild.isBase() || guild.getDonationAmount() <= 0) { + return; + } + if (!skipsMoneyMovement() && !donationOverdraws()) { + return; + } + double sent = guild.getDonationAmount(); + guild.setDonationAmount(0); + GuildDonation.notifyCancelled(guild, sent); + } + private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { if(skipsMoneyMovement()) return; //bankrupt guilds dont pay or receive money, they need to get our of bankrupcy first switch (cf) { @@ -1134,6 +1190,26 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { return; } + case DONATION_PAYMENTS: { + if (guild.isBase()) return; + double amount = guild.getDonationAmount(); + if (amount <= 0) return; + Faction faction = guild.getFaction(); + if (faction == null) return; + Guild capital = faction.getOrCreateMainGuild(); + if (capital == null || capital == guild) return; + buffer.add(guild, capital, amount); + return; + } + + case DONATION_FEE: { + if (guild.isBase()) return; + double fee = GuildDonation.fee(guild.getDonationAmount()); + if (fee <= 0) return; + buffer.addExternalDelta(guild, -fee); + return; + } + case WAGE_PAYMENTS: { MercenaryCompany company = getFormedCompany(); if (company == null) return; @@ -1161,6 +1237,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case WAR_REPARATIONS: case MERCENARY_CONTRACT: case REFUNDS: + case DONATIONS: default: return; } diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java index f71eb893..6bc4c8ca 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/InventoryManager.java @@ -29,6 +29,8 @@ import net.tfminecraft.simplefactions.army.Regiment; import net.tfminecraft.simplefactions.Cache; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.GuildDonation; +import net.tfminecraft.simplefactions.utils.Formatter; import net.tfminecraft.simplefactions.guild.loans.Loan; import net.tfminecraft.simplefactions.managers.holder.CampaignInventoryHolder; import net.tfminecraft.simplefactions.managers.holder.CampaignRaidLaunchHolder; @@ -45,6 +47,7 @@ import net.tfminecraft.simplefactions.managers.inventory.MercenaryMarketView; import net.tfminecraft.simplefactions.managers.inventory.DeclareWarView; import net.tfminecraft.simplefactions.managers.inventory.DividendChange; +import net.tfminecraft.simplefactions.managers.inventory.DonationChange; import net.tfminecraft.simplefactions.managers.inventory.ElectionView; import net.tfminecraft.simplefactions.managers.inventory.FactionView; import net.tfminecraft.simplefactions.managers.inventory.GovernmentView; @@ -72,7 +75,6 @@ import net.tfminecraft.simplefactions.objects.Bank; import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.objects.handler.TaxHandler; -import net.tfminecraft.simplefactions.utils.Formatter; import net.tfminecraft.simplefactions.SimpleFactions; import net.tfminecraft.simplefactions.tiers.Tier; import net.tfminecraft.simplefactions.war.core.Participant; @@ -106,6 +108,7 @@ public class InventoryManager implements Listener{ public HashMap feeChange = new HashMap<>(); public HashMap loanPayments = new HashMap<>(); public HashMap dividendChange = new HashMap<>(); + public HashMap donationChange = new HashMap<>(); public HashMap slotChanges = new HashMap<>(); InventoryUpdater updater = new InventoryUpdater(this); @@ -146,6 +149,12 @@ public void run() { entry.getKey().sendMessage("§cDividend change timed out."); } } + for(Map.Entry entry : ((HashMap) donationChange.clone()).entrySet()) { + if(entry.getValue().tick()) { + donationChange.remove(entry.getKey()); + entry.getKey().sendMessage("§cDonation change timed out."); + } + } for(Map.Entry entry : ((HashMap) slotChanges.clone()).entrySet()) { if(entry.getValue().tick()) { slotChanges.remove(entry.getKey()); @@ -406,7 +415,7 @@ public void loanDetailView(Player p, Guild guild, Loan loan, boolean isTaken, In public boolean chatTrigger(Player p) { return taxChange.containsKey(p) || feeChange.containsKey(p) || loanPayments.containsKey(p) || dividendChange.containsKey(p) - || slotChanges.containsKey(p); + || donationChange.containsKey(p) || slotChanges.containsKey(p); } public void setChanging(Faction faction, Player p, TaxTarget target, String id) { @@ -431,6 +440,25 @@ public void setPayingLoan(Player p, Loan loan) { String.format("%.2f", loan.getTotalOwed()) + "d#d6cf69):")); } + public void setChangingDonation(Player p, Guild guild) { + if (guild == null || p == null || guild.isBase()) { + return; + } + donationChange.put(p, new DonationChange(guild)); + p.closeInventory(); + double sent = guild.getDonationAmount(); + p.sendMessage(StringFormatter.formatHex( + "#d6cf69Enter how many denars to send your realm each day (currently #87d65c" + + String.format("%.2f", sent) + + "d#d6cf69).")); + p.sendMessage(StringFormatter.formatHex( + "#d6cf69Sending #87d65c100d #d6cf69also costs a #cf493a" + + Formatter.formatMoney(GuildDonation.fee(100)) + + "d #d6cf69fee, so you need #ccbb76" + + Formatter.formatMoney(GuildDonation.cost(100)) + + "d #d6cf69on hand. Type #87d65c0 #d6cf69to stop, or #c74d32cancel #d6cf69to abort.")); + } + public void setChangingDividend(Player p, Guild guild) { if (guild == null || p == null) { return; @@ -457,6 +485,7 @@ public void run() { if(feeChange.containsKey(p)) feeChat(p, e); if(loanPayments.containsKey(p)) loanPaymentChat(p, e); if(dividendChange.containsKey(p)) dividendChat(p, e); + if(donationChange.containsKey(p)) donationChat(p, e); if(slotChanges.containsKey(p)) slotChat(p, e); } }.runTask(SimpleFactions.plugin); @@ -691,6 +720,61 @@ public void dividendChat(Player p, AsyncPlayerChatEvent e) { guildView(p, guild); } + // Retain Bukkit chat-event ordering and String message semantics for existing integrations. + @SuppressWarnings("deprecation") + public void donationChat(Player p, AsyncPlayerChatEvent e) { + DonationChange change = donationChange.get(p); + Guild guild = change == null ? null : change.getGuild(); + if (guild == null) { + donationChange.remove(p); + return; + } + if (e.getMessage().equalsIgnoreCase("cancel")) { + p.sendMessage("§cDonation change cancelled."); + donationChange.remove(p); + guildView(p, guild); + return; + } + if (!guild.isLeader(p) || guild.isBase()) { + p.sendMessage("§cOnly the guild leader can set donations."); + donationChange.remove(p); + return; + } + double amount; + try { + amount = Double.parseDouble(e.getMessage()); + } catch (Exception ex) { + p.sendMessage("§cError inputting the amount, use the format §e100 §cfor 100 denars (example)"); + p.sendMessage("§4Type 'cancel' to cancel."); + return; + } + if (!Double.isFinite(amount)) { + p.sendMessage("§cDonation must be a number of denars."); + p.sendMessage("§4Type 'cancel' to cancel."); + return; + } + amount = Math.round(amount * 100.0) / 100.0; + if (amount < 0) { + p.sendMessage("§cDonation must be §e0 §cor more."); + p.sendMessage("§4Type 'cancel' to cancel."); + return; + } + guild.setDonationAmount(amount); + double sent = guild.getDonationAmount(); + if (sent <= 0) { + p.sendMessage(StringFormatter.formatHex("#d6cf69Daily donation cleared.")); + } else { + p.sendMessage(StringFormatter.formatHex( + "#d6cf69Daily donation set to #87d65c" + Formatter.formatMoney(sent) + + "d#d6cf69. Fee #cf493a" + Formatter.formatMoney(GuildDonation.fee(sent)) + + "d#d6cf69, so you need #ccbb76" + Formatter.formatMoney(GuildDonation.cost(sent)) + + "d #d6cf69on hand.")); + } + p.playSound(p.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); + donationChange.remove(p); + guildView(p, guild); + } + public void beginSlotChange(Player p, Guild guild, MercenaryContract contract) { if (p == null || guild == null || contract == null || guild.getCompany() == null) return; slotChanges.put(p, new SlotChangePrompt(guild.getId(), contract.getId())); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DonationChange.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DonationChange.java new file mode 100644 index 00000000..47237c31 --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/DonationChange.java @@ -0,0 +1,22 @@ +package net.tfminecraft.simplefactions.managers.inventory; + +import net.tfminecraft.simplefactions.guild.Guild; + +public class DonationChange { + private final Guild guild; + private int time; + + public DonationChange(Guild guild) { + this.guild = guild; + time = 0; + } + + public boolean tick() { + time++; + return time == 30; + } + + public Guild getGuild() { + return guild; + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java index d9cafe1a..d619a10e 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -28,6 +28,7 @@ import net.tfminecraft.simplefactions.guild.branch.Branch; import net.tfminecraft.simplefactions.guild.branch.BranchModifier; import net.tfminecraft.simplefactions.guild.income.Cashflow; +import net.tfminecraft.simplefactions.guild.income.GuildDonation; import net.tfminecraft.simplefactions.guild.income.Ledger; import net.tfminecraft.simplefactions.guild.income.LedgerHistory; import net.tfminecraft.simplefactions.guild.loans.Loan; @@ -520,6 +521,37 @@ public ItemStack createDividendItem(Player p, Guild g) { return i; } + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + public ItemStack createDonationItem(Player p, Guild g) { + ItemStack i = IconGetter.getIconOrDefault("tributary", Material.BLACK_DYE); + ItemMeta meta = i.getItemMeta(); + meta.setDisplayName(StringFormatter.formatHex("#cfc97cDonations")); + List lore = new ArrayList<>(); + double sent = g.getDonationAmount(); + lore.add(StringFormatter.formatHex("#d4c9aeSent to your realm each day.")); + if (sent <= 0) { + lore.add(StringFormatter.formatHex("#d4c9aeNothing is sent right now.")); + } else { + lore.add(StringFormatter.formatHex("#d4c9aeSent: #7fbd73" + Formatter.formatMoney(sent) + "d")); + lore.add(StringFormatter.formatHex("#d4c9aeFee (" + (int) Math.round(GuildDonation.FEE_RATE * 100) + + "%): #cf493a" + Formatter.formatMoney(GuildDonation.fee(sent)) + "d")); + lore.add(StringFormatter.formatHex("#d4c9aeNeeded on hand: #ccbb76" + + Formatter.formatMoney(GuildDonation.cost(sent)) + "d")); + } + lore.add(StringFormatter.formatHex("#7a706aSending 100d costs 110d. The extra is a fee.")); + lore.add(StringFormatter.formatHex("#7a706aCancelled at the new day if the guild cannot cover it.")); + lore.add(""); + if (g.isLeader(p)) { + lore.add(StringFormatter.formatHex("#28ed70Click to set the amount in chat")); + } else { + lore.add(StringFormatter.formatHex("#7a706aOnly the guild leader can change this.")); + } + meta.setLore(lore); + i.setItemMeta(meta); + return i; + } + // Preserve the existing additional-tooltip component selection and legacy item text; hiding the whole tooltip is different. @SuppressWarnings({"deprecation"}) public ItemStack createLedgerCitizensItem(Guild g) { diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java index ecca2186..190795d6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -60,6 +60,8 @@ public class GuildView { /** Mercenary company entry. Slot 13 of the guild view already holds the trade breakdown. */ public static final int COMPANY_SLOT = 19; + /** Daily gift to the realm. Same column as dividends. Realm guilds do not have one. */ + public static final int DONATION_SLOT = 8; public static final int BANNER_RANDOM_SLOT = 28; public static final int HOST_FACTION_SLOT = 37; @@ -224,6 +226,7 @@ public void guildView(Player player, Guild guild, Inventory i) { i.setItem(14, creator.createLedgerItem(player, guild)); if (!guild.isBase()) { i.setItem(17, creator.createDividendItem(player, guild)); + i.setItem(DONATION_SLOT, creator.createDonationItem(player, guild)); } i.setItem(HOST_FACTION_SLOT, creator.createHostFactionItem(guild)); i.setItem(COMPANY_SLOT, inv.companyView.creator.createCompanyEntryItem(guild)); @@ -458,6 +461,12 @@ public void click(InventoryClickEvent e, Inventory inventory, Player p) { } inv.setChangingDividend(p, guild); return; + } else if(e.getSlot() == DONATION_SLOT) { + if (guild.isBase() || !guild.isLeader(p)) { + return; + } + inv.setChangingDonation(p, guild); + return; } ItemStack item = e.getCurrentItem(); ItemMeta meta = item.getItemMeta(); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/GuildDonationTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/GuildDonationTest.java new file mode 100644 index 00000000..58db0fb3 --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/GuildDonationTest.java @@ -0,0 +1,183 @@ +package net.tfminecraft.simplefactions.guild.income; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.database.GuildData; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; +import net.tfminecraft.simplefactions.objects.handler.GuildHandler; +import net.tfminecraft.simplefactions.utils.DailyGuildTransfers; + +class GuildDonationTest { + + @AfterEach + void tearDown() { + Ledger.setNodeUpkeepLookup(null); + } + + @Test + void feeIsTenPercentOnTopOfTheGift() { + assertEquals(10.0, GuildDonation.fee(100)); + assertEquals(110.0, GuildDonation.cost(100)); + assertEquals(0.0, GuildDonation.fee(0)); + assertEquals(0.0, GuildDonation.cost(0)); + assertEquals(3.33, GuildDonation.fee(33.33)); + assertEquals(36.66, GuildDonation.cost(33.33)); + } + + @Test + void donationAmountRoundTripsInGuildData() { + GuildData data = new GuildData(); + data.donation = 100.0; + GuildData restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(data), GuildData.class); + assertEquals(100.0, restored.donation); + GuildData empty = JsonUtil.GSON.fromJson("{}", GuildData.class); + assertNull(empty.donation); + } + + @Test + void settlementSendsTheGiftAndSinksTheFee() { + Guilds guilds = guilds(1000, 100); + assertEquals(-100.0, guilds.payer.getLedger().getIncome(Cashflow.DONATION_PAYMENTS)); + assertEquals(-10.0, guilds.payer.getLedger().getIncome(Cashflow.DONATION_FEE)); + assertEquals(-110.0, guilds.payer.getLedger().getNetIncome()); + assertEquals(-110.0, guilds.payer.getLedger().getDividendBase()); + assertEquals(100.0, guilds.capital.getLedger().getIncome(Cashflow.DONATIONS)); + assertEquals(-10.0, guilds.payer.getLedger().getInflationDelta()); + assertEquals(0.0, guilds.payer.getLedger().getGrossTaxableIncome()); + + DailyGuildTransfers transfers = new DailyGuildTransfers(); + guilds.payer.getLedger().populateDailyTransfers(transfers); + + assertEquals(100.0, guilds.donation[0]); + assertEquals(100.0, transfers.getTransfers().get(guilds.payer).get(guilds.capital)); + assertEquals(-10.0, transfers.getExternalDeltas().get(guilds.payer)); + } + + @Test + void settlementCancelsTheGiftWhenTheDayWouldFinishBelowZero() { + Ledger.setNodeUpkeepLookup(guild -> 50); + Guilds guilds = guilds(100, 100); + + DailyGuildTransfers transfers = new DailyGuildTransfers(); + guilds.payer.getLedger().populateDailyTransfers(transfers); + + assertEquals(0.0, guilds.donation[0]); + assertNull(transfers.getTransfers().get(guilds.payer)); + assertEquals(-50.0, transfers.getExternalDeltas().get(guilds.payer)); + } + + @Test + void settlementKeepsTheGiftWhenTheDayFinishesAtZero() { + Ledger.setNodeUpkeepLookup(guild -> 50); + Guilds guilds = guilds(160, 100); + + DailyGuildTransfers transfers = new DailyGuildTransfers(); + guilds.payer.getLedger().populateDailyTransfers(transfers); + + assertEquals(100.0, guilds.donation[0]); + assertEquals(100.0, transfers.getTransfers().get(guilds.payer).get(guilds.capital)); + assertEquals(-60.0, transfers.getExternalDeltas().get(guilds.payer)); + } + + @Test + void tradeIncomeArrivingTheSameDayCanCoverTheGift() { + Guilds guilds = guilds(0, 100); + guilds.payer.getTradeBreakdown().setIncome(200); + + DailyGuildTransfers transfers = new DailyGuildTransfers(); + guilds.payer.getLedger().populateDailyTransfers(transfers); + + assertEquals(100.0, guilds.donation[0]); + assertEquals(100.0, transfers.getTransfers().get(guilds.payer).get(guilds.capital)); + assertEquals(190.0, transfers.getExternalDeltas().get(guilds.payer)); + } + + @Test + void bankruptGuildClearsThePledgeInsteadOfCarryingIt() { + Guilds guilds = guilds(1000, 80); + when(guilds.payer.isBankrupt()).thenReturn(true); + + guilds.payer.getLedger().populateDailyTransfers(new DailyGuildTransfers()); + + assertEquals(0.0, guilds.donation[0]); + } + + @Test + void realmGuildDoesNotSendADonation() { + Guilds guilds = guilds(1000, 0); + when(guilds.capital.getDonationAmount()).thenReturn(500.0); + + DailyGuildTransfers transfers = new DailyGuildTransfers(); + guilds.capital.getLedger().populateDailyTransfers(transfers); + + assertNull(transfers.getTransfers().get(guilds.capital)); + assertEquals(0.0, guilds.capital.getLedger().getIncome(Cashflow.DONATION_PAYMENTS)); + assertEquals(0.0, guilds.capital.getLedger().getIncome(Cashflow.DONATION_FEE)); + } + + @Test + void realmReceiptSkipsBankruptGuilds() { + Guilds guilds = guilds(1000, 25); + net.tfminecraft.simplefactions.guild.Guild broke = mock(net.tfminecraft.simplefactions.guild.Guild.class); + when(broke.isBase()).thenReturn(false); + when(broke.isBankrupt()).thenReturn(true); + when(broke.getDonationAmount()).thenReturn(40.0); + Bank bank = mock(Bank.class); + when(bank.getWealth()).thenReturn(-5.0); + when(broke.getBank()).thenReturn(bank); + when(broke.getLedger()).thenReturn(new Ledger(broke)); + when(guilds.handler.getGuilds()).thenReturn(List.of(guilds.capital, guilds.payer, broke)); + + assertEquals(25.0, guilds.capital.getLedger().getIncome(Cashflow.DONATIONS)); + } + + private static Guilds guilds(double wealth, double donation) { + Faction faction = mock(Faction.class); + when(faction.getId()).thenReturn("realm"); + when(faction.getName()).thenReturn("Realm"); + GuildHandler handler = mock(GuildHandler.class); + when(faction.getGuildHandler()).thenReturn(handler); + + net.tfminecraft.simplefactions.guild.Guild payer = guild(faction, false, wealth); + net.tfminecraft.simplefactions.guild.Guild capital = guild(faction, true, wealth); + when(faction.getOrCreateMainGuild()).thenReturn(capital); + when(handler.getGuilds()).thenReturn(List.of(capital, payer)); + + double[] stored = {donation}; + when(payer.getDonationAmount()).thenAnswer(invocation -> stored[0]); + when(payer.setDonationAmount(anyDouble())).thenAnswer(invocation -> { + stored[0] = invocation.getArgument(0); + return stored[0]; + }); + return new Guilds(payer, capital, handler, stored); + } + + private static net.tfminecraft.simplefactions.guild.Guild guild(Faction faction, boolean base, double wealth) { + net.tfminecraft.simplefactions.guild.Guild guild = mock(net.tfminecraft.simplefactions.guild.Guild.class); + Bank bank = mock(Bank.class); + when(bank.getWealth()).thenReturn(wealth); + when(guild.getBank()).thenReturn(bank); + when(guild.getFaction()).thenReturn(faction); + when(guild.isBase()).thenReturn(base); + when(guild.getTradeBreakdown()).thenReturn(new TradeBreakdown()); + when(guild.getLedger()).thenReturn(new Ledger(guild)); + return guild; + } + + private record Guilds( + net.tfminecraft.simplefactions.guild.Guild payer, + net.tfminecraft.simplefactions.guild.Guild capital, + GuildHandler handler, + double[] donation) {} +}