diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/FrozenReads.java b/src/main/java/net/tfminecraft/simplefactions/economy/FrozenReads.java index a1b58e50..90537d3d 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/FrozenReads.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/FrozenReads.java @@ -16,7 +16,7 @@ import net.tfminecraft.simplefactions.objects.Faction; /** - * Guild treasuries, node upkeep and espionage stability read once on the main thread. + * Guild treasuries, held tax, node upkeep and espionage stability read once on the main thread. * DenarEconomy, Dowsing and TLibs character identity refuse reads from other threads, so * income previews that run async read these instead. */ @@ -34,7 +34,7 @@ private FrozenReads( this.espionage = Map.copyOf(espionage); } - /** Reads every guild treasury and node upkeep and every realm espionage modifier now. Call on the main thread. */ + /** Reads guild treasuries, held tax and node upkeep and realm espionage modifiers now. Call on the main thread. */ public static FrozenReads capture() { long now = System.currentTimeMillis(); Map> espionage = new HashMap<>(); @@ -58,6 +58,8 @@ public static FrozenReads capture() { } UUID incarnation = guild == null ? null : guild.getIncarnationId(); if (incarnation != null) { + String taxAccount = Denars.guildTax(incarnation); + cents.put(taxAccount, Denars.balance(taxAccount)); nodeUpkeep.put(incarnation, Ledger.liveNodeUpkeep(guild)); } } 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 b7b8e41d..d521bfb2 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -8,7 +8,9 @@ import java.util.Set; import java.util.function.ToDoubleFunction; +import net.tfminecraft.denareconomy.api.Denars; import net.tfminecraft.simplefactions.economy.FrozenReads; +import net.tfminecraft.simplefactions.economy.Money; import net.tfminecraft.simplefactions.vehicles.pool.FactionVehiclePoolService; @@ -142,6 +144,25 @@ public Map getCitizenTaxesCopy() { return new HashMap<>(citizenTaxes); } + /** Held tax is authoritative; legacy contributor rows describe only part of it. */ + public Map getCitizenTaxBreakdown() { + double collected = getCollectedCitizenTax(); + if (collected <= 0) return Map.of(); + if (!Double.isFinite(LedgerHistory.total(citizenTaxes))) { + return Map.of("Other citizen taxes", collected); + } + Map amounts = new HashMap<>(); + citizenTaxes.forEach((name, value) -> { + double amount = Formatter.formatDouble(value); + if (amount > 0) amounts.put(name, amount); + }); + double attributed = Formatter.formatDouble(LedgerHistory.total(amounts)); + if (attributed > collected) return Map.of("Other citizen taxes", collected); + double remaining = Formatter.formatDouble(collected - attributed); + if (remaining > 0) amounts.merge("Other citizen taxes", remaining, Double::sum); + return amounts; + } + /** Seeded from disk at load, so an unsettled day survives a restart. */ public void setCitizenTaxes(Map taxes) { citizenTaxes.clear(); @@ -286,7 +307,7 @@ public double getIncome(Cashflow cashflow) { break; case CITIZENS: if(!guild.isBase()) return 0; - amount = getAggregatedCitizenTax(); + amount = getCollectedCitizenTax(); break; case TARIFFS: if(!guild.isBase()) return 0; @@ -466,18 +487,18 @@ public double getTotalTariffsEarned() { } public List> getCitizenTaxEntriesDescending() { - return citizenTaxes.entrySet().stream() + return getCitizenTaxBreakdown().entrySet().stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) .toList(); } - private double getAggregatedCitizenTax() { - double total = 0; - for(Double d : citizenTaxes.values()) { - total+=d; + private double getCollectedCitizenTax() { + if (settlement != null && settlement.income.containsKey(Cashflow.CITIZENS)) { + return settlement.income.get(Cashflow.CITIZENS); } - return total; + var incarnation = guild.getIncarnationId(); + return incarnation == null ? 0.0 : Money.denars(FrozenReads.balance(Denars.guildTax(incarnation))); } private double getAggregatedLoanPayments() { @@ -894,7 +915,7 @@ public static Map>> collect if (f == null || ledger.skipsMoneyMovement()) continue; if (payer.isBase()) { - ledger.citizenTaxes.forEach((name, tax) -> + ledger.getCitizenTaxBreakdown().forEach((name, tax) -> recordHistory(out, payer, LedgerHistory.Source.CITIZENS, name, tax)); Faction overlord = f.getOverlord(); 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 2b794f04..8fc6e9b9 100644 --- a/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java +++ b/src/main/java/net/tfminecraft/simplefactions/managers/inventory/GuildCreator.java @@ -589,7 +589,7 @@ public ItemStack createLedgerCitizensItem(Guild g) { m.addItemFlags(ItemFlag.HIDE_ADDITIONAL_TOOLTIP); m.setDisplayName(StringFormatter.formatHex("#94b572Citizens")); m.setLore(ledgerSourceLore(g, LedgerHistory.Source.CITIZENS, "Top contributors", - g.getLedger().getCitizenTaxesCopy(), "No citizen taxes.")); + g.getLedger().getCitizenTaxBreakdown(), "No citizen taxes.")); i.setItemMeta(m); return i; } diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/FrozenReadsTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/FrozenReadsTest.java index 3716ca65..5d2902d1 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/FrozenReadsTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/FrozenReadsTest.java @@ -84,6 +84,26 @@ void asyncReadsUseBalancesCapturedOnTheMainThread() throws Exception { assertEquals(0L, frozen.read(() -> FrozenReads.balance("unknown"))); } + @Test + void asyncCitizenTaxUsesTheHoldingBalanceCapturedOnTheMainThread() throws Exception { + when(guild.isBase()).thenReturn(true); + when(guild.getFaction()).thenReturn(FactionManager.factions.getFirst()); + Ledger ledger = new Ledger(guild); + String holding = Denars.guildTax(guild.getIncarnationId()); + FrozenReads frozen; + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.balance(anyString())).thenReturn(0L); + denars.when(() -> Denars.balance(holding)).thenReturn(1234L); + frozen = FrozenReads.capture(); + denars.when(() -> Denars.balance(holding)).thenReturn(5000L); + assertEquals(50.0, ledger.getIncome(Cashflow.CITIZENS)); + } + + assertEquals(12.34, CompletableFuture.supplyAsync(() -> frozen.read(() -> ledger.getIncome(Cashflow.CITIZENS))).get()); + assertEquals(Map.of("Other citizen taxes", 12.34), CompletableFuture + .supplyAsync(() -> frozen.read(ledger::getCitizenTaxBreakdown)).get()); + } + @Test void readRestoresTheLiveLedgerAfterwards() { try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java index 4be963d0..caaa2472 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java @@ -83,6 +83,18 @@ RealmSettlementChanges changes(SettlementJournal.Plan plan) { assertTrue(changes(plan).guilds.stream().filter(c -> c.incarnation.equals(capital.getIncarnationId())).findFirst().orElseThrow().citizenTaxes.isEmpty()); } + @Test void heldTaxWithoutLegacyEntriesIsIncludedInSettlementHistory() { + denars.when(() -> Denars.balance(Denars.guildTax(capital.getIncarnationId()))).thenReturn(1234L); + var plan = plan(List.of()); + assertEquals(-1234, net(plan, Denars.guildTax(capital.getIncarnationId()))); + assertEquals(1234, net(plan, capital.getBank().accountId())); + var saved = changes(plan).guilds.stream() + .filter(c -> c.incarnation.equals(capital.getIncarnationId())).findFirst().orElseThrow(); + assertEquals(Map.of("Other citizen taxes", 12.34), saved.ledgerLastDay.get("CITIZENS")); + assertEquals(Map.of("Other citizen taxes", 12.34), saved.ledgerLifetime.get("CITIZENS")); + assertTrue(capital.getLedger().getCitizenTaxesCopy().isEmpty()); + } + @Test void debtIsPreservedAndDailyShortfallsLiquidateWithoutTouchingLiveBranches() { guild.getBranch(0).levelUp(); guild.getBranch(0).levelUp(); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerCitizenTaxPersistenceTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerCitizenTaxPersistenceTest.java index 68fee263..f79903d6 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerCitizenTaxPersistenceTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerCitizenTaxPersistenceTest.java @@ -4,21 +4,90 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; import java.util.Collections; import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.Test; import net.tfminecraft.simplefactions.database.GuildData; import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.economy.FrozenReads; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.guild.loans.LoanHandler; +import net.tfminecraft.simplefactions.objects.Bank; +import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.utils.DailyGuildTransfers; class LedgerCitizenTaxPersistenceTest { + @Test + void heldTaxIsVisibleWithoutLegacyContributorEntries() { + Guild guild = mock(Guild.class); + UUID incarnation = UUID.randomUUID(); + when(guild.getIncarnationId()).thenReturn(incarnation); + when(guild.isBase()).thenReturn(true); + when(guild.getBank()).thenReturn(mock(Bank.class)); + when(guild.getFaction()).thenReturn(mock(Faction.class)); + Ledger ledger = new Ledger(guild); + try (var reads = mockStatic(FrozenReads.class)) { + reads.when(() -> FrozenReads.balance("guildtax:" + incarnation)).thenReturn(1234L); + assertTrue(ledger.getCitizenTaxesCopy().isEmpty()); + assertEquals(12.34, ledger.getIncome(Cashflow.CITIZENS)); + assertEquals(Map.of("Other citizen taxes", 12.34), ledger.getCitizenTaxBreakdown()); + reads.when(() -> FrozenReads.balance("guildtax:" + incarnation)).thenReturn(0L); + assertEquals(0.0, ledger.getIncome(Cashflow.CITIZENS)); + assertTrue(ledger.getCitizenTaxBreakdown().isEmpty()); + } + } + + @Test + void fractionalLegacyContributorsAreRoundedBeforeReconcilingHeldTax() { + Guild guild = mock(Guild.class); + UUID incarnation = UUID.randomUUID(); + when(guild.getIncarnationId()).thenReturn(incarnation); + Ledger ledger = new Ledger(guild); + ledger.setCitizenTaxes(Map.of("Alice", 0.005)); + try (var reads = mockStatic(FrozenReads.class)) { + reads.when(() -> FrozenReads.balance("guildtax:" + incarnation)).thenReturn(1L); + assertEquals(Map.of("Alice", 0.01), ledger.getCitizenTaxBreakdown()); + assertEquals(Map.of("Alice", 0.005), ledger.getCitizenTaxesCopy()); + ledger.setCitizenTaxes(Map.of("Alice", 0.005, "Bob", 0.005)); + assertEquals(Map.of("Other citizen taxes", 0.01), ledger.getCitizenTaxBreakdown()); + ledger.setCitizenTaxes(Map.of("Alice", 0.004, "Bob", 0.004)); + assertEquals(Map.of("Other citizen taxes", 0.01), ledger.getCitizenTaxBreakdown()); + } + } + + @Test + void contributorBreakdownReconcilesToHeldTaxAndDoesNotChangeSavedEntries() { + Guild guild = mock(Guild.class); + UUID incarnation = UUID.randomUUID(); + when(guild.getIncarnationId()).thenReturn(incarnation); + Ledger ledger = new Ledger(guild); + ledger.setCitizenTaxes(Map.of("Alice", 0.20, "Bob", 0.10)); + try (var reads = mockStatic(FrozenReads.class)) { + reads.when(() -> FrozenReads.balance("guildtax:" + incarnation)).thenReturn(80L); + assertEquals(Map.of("Alice", 0.20, "Bob", 0.10, "Other citizen taxes", 0.50), ledger.getCitizenTaxBreakdown()); + assertEquals("Other citizen taxes", ledger.getCitizenTaxEntriesDescending().getFirst().getKey()); + reads.when(() -> FrozenReads.balance("guildtax:" + incarnation)).thenReturn(30L); + Map displayed = ledger.getCitizenTaxBreakdown(); + assertEquals(Map.of("Alice", 0.20, "Bob", 0.10), displayed); + displayed.clear(); + assertEquals(Map.of("Alice", 0.20, "Bob", 0.10), ledger.getCitizenTaxesCopy()); + reads.when(() -> FrozenReads.balance("guildtax:" + incarnation)).thenReturn(15L); + assertEquals(Map.of("Other citizen taxes", 0.15), ledger.getCitizenTaxBreakdown()); + reads.when(() -> FrozenReads.balance("guildtax:" + incarnation)).thenReturn(0L); + assertTrue(ledger.getCitizenTaxBreakdown().isEmpty()); + ledger.setCitizenTaxes(Map.of("Alice", Double.NaN)); + reads.when(() -> FrozenReads.balance("guildtax:" + incarnation)).thenReturn(30L); + assertEquals(Map.of("Other citizen taxes", 0.30), ledger.getCitizenTaxBreakdown()); + } + } + @Test void oldJsonWithoutCitizenTaxes_loadsEmpty() { GuildData data = JsonUtil.GSON.fromJson("{\"id\":\"capital\"}", GuildData.class); diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHistoryTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHistoryTest.java index ae619f8a..81b507cd 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHistoryTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerHistoryTest.java @@ -4,15 +4,18 @@ 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.mockStatic; import static org.mockito.Mockito.when; import java.util.List; import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.Test; import net.tfminecraft.simplefactions.database.GuildData; import net.tfminecraft.simplefactions.database.JsonUtil; +import net.tfminecraft.simplefactions.economy.FrozenReads; import net.tfminecraft.simplefactions.guild.Guild; import net.tfminecraft.simplefactions.objects.Bank; import net.tfminecraft.simplefactions.objects.Faction; @@ -100,11 +103,15 @@ void collectHistoryDay_recordsCitizensAndTariffsForTheReceivingCapital() { 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)); + UUID incarnation = UUID.randomUUID(); + when(romeCapital.getIncarnationId()).thenReturn(incarnation); + try (var reads = mockStatic(FrozenReads.class)) { + reads.when(() -> FrozenReads.balance("guildtax:" + incarnation)).thenReturn(200L); + 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); } diff --git a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java index 0c1fa7c2..f6d5cfc8 100644 --- a/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/guild/income/LedgerSettlementCoverageTest.java @@ -73,6 +73,8 @@ void independentDailyEntriesAccumulateAndSettlementClearsThemExactlyOnce() { ledger.setCitizenTaxes(saved); ledger.addCitizenTaxEntry("Alice", 3.0); ledger.addCitizenTaxEntry("Bob", 9.0); + String holding = net.tfminecraft.denareconomy.api.Denars.guildTax(capital.getIncarnationId()); + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(holding, 1400L); assertEquals(Map.of("Alice", 5.0, "Bob", 9.0), ledger.getCitizenTaxesCopy()); assertEquals( List.of("Bob", "Alice"), @@ -104,6 +106,8 @@ void independentDailyEntriesAccumulateAndSettlementClearsThemExactlyOnce() { assertEquals(33, first.getExternalDeltas().get(capital)); assertEquals(0, ledger.getCasinoProfit()); assertTrue(ledger.getCitizenTaxesCopy().isEmpty()); + // The legacy transfer buffer does not post money; simulate its holding-account sweep. + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(holding, 0L); for (Cashflow kind : List.of(Cashflow.LOANS, Cashflow.INTEREST, Cashflow.MERCENARY_PAYMENTS, Cashflow.REFUNDS)) assertEquals(0, ledger.getIncome(kind)); diff --git a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java index 793364b4..67b29adb 100644 --- a/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/managers/inventory/GuildMenusCoverageTest.java @@ -578,6 +578,20 @@ void iconOverridesRetainMenuContentAndInactivityDoesNotHideUpgradeLevels() { assertLore(creator.createUpgradesItem(fixture.player, guild), "Inactive output -40%", true); } + @Test + void citizenTooltipShowsHeldTaxWithoutLegacyContributorRows() { + var incarnation = guild.getIncarnationId(); + try (var reads = mockStatic(net.tfminecraft.simplefactions.economy.FrozenReads.class)) { + reads.when(() -> net.tfminecraft.simplefactions.economy.FrozenReads.balance("guildtax:" + incarnation)) + .thenReturn(1234L); + assertTrue(guild.getLedger().getCitizenTaxesCopy().isEmpty()); + ItemStack citizens = creator.createLedgerCitizensItem(guild); + assertLore(citizens, "Today: +12.34d", true); + assertLore(citizens, "Other citizen taxes: +12.34d", true); + assertLore(citizens, "No citizen taxes", false); + } + } + @Test void ledgerSourcesSortCapAndSeparateTodayFromHistoricalTotals() { Guild display = displayGuild(guild); @@ -586,7 +600,7 @@ void ledgerSourcesSortCapAndSeparateTodayFromHistoricalTotals() { Map taxes = new LinkedHashMap<>(); for (int n = 0; n < 7; n++) taxes.put("Citizen" + n, (double) n); taxes.put("Negative", -2.0); - when(ledger.getCitizenTaxesCopy()).thenReturn(taxes); + when(ledger.getCitizenTaxBreakdown()).thenReturn(taxes); history.closeDay(Map.of(LedgerHistory.Source.CITIZENS, Map.of("Yesterday", 15.0))); history.addDeposit("Leader", 25); history.addDeposit("Alice", 10); @@ -596,7 +610,7 @@ void ledgerSourcesSortCapAndSeparateTodayFromHistoricalTotals() { assertLore(citizens, "Citizen1", false); assertLore(citizens, "Yesterday", true); assertLore(citizens, "Negative", false); - when(ledger.getCitizenTaxesCopy()).thenReturn(Map.of("Zero", 0.0)); + when(ledger.getCitizenTaxBreakdown()).thenReturn(Map.of("Zero", 0.0)); assertLore(creator.createLedgerCitizensItem(display), "No citizen taxes", true); Guild paying = displayGuild(fixture.guild("guild", 0)); when(paying.getLedger().getIncome(Cashflow.GUILD_PAYMENTS)).thenReturn(-12.0);