From 9ea6a2e513aa6de13686c330328403d0a6b9681a Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:34:24 +0000 Subject: [PATCH] Show last-day and lifetime totals in the ledger view, and track deposits Each ledger view icon (Citizens, Guild Taxes, Vassals, Tributes, Tariffs) now lists today's payers, what they paid on the last day, and what they have paid in total. A new Deposits icon does the same for members' /f deposit and /g deposit amounts. - Snapshot each capital's per-payer receipts from the payer side just before settlement, mirroring what applySettlementFor moves - Roll the snapshot into last-day and lifetime totals after settlement - Deposits are tracked only; settlement never reads the history - Save the history with the guild so a restart keeps it - Tributes "Today" uses the same internal income base as settlement Co-Authored-By: Claude Opus 5.5 (1M context) --- .../simplefactions/database/Database.java | 8 + .../simplefactions/database/GuildData.java | 7 + .../simplefactions/guild/Guild.java | 1 + .../simplefactions/guild/income/Ledger.java | 68 +++++++- .../guild/income/LedgerHistory.java | 135 +++++++++++++++ .../managers/CommandManager.java | 2 + .../managers/FactionManager.java | 11 ++ .../managers/inventory/GuildCreator.java | 155 +++++++----------- .../managers/inventory/GuildView.java | 1 + .../guild/income/LedgerHistoryTest.java | 135 +++++++++++++++ 10 files changed, 425 insertions(+), 98 deletions(-) create mode 100644 src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java create mode 100644 src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHistoryTest.java diff --git a/src/main/java/net/tfminecraft/simplefactions/database/Database.java b/src/main/java/net/tfminecraft/simplefactions/database/Database.java index 75b396d2..df0a8529 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/Database.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/Database.java @@ -21,6 +21,7 @@ import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; import net.tfminecraft.simplefactions.guild.upgrade.UpgradeExpansion; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.LedgerHistory; import net.tfminecraft.simplefactions.loaders.BranchLoader; import net.tfminecraft.simplefactions.loaders.RankLoader; import net.tfminecraft.simplefactions.loaders.TitleLoader; @@ -408,6 +409,13 @@ public void saveFaction(Faction f) { gd.vehicleFeeIncome = g.getLedger().getVehicleFeeIncome(); Map citizenTaxes = g.getLedger().getCitizenTaxesCopy(); gd.citizenTaxes = citizenTaxes.isEmpty() ? null : citizenTaxes; + LedgerHistory history = g.getLedger().getHistory(); + Map> lastDay = history.getLastDayCopy(); + gd.ledgerLastDay = lastDay.isEmpty() ? null : lastDay; + Map> lifetime = history.getLifetimeCopy(); + gd.ledgerLifetime = lifetime.isEmpty() ? null : lifetime; + Map depositsToday = history.getDepositsTodayCopy(); + gd.depositsToday = depositsToday.isEmpty() ? null : depositsToday; gd.company = g.getCompany() != null ? g.getCompany().serialize() : null; // --- Bank --- diff --git a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java index 1ebb6dfc..52909c2b 100644 --- a/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java +++ b/src/main/java/net/tfminecraft/simplefactions/database/GuildData.java @@ -57,5 +57,12 @@ public class GuildData { @SerializedName("citizen taxes") public Map citizenTaxes; + @SerializedName("ledger last day") + public Map> ledgerLastDay; + @SerializedName("ledger lifetime") + public Map> ledgerLifetime; + @SerializedName("deposits today") + public Map depositsToday; + public MercenaryCompanyData company; } diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java index 88d182a5..9804a714 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/Guild.java @@ -223,6 +223,7 @@ public Guild(GuildData data, Faction host) { this.ledger.setCasinoProfit(data.casinoProfit); } this.ledger.setCitizenTaxes(data.citizenTaxes); + this.ledger.getHistory().load(data.ledgerLastDay, data.ledgerLifetime, data.depositsToday); this.loanHandler = new LoanHandler(this, data.creditScore == null ? 50 : data.creditScore); if(data.favoured != null) this.favoured = data.favoured; if(data.repressed != null) this.repressed = data.repressed; 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 cbfb6e7d..539f7d1d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -1,6 +1,7 @@ package net.tfminecraft.simplefactions.guild.income; import java.util.ArrayList; +import java.util.EnumMap; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -53,6 +54,8 @@ public class Ledger { // Vehicle tax and fees collected today, already in the bank. Saved like casinoProfit. private double vehicleFeeIncome; + private final LedgerHistory history = new LedgerHistory(); + // 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; @@ -169,6 +172,10 @@ public void setVehicleFeeIncome(double amount) { vehicleFeeIncome = amount; } + public LedgerHistory getHistory() { + return history; + } + 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 @@ -732,7 +739,7 @@ public double getWarReparationsReceived() { * war reparations, vassal guild rollups). Used as the base for tribute and * reparations so ledger queries cannot recurse between factions. */ - double getInternalTaxableIncome() { + public double getInternalTaxableIncome() { if (skipsMoneyMovement()) { return 0.0; } @@ -777,13 +784,70 @@ public void clearDailyIncome() { * Bankruptcy freezes a guild. A missing bank is not bankruptcy, but it also * cannot pay or receive, so settlement must skip it instead of throwing. */ - private boolean skipsMoneyMovement() { + boolean skipsMoneyMovement() { if (guild.isBankrupt()) { return true; } return guild.getBank() == null; } + /** + * What each capital is about to receive per ledger view source, walked from the payer side + * as {@link #applySettlementFor} moves it. Read before settlement clears the day. + */ + public static Map>> collectHistoryDay(Iterable guilds) { + Map>> out = new HashMap<>(); + for (Guild payer : guilds) { + if (payer == null || payer.getLedger() == null) continue; + Ledger ledger = payer.getLedger(); + Faction f = payer.getFaction(); + if (f == null || ledger.skipsMoneyMovement()) continue; + + if (payer.isBase()) { + ledger.citizenTaxes.forEach((name, tax) -> + recordHistory(out, payer, LedgerHistory.Source.CITIZENS, name, tax)); + + Faction overlord = f.getOverlord(); + if (overlord != null) { + recordHistory(out, overlord.getOrCreateMainGuild(), LedgerHistory.Source.VASSALS, + f.getName(), Math.abs(ledger.getIncome(Cashflow.OVERLORD_TAX))); + } + + if (f.getModifiers() != null) { + double base = ledger.getInternalTaxableIncome(); + for (FactionModifier mod : f.getModifiers()) { + if (mod.getFrom() == null) continue; + if (!mod.getType().equals(FactionModifiers.TRIBUTE)) continue; + recordHistory(out, mod.getFrom().getOrCreateMainGuild(), LedgerHistory.Source.TRIBUTES, + f.getName(), base * (mod.getAmount() / 100.0)); + } + } + } else { + recordHistory(out, f.getOrCreateMainGuild(), LedgerHistory.Source.GUILD_TAXES, + payer.getName(), Math.abs(ledger.getIncome(Cashflow.GUILD_PAYMENTS))); + } + + TradeBreakdown trade = payer.getTradeBreakdown(); + if (trade != null && trade.getTariffsByFactionMap() != null) { + for (Map.Entry entry : trade.getTariffsByFactionMap().entrySet()) { + if (entry.getKey() == null || entry.getValue() == null) continue; + recordHistory(out, entry.getKey().getOrCreateMainGuild(), LedgerHistory.Source.TARIFFS, + f.getName(), entry.getValue()); + } + } + } + return out; + } + + private static void recordHistory(Map>> out, + Guild receiver, LedgerHistory.Source source, String name, Double amount) { + if (receiver == null || receiver.getBank() == null) return; + if (name == null || amount == null || amount <= 0) return; + out.computeIfAbsent(receiver, g -> new EnumMap<>(LedgerHistory.Source.class)) + .computeIfAbsent(source, s -> new HashMap<>()) + .merge(name, amount, Double::sum); + } + public void populateDailyTransfers(DailyGuildTransfers buffer) { if(guild.isBankrupt() && guild.getLoanHandler() != null && guild.getLoanHandler().getLoansTaken() != null) { for(Loan loan : guild.getLoanHandler().getLoansTaken()) { diff --git a/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java b/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java new file mode 100644 index 00000000..6aab969d --- /dev/null +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/LedgerHistory.java @@ -0,0 +1,135 @@ +package net.tfminecraft.simplefactions.guild.income; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import net.tfminecraft.simplefactions.utils.Formatter; + +/** + * Last-day and lifetime totals behind the ledger view icons, keyed by payer name. This is a + * record of what already moved; settlement never reads it, so it cannot change a bank. + */ +public final class LedgerHistory { + + public enum Source { + CITIZENS, + GUILD_TAXES, + VASSALS, + TRIBUTES, + TARIFFS, + DEPOSITS + } + + private final Map> lastDay = new EnumMap<>(Source.class); + private final Map> lifetime = new EnumMap<>(Source.class); + // Deposits land in the bank straight away, so they are gathered here until the day ends. + private final Map depositsToday = new HashMap<>(); + + public void addDeposit(String name, double amount) { + if (name == null || name.isBlank() || amount <= 0) return; + depositsToday.merge(name, amount, Double::sum); + } + + public Map getDepositsToday() { + return depositsToday; + } + + /** Ends the day: what settled becomes the last day and is added to the lifetime totals. */ + public void closeDay(Map> settled) { + lastDay.clear(); + Map> day = new EnumMap<>(Source.class); + if (settled != null) day.putAll(settled); + day.put(Source.DEPOSITS, new HashMap<>(depositsToday)); + depositsToday.clear(); + + for (Map.Entry> entry : day.entrySet()) { + if (entry.getKey() == null || entry.getValue() == null) continue; + Map kept = new HashMap<>(); + for (Map.Entry amount : entry.getValue().entrySet()) { + String name = amount.getKey(); + Double value = amount.getValue(); + if (name == null || name.isBlank() || value == null || value <= 0) continue; + kept.merge(name, value, Double::sum); + lifetime.computeIfAbsent(entry.getKey(), s -> new HashMap<>()) + .merge(name, value, (a, b) -> Formatter.formatDouble(a + b)); + } + if (!kept.isEmpty()) lastDay.put(entry.getKey(), kept); + } + } + + public Map getLastDay(Source source) { + return lastDay.getOrDefault(source, Map.of()); + } + + public Map getLifetime(Source source) { + return lifetime.getOrDefault(source, Map.of()); + } + + public static double total(Map amounts) { + double total = 0; + for (double value : amounts.values()) total += value; + return total; + } + + public static List> descending(Map amounts) { + return amounts.entrySet().stream() + .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) + .toList(); + } + + // --- Save/load --- + + public Map> getLastDayCopy() { + return copyOut(lastDay); + } + + public Map> getLifetimeCopy() { + return copyOut(lifetime); + } + + public Map getDepositsTodayCopy() { + return new HashMap<>(depositsToday); + } + + public void load(Map> lastDayData, + Map> lifetimeData, + Map depositsTodayData) { + copyIn(lastDayData, lastDay); + copyIn(lifetimeData, lifetime); + depositsToday.clear(); + if (depositsTodayData != null) { + depositsTodayData.forEach((name, value) -> { + if (value != null) addDeposit(name, value); + }); + } + } + + private static Map> copyOut(Map> from) { + Map> out = new HashMap<>(); + from.forEach((source, amounts) -> { + if (!amounts.isEmpty()) out.put(source.name(), new HashMap<>(amounts)); + }); + return out; + } + + private static void copyIn(Map> from, Map> into) { + into.clear(); + if (from == null) return; + for (Map.Entry> entry : from.entrySet()) { + Source source; + try { + source = Source.valueOf(entry.getKey()); + } catch (IllegalArgumentException | NullPointerException e) { + continue; + } + if (entry.getValue() == null) continue; + Map amounts = new HashMap<>(); + entry.getValue().forEach((name, value) -> { + if (name != null && !name.isBlank() && value != null && value > 0) amounts.put(name, value); + }); + if (!amounts.isEmpty()) into.put(source, amounts); + } + } +} diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java index 504954b7..6e605fc2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/CommandManager.java @@ -364,6 +364,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } pouch.change(amount*-1); g.getBank().deposit(amount); + g.getLedger().getHistory().addDeposit(p.getName(), amount); p.sendMessage("§e============§6[Bank Report]§e=============="); p.sendMessage(StringFormatter.formatHex("#6ab05aDeposited: #b39122"+amount+"#dbaf1dd")); p.sendMessage(StringFormatter.formatHex("#3ce8c9New Guild Balance: #b39122"+b.getWealth()+"#dbaf1dd")); @@ -1129,6 +1130,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } pouch.change(amount*-1); f.getBank().deposit(amount); + f.getOrCreateMainGuild().getLedger().getHistory().addDeposit(p.getName(), amount); p.sendMessage("§e============§6[Bank Report]§e=============="); p.sendMessage(StringFormatter.formatHex("#6ab05aDeposited: #b39122"+amount+"#dbaf1dd")); p.sendMessage(StringFormatter.formatHex("#3ce8c9New Faction Balance: #b39122"+b.getWealth()+"#dbaf1dd")); diff --git a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java index 4abbabee..900e021a 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/FactionManager.java @@ -26,6 +26,8 @@ import net.tfminecraft.simplefactions.diplomacy.Relation; import net.tfminecraft.simplefactions.diplomacy.RelationType; import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.guild.income.LedgerHistory; import net.tfminecraft.simplefactions.guild.loans.Loan; import net.tfminecraft.simplefactions.loaders.RankLoader; import net.tfminecraft.simplefactions.loaders.RelationLoader; @@ -506,6 +508,10 @@ public void settleIncome() { runDailyStep("mercenary accrual", () -> net.tfminecraft.simplefactions.mercenary.contract.ContractAccrualService.accrueDailyAndPush()); + // Read the ledger view history before Phase 1 clears the day's citizen taxes. + Map>> history = new HashMap<>(); + runDailyStep("ledger history", () -> history.putAll(Ledger.collectHistoryDay(getAllGuilds()))); + // Phase 1: collect transfers & external deltas for (Guild g : getAllGuilds()) { if (g == null || g.getLedger() == null) continue; @@ -552,6 +558,11 @@ public void settleIncome() { }); } + for (Guild g : getAllGuilds()) { + if (g == null || g.getLedger() == null) continue; + runDailyStep("ledger history " + g.getId(), () -> g.getLedger().getHistory().closeDay(history.get(g))); + } + PostSettlementPayouts.apply( buffer, DenarEconomyPlayerBank.INSTANCE, 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 4e086727..953efb08 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -27,6 +27,7 @@ import net.tfminecraft.simplefactions.guild.branch.BranchModifier; import net.tfminecraft.simplefactions.guild.income.Cashflow; import net.tfminecraft.simplefactions.guild.income.Ledger; +import net.tfminecraft.simplefactions.guild.income.LedgerHistory; import net.tfminecraft.simplefactions.guild.loans.Loan; import net.tfminecraft.simplefactions.guild.loans.LoanHandler; import net.tfminecraft.simplefactions.guild.upgrade.Upgrade; @@ -484,31 +485,12 @@ public ItemStack createDividendItem(Player p, Guild g) { // Preserve the existing additional-tooltip component selection and legacy item text; hiding the whole tooltip is different. @SuppressWarnings({"deprecation"}) public ItemStack createLedgerCitizensItem(Guild g) { - Ledger ledger = g.getLedger(); - ItemStack i = new ItemStack(Material.PLAYER_HEAD); ItemMeta m = i.getItemMeta(); m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); m.setDisplayName(StringFormatter.formatHex("#94b572Citizens")); - - List lore = new ArrayList<>(); - lore.add(StringFormatter.formatHex("#7a706aTop contributors")); - lore.add(""); - - int count = 0; - for (var entry : ledger.getCitizenTaxEntriesDescending()) { - if (count++ >= 5) break; - lore.add(StringFormatter.formatHex( - "#d4c9ae" + entry.getKey() - + "#7a706a: #7fbd73+" - + String.format("%.2f", entry.getValue()) + "d" - )); - } - - if (count == 0) - lore.add(StringFormatter.formatHex("#7a706aNo citizen taxes.")); - - m.setLore(lore); + m.setLore(ledgerSourceLore(g, LedgerHistory.Source.CITIZENS, "Top contributors", + g.getLedger().getCitizenTaxesCopy(), "No citizen taxes.")); i.setItemMeta(m); return i; } @@ -520,30 +502,16 @@ public ItemStack createLedgerGuildsItem(Guild g) { ItemMeta m = i.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#b89448Guild Taxes")); - List lore = new ArrayList<>(); - lore.add(StringFormatter.formatHex("#7a706aPaying guilds")); - lore.add(""); - - int count = 0; + Map today = new HashMap<>(); for (Guild sub : g.getFaction().getGuildHandler().getGuilds()) { if (sub.isBase()) continue; double paid = Math.abs(sub.getLedger().getIncome(Cashflow.GUILD_PAYMENTS)); if (paid <= 0) continue; - - lore.add(StringFormatter.formatHex( - "#d4c9ae" + sub.getName() - + "#7a706a: #7fbd73+" - + String.format("%.2f", paid) + "d" - )); - - if (++count >= 5) break; + today.merge(sub.getName(), paid, Double::sum); } - if (count == 0) - lore.add(StringFormatter.formatHex("#7a706aNo guild taxes.")); - - m.setLore(lore); + m.setLore(ledgerSourceLore(g, LedgerHistory.Source.GUILD_TAXES, "Paying guilds", today, "No guild taxes.")); i.setItemMeta(m); return i; } @@ -555,29 +523,15 @@ public ItemStack createLedgerVassalsItem(Guild g) { ItemMeta m = i.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#7299b5Vassals")); - List lore = new ArrayList<>(); - lore.add(StringFormatter.formatHex("#7a706aSubject contributions")); - lore.add(""); - - int count = 0; + Map today = new HashMap<>(); for (Faction v : RelationManager.getSubjects(g.getFaction())) { Guild vg = v.getOrCreateMainGuild(); double paid = Math.abs(vg.getLedger().getIncome(Cashflow.OVERLORD_TAX)); if (paid <= 0) continue; - - lore.add(StringFormatter.formatHex( - "#d4c9ae" + v.getName() - + "#7a706a: #7fbd73+" - + String.format("%.2f", paid) + "d" - )); - - if (++count >= 5) break; + today.merge(v.getName(), paid, Double::sum); } - if (count == 0) - lore.add(StringFormatter.formatHex("#7a706aNo vassal income.")); - - m.setLore(lore); + m.setLore(ledgerSourceLore(g, LedgerHistory.Source.VASSALS, "Subject contributions", today, "No vassal income.")); i.setItemMeta(m); return i; } @@ -591,12 +545,8 @@ public ItemStack createLedgerTributesItem(Guild g) { ItemMeta m = i.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#ab8568Tributes")); - List lore = new ArrayList<>(); - lore.add(StringFormatter.formatHex("#7a706aTop tribute payers")); - lore.add(""); - // payerFaction -> amount - HashMap received = new HashMap<>(); + Map today = new HashMap<>(); for (Faction payer : FactionManager.getCopy()) { // or FactionManager.factions if you prefer if (payer == null) continue; @@ -605,7 +555,7 @@ public ItemStack createLedgerTributesItem(Guild g) { Guild payerGuild = payer.getOrCreateMainGuild(); if (payerGuild == null) continue; - double base = payerGuild.getLedger().getGrossTaxableIncome(); + double base = payerGuild.getLedger().getInternalTaxableIncome(); if (base <= 0) continue; double totalFromPayer = 0.0; @@ -619,29 +569,11 @@ public ItemStack createLedgerTributesItem(Guild g) { } if (totalFromPayer > 0) { - received.put(payer, totalFromPayer); + today.merge(payer.getName(), totalFromPayer, Double::sum); } } - // sort by value desc - List> top = new ArrayList<>(received.entrySet()); - top.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); - - int shown = 0; - for (var e : top) { - lore.add(StringFormatter.formatHex( - "#d4c9ae" + e.getKey().getName() - + "#7a706a: #7fbd73+" - + String.format("%.2f", e.getValue()) + "d" - )); - if (++shown >= 5) break; - } - - if (shown == 0) { - lore.add(StringFormatter.formatHex("#7a706aNo tributes received.")); - } - - m.setLore(lore); + m.setLore(ledgerSourceLore(g, LedgerHistory.Source.TRIBUTES, "Top tribute payers", today, "No tributes received.")); i.setItemMeta(m); return i; } @@ -653,26 +585,61 @@ public ItemStack createLedgerTariffsItem(Guild g) { ItemMeta m = i.getItemMeta(); m.setDisplayName(StringFormatter.formatHex("#5cc46aTariffs")); - List lore = new ArrayList<>(); - lore.add(StringFormatter.formatHex("#7a706aTop tariff payers")); - lore.add(""); - // payerFaction -> tariffs its guilds pay us - HashMap received = new HashMap<>(); + Map today = new HashMap<>(); for (Guild payer : FactionManager.getAllGuilds()) { if (payer == null || payer.getFaction() == null) continue; double paid = payer.getTradeBreakdown().getTariffsByFaction(g.getFaction()); if (paid <= 0) continue; - received.merge(payer.getFaction(), paid, Double::sum); + today.merge(payer.getFaction().getName(), paid, Double::sum); } - List> top = new ArrayList<>(received.entrySet()); - top.sort((a, b) -> Double.compare(b.getValue(), a.getValue())); + m.setLore(ledgerSourceLore(g, LedgerHistory.Source.TARIFFS, "Top tariff payers", today, "No tariff income.")); + i.setItemMeta(m); + return i; + } + + // Keep the existing legacy text representation, formatting, and exact-string comparisons. + @SuppressWarnings("deprecation") + public ItemStack createLedgerDepositsItem(Guild g) { + ItemStack i = new ItemStack(Material.CHEST); + ItemMeta m = i.getItemMeta(); + m.setDisplayName(StringFormatter.formatHex("#c9a25eDeposits")); + + List lore = new ArrayList<>(); + lore.add(StringFormatter.formatHex("#4c5250§oMember deposits into the bank")); + lore.add(StringFormatter.formatHex("#4c5250§oTracked only, not part of daily income")); + lore.addAll(ledgerSourceLore(g, LedgerHistory.Source.DEPOSITS, "Top depositors", + g.getLedger().getHistory().getDepositsToday(), "No deposits today.")); + m.setLore(lore); + i.setItemMeta(m); + return i; + } + + private static List ledgerSourceLore(Guild g, LedgerHistory.Source source, String title, + Map today, String emptyToday) { + LedgerHistory history = g.getLedger().getHistory(); + List lore = new ArrayList<>(); + lore.add(StringFormatter.formatHex("#7a706a" + title)); + addLedgerSourceSection(lore, "Today", today, emptyToday); + addLedgerSourceSection(lore, "Last day", history.getLastDay(source), "Nothing last day."); + addLedgerSourceSection(lore, "Lifetime", history.getLifetime(source), "Nothing yet."); + return lore; + } + + private static void addLedgerSourceSection(List lore, String header, Map amounts, String empty) { + lore.add(""); + lore.add(StringFormatter.formatHex( + "#f2e5c2" + header + + "#7a706a: #7fbd73+" + + String.format("%.2f", LedgerHistory.total(amounts)) + "d" + )); int count = 0; - for (var e : top) { + for (var e : LedgerHistory.descending(amounts)) { + if (e.getValue() <= 0) continue; lore.add(StringFormatter.formatHex( - "#d4c9ae" + e.getKey().getName() + "#d4c9ae" + e.getKey() + "#7a706a: #7fbd73+" + String.format("%.2f", e.getValue()) + "d" )); @@ -681,11 +648,7 @@ public ItemStack createLedgerTariffsItem(Guild g) { } if (count == 0) - lore.add(StringFormatter.formatHex("#7a706aNo tariff income.")); - - m.setLore(lore); - i.setItemMeta(m); - return i; + lore.add(StringFormatter.formatHex("#7a706a" + empty)); } // Keep the existing legacy text representation, formatting, and exact-string comparisons. 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 33b70eeb..2e35159f 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildView.java @@ -130,6 +130,7 @@ public void ledgerView(Player player, Guild guild, Inventory i) { i.setItem(12, creator.createLedgerVassalsItem(guild)); i.setItem(13, creator.createLedgerTributesItem(guild)); i.setItem(14, creator.createLedgerTariffsItem(guild)); + i.setItem(15, creator.createLedgerDepositsItem(guild)); } i.setItem(26, inv.createBackButton(SFGUI.LEDGER_VIEW)); if(open) player.openInventory(i); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHistoryTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHistoryTest.java new file mode 100644 index 00000000..ae619f8a --- /dev/null +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHistoryTest.java @@ -0,0 +1,135 @@ +package net.tfminecraft.simplefactions.guild.income; + +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.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import net.tfminecraft.simplefactions.database.GuildData; +import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; + +class LedgerHistoryTest { + + @Test + void closeDay_setsLastDayAndAddsToLifetime() { + LedgerHistory history = new LedgerHistory(); + history.closeDay(Map.of(LedgerHistory.Source.CITIZENS, Map.of("Alice", 2.0, "Bob", 1.0))); + history.closeDay(Map.of(LedgerHistory.Source.CITIZENS, Map.of("Alice", 3.0))); + + assertEquals(Map.of("Alice", 3.0), history.getLastDay(LedgerHistory.Source.CITIZENS)); + assertEquals(5.0, history.getLifetime(LedgerHistory.Source.CITIZENS).get("Alice"), 1e-9); + assertEquals(1.0, history.getLifetime(LedgerHistory.Source.CITIZENS).get("Bob"), 1e-9); + } + + @Test + void closeDay_withNothingSettled_clearsLastDayButKeepsLifetime() { + LedgerHistory history = new LedgerHistory(); + history.closeDay(Map.of(LedgerHistory.Source.TARIFFS, Map.of("Rome", 4.0))); + history.closeDay(null); + + assertTrue(history.getLastDay(LedgerHistory.Source.TARIFFS).isEmpty()); + assertEquals(4.0, history.getLifetime(LedgerHistory.Source.TARIFFS).get("Rome"), 1e-9); + } + + @Test + void deposits_rollOverAtDayEnd() { + LedgerHistory history = new LedgerHistory(); + history.addDeposit("Alice", 10.0); + history.addDeposit("Alice", 5.0); + history.addDeposit("Bob", -1.0); + assertEquals(Map.of("Alice", 15.0), history.getDepositsToday()); + assertTrue(history.getLastDay(LedgerHistory.Source.DEPOSITS).isEmpty()); + + history.closeDay(Map.of()); + assertTrue(history.getDepositsToday().isEmpty()); + assertEquals(15.0, history.getLastDay(LedgerHistory.Source.DEPOSITS).get("Alice"), 1e-9); + assertEquals(15.0, history.getLifetime(LedgerHistory.Source.DEPOSITS).get("Alice"), 1e-9); + } + + @Test + void saveThenParse_roundTrips() { + LedgerHistory history = new LedgerHistory(); + history.closeDay(Map.of(LedgerHistory.Source.VASSALS, Map.of("Gaul", 7.5))); + history.addDeposit("Alice", 3.0); + + GuildData data = new GuildData(); + data.ledgerLastDay = history.getLastDayCopy(); + data.ledgerLifetime = history.getLifetimeCopy(); + data.depositsToday = history.getDepositsTodayCopy(); + GuildData restored = JsonUtil.GSON.fromJson(JsonUtil.GSON.toJson(data), GuildData.class); + + LedgerHistory loaded = new LedgerHistory(); + loaded.load(restored.ledgerLastDay, restored.ledgerLifetime, restored.depositsToday); + assertEquals(7.5, loaded.getLastDay(LedgerHistory.Source.VASSALS).get("Gaul"), 1e-9); + assertEquals(7.5, loaded.getLifetime(LedgerHistory.Source.VASSALS).get("Gaul"), 1e-9); + assertEquals(3.0, loaded.getDepositsToday().get("Alice"), 1e-9); + } + + @Test + void oldJsonWithoutHistory_loadsEmpty() { + GuildData data = JsonUtil.GSON.fromJson("{\"id\":\"capital\"}", GuildData.class); + LedgerHistory loaded = new LedgerHistory(); + loaded.load(data.ledgerLastDay, data.ledgerLifetime, data.depositsToday); + for (LedgerHistory.Source source : LedgerHistory.Source.values()) { + assertTrue(loaded.getLastDay(source).isEmpty()); + assertTrue(loaded.getLifetime(source).isEmpty()); + } + assertTrue(loaded.getDepositsToday().isEmpty()); + } + + @Test + void collectHistoryDay_recordsCitizensAndTariffsForTheReceivingCapital() { + Faction rome = mock(Faction.class); + when(rome.getName()).thenReturn("Rome"); + Guild romeCapital = capital(rome, mock(Bank.class)); + romeCapital.getLedger().addCitizenTaxEntry("Alice", 2.0); + + Faction gaul = mock(Faction.class); + when(gaul.getName()).thenReturn("Gaul"); + Guild gaulCapital = capital(gaul, mock(Bank.class)); + TradeBreakdown trade = mock(TradeBreakdown.class); + when(trade.getTariffsByFactionMap()).thenReturn(new java.util.HashMap<>(Map.of(rome, 4.0))); + when(gaulCapital.getTradeBreakdown()).thenReturn(trade); + + var day = Ledger.collectHistoryDay(List.of(romeCapital, gaulCapital)); + + assertEquals(Map.of("Alice", 2.0), day.get(romeCapital).get(LedgerHistory.Source.CITIZENS)); + assertEquals(Map.of("Gaul", 4.0), day.get(romeCapital).get(LedgerHistory.Source.TARIFFS)); + assertFalse(day.containsKey(gaulCapital)); + // Collecting is a read: settlement still clears the citizen taxes afterwards. + assertEquals(2.0, romeCapital.getLedger().getCitizenTaxesCopy().get("Alice"), 1e-9); + } + + @Test + void collectHistoryDay_skipsBankruptPayers() { + Faction rome = mock(Faction.class); + when(rome.getName()).thenReturn("Rome"); + Guild romeCapital = capital(rome, mock(Bank.class)); + when(romeCapital.isBankrupt()).thenReturn(true); + romeCapital.getLedger().addCitizenTaxEntry("Alice", 2.0); + + assertTrue(Ledger.collectHistoryDay(List.of(romeCapital)).isEmpty()); + } + + private static Guild capital(Faction faction, Bank bank) { + Guild guild = mock(Guild.class); + when(guild.getFaction()).thenReturn(faction); + when(guild.isBase()).thenReturn(true); + when(guild.getBank()).thenReturn(bank); + when(faction.getOrCreateMainGuild()).thenReturn(guild); + // No tribute modifiers, so the heavy taxable-income path is not walked. + when(faction.getModifiers()).thenReturn(null); + Ledger ledger = new Ledger(guild); + when(guild.getLedger()).thenReturn(ledger); + return guild; + } +}