From 3bdc1ef1b946de6b8b7f23ebe14237683da2d215 Mon Sep 17 00:00:00 2001 From: Drefvelin <54400154+Drefvelin@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:22:29 +0000 Subject: [PATCH] Pay army, installation and loan upkeep from the day's income Daily settlement planned army and installation upkeep against the ruling guild's balance before any of the day's income arrived, and funded automatic loan payments from that same pre-income balance. A realm that spent its treasury down lost its paid regiment slots and installations even when the day's taxes would have covered them. Settlement now runs in this order: income and the day's other transfers, then army and installation upkeep, then automatic loans, then branch liquidation, dividends and wages, and vehicles last. The upkeep plan is no longer saved in day preparation. The settlement journal already keeps the final plan, and planning after the province removal means a lost province's installations are no longer charged. Co-Authored-By: Claude Opus 5.5 --- .../settlement/DailyPreparationJournal.java | 24 ++------- .../economy/settlement/FactionUpkeepPlan.java | 14 ++++- .../RealmSettlementCalculation.java | 52 ++++++++++--------- .../settlement/RealmSettlementService.java | 2 +- .../simplefactions/guild/income/Ledger.java | 49 ++++++++++------- .../DailyPreparationJournalTest.java | 4 +- .../RealmSettlementCalculationTest.java | 47 +++++++++++++++++ 7 files changed, 122 insertions(+), 70 deletions(-) diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java index 3bcb70bd..f49abc98 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournal.java @@ -12,8 +12,6 @@ import net.tfminecraft.simplefactions.database.Database; import net.tfminecraft.simplefactions.database.DurableJson; import net.tfminecraft.simplefactions.government.movement.Movement; -import net.tfminecraft.simplefactions.installation.InstallationKind; -import net.tfminecraft.simplefactions.loaders.InstallationConfigLoader; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.TitleManager; import net.tfminecraft.simplefactions.objects.Faction; @@ -23,12 +21,11 @@ /** Replays the nonfinancial preparation of a day before its monetary plan is published. */ public final class DailyPreparationJournal { private record Removal(String faction, UUID incarnation, Integer province) {} - public record Prepared(long day, long timestamp, List upkeep) {} + public record Prepared(long day, long timestamp) {} private static final class Saved { long day; long timestamp; List removals; - List upkeep; Map> organization; Map offlineDays; boolean prepared; @@ -62,10 +59,8 @@ public Prepared prepare(long day, long now) throws IOException { saved.day = day; saved.timestamp = now; saved.removals = new ArrayList<>(); - saved.upkeep = new ArrayList<>(); for (Faction faction : FactionManager.factions.stream().sorted(Comparator.comparing(Faction::getId)).toList()) { saved.removals.add(new Removal(faction.getId(), SettlementGuilds.capital(faction).getIncarnationId(), removal(faction))); - saved.upkeep.add(FactionUpkeepPlan.calculate(faction)); } DurableJson.write(path, saved); } @@ -89,8 +84,6 @@ public Prepared prepare(long day, long now) throws IOException { saved.organization.put(faction.getId(), values); } saved.offlineDays = FactionCleanup.planOfflineDays(FactionManager.factions); - List removals = saved.removals; - saved.upkeep = saved.upkeep.stream().map(plan -> afterProvinceLoss(plan, removals)).toList(); DurableJson.write(path, saved); } for (Removal removal : saved.removals) { @@ -106,7 +99,7 @@ public Prepared prepare(long day, long now) throws IOException { saved.prepared = true; DurableJson.write(path, saved); } - return new Prepared(saved.day, saved.timestamp, saved.upkeep); + return new Prepared(saved.day, saved.timestamp); } public void complete(long day) throws IOException { @@ -116,17 +109,6 @@ public void complete(long day) throws IOException { DurableJson.write(path, saved); } - private static FactionUpkeepPlan afterProvinceLoss(FactionUpkeepPlan plan, List removals) { - Integer lost = removals.stream().filter(r -> r.faction().equals(plan.faction())).findFirst().orElseThrow().province(); - if (lost == null) return plan; - var installations = plan.installations().stream().filter(i -> !lost.equals(i.province)).toList(); - var construction = plan.construction(); - if (construction != null && lost.equals(construction.province)) construction = null; - double cost = installations.stream().mapToDouble(i -> InstallationConfigLoader.getDailyUpkeep(InstallationKind.fromCommand(i.kind), i.level)).sum(); - return new FactionUpkeepPlan(plan.faction(), plan.regimentSlots(), installations, construction, - plan.charge(), plan.armyLedgerCost(), cost); - } - private static Integer removal(Faction faction) { List provinces = faction.getProvinces(); if (CivilWarBorderLock.isLocked(faction) || net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(faction) @@ -151,7 +133,7 @@ private void save(Faction faction) throws IOException { private static Saved read(Path path) throws IOException { Saved saved = DurableJson.read(path, Saved.class); if (!path.getFileName().toString().equals(saved.day + ".json") || saved.timestamp <= 0 - || saved.removals == null || saved.upkeep == null || (saved.prepared && saved.organization == null)) + || saved.removals == null || (saved.prepared && saved.organization == null)) throw new IOException("Invalid daily preparation: " + path); return saved; } diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java index f578f144..2eb777ee 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/FactionUpkeepPlan.java @@ -15,12 +15,22 @@ import net.tfminecraft.simplefactions.objects.Faction; import net.tfminecraft.simplefactions.utils.Formatter; -/** The pre-income army and installation decisions, calculated without changing the faction. */ +/** The army and installation decisions, calculated without changing the faction. */ public record FactionUpkeepPlan(String faction, Map regimentSlots, List installations, InstallationConstructionData construction, long charge, double armyLedgerCost, double installationLedgerCost) { + /** Plans against the ruling guild's balance as it stands now. */ public static FactionUpkeepPlan calculate(Faction faction) { + var bank = SettlementGuilds.capital(faction).getBank(); + return calculate(faction, bank == null ? 0 : bank.getWealth()); + } + + /** + * Plans against {@code available}: during settlement, the ruling guild's balance once the + * day's income and other transfers have arrived, so upkeep is paid from that income. + */ + public static FactionUpkeepPlan calculate(Faction faction, double available) { if (net.tfminecraft.simplefactions.guild.HeldRealms.isHeld(faction)) return paused(faction); List regiments = new ArrayList<>(); if (faction.getMilitary() != null) { @@ -29,7 +39,7 @@ public static FactionUpkeepPlan calculate(Faction faction) { } } boolean bank = SettlementGuilds.capital(faction).getBank() != null; - double wealth = bank ? SettlementGuilds.capital(faction).getBank().getWealth() : 0; + double wealth = bank ? available : 0; double factor = 1 + faction.getModifier(FactionModifiers.MILITARY_UPKEEP).getAmount() / 100; double army = armyCost(regiments, factor); if (!bank && army > 0) { diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java index 89470c73..363fd721 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculation.java @@ -49,7 +49,6 @@ public interface Vehicles { private final long now; private final List guilds; private final List claims; - private List preparedUpkeep; private final Map ledgers = new LinkedHashMap<>(); private final Map views = new LinkedHashMap<>(); private final Map companies = new LinkedHashMap<>(); @@ -67,22 +66,15 @@ public RealmSettlementCalculation(long day, long now, Collection guilds, this.claims = List.copyOf(claims); } - public RealmSettlementCalculation withPreparedUpkeep(List upkeep) { - this.preparedUpkeep = List.copyOf(upkeep); - return this; - } - public SettlementJournal.Plan calculate(Vehicles vehicles) { capture(); accrue(); var history = Ledger.collectHistoryDay(guilds, ledgers::get); Map beforeIncome = new LinkedHashMap<>(balances); + // Income and the day's other payments land first, so upkeep and loans are paid from them. DailyGuildTransfers buffer = new DailyGuildTransfers(); - for (Guild guild : guilds) ledgers.get(guild).populateDailyTransfers(buffer); - for (var from : buffer.getTransfers().entrySet()) { - for (var to : from.getValue().entrySet()) transfer(account(from.getKey()), account(to.getKey()), Money.cents(to.getValue())); - } - for (var entry : buffer.getExternalDeltas().entrySet()) transfer(Denars.MINT, account(entry.getKey()), Money.cents(entry.getValue())); + for (Guild guild : guilds) ledgers.get(guild).populateTransfersBeforeLoans(buffer); + apply(buffer); for (Guild guild : guilds) { if (canMove(guild) && guild.isBase()) { // CITIZENS in the transfer buffer is backed by held tax, never newly minted. @@ -90,6 +82,13 @@ public SettlementJournal.Plan calculate(Vehicles vehicles) { transfer(Denars.guildTax(guild.getIncarnationId()), Denars.MINT, held); } } + chargeUpkeep(); + DailyGuildTransfers loanPayments = new DailyGuildTransfers(); + for (Guild guild : guilds) { + long left = balances.getOrDefault(account(guild), 0L); + ledgers.get(guild).populateLoanTransfers(loanPayments, Money.denars(Math.max(0, left))); + } + apply(loanPayments); for (Guild guild : guilds) liquidate(guild, !java.util.Objects.equals(beforeIncome.get(account(guild)), balances.get(account(guild)))); for (Guild guild : guilds) payouts(guild, buffer); payOrphanClaims(); @@ -114,8 +113,24 @@ public SettlementJournal.Plan calculate(Vehicles vehicles) { return new SettlementJournal.Plan(day, posting, JsonUtil.GSON.toJsonTree(changes).getAsJsonObject()); } + private void apply(DailyGuildTransfers buffer) { + for (var from : buffer.getTransfers().entrySet()) { + for (var to : from.getValue().entrySet()) transfer(account(from.getKey()), account(to.getKey()), Money.cents(to.getValue())); + } + for (var entry : buffer.getExternalDeltas().entrySet()) transfer(Denars.MINT, account(entry.getKey()), Money.cents(entry.getValue())); + } + + /** Army and installation upkeep, planned against each ruling guild's balance after the day's income. */ + private void chargeUpkeep() { + for (Faction faction : guilds.stream().map(Guild::getFaction).distinct().toList()) { + Guild capital = SettlementGuilds.capital(faction); + FactionUpkeepPlan plan = FactionUpkeepPlan.calculate(faction, Money.denars(balances.get(account(capital)))); + changes.upkeep.add(plan); + transfer(account(capital), Denars.MINT, plan.charge()); + } + } + private void capture() { - Map upkeep = new LinkedHashMap<>(); for (Guild guild : guilds) { balances.put(account(guild), guild.getBank() == null ? 0 : Money.cents(guild.getBank().getWealth())); initial.put(guild, guild.getBank() == null ? 0 : guild.getBank().getWealth()); @@ -128,15 +143,6 @@ private void capture() { companies.put(guild, copy); } } - for (Faction faction : guilds.stream().map(Guild::getFaction).distinct().toList()) { - FactionUpkeepPlan plan = preparedUpkeep == null ? FactionUpkeepPlan.calculate(faction) - : preparedUpkeep.stream().filter(p -> p.faction().equals(faction.getId())).findFirst().orElseThrow(); - changes.upkeep.add(plan); - upkeep.put(faction, plan); - Guild capital = SettlementGuilds.capital(faction); - transfer(account(capital), Denars.MINT, plan.charge()); - initial.put(capital, Money.denars(balances.get(account(capital)))); - } for (Guild guild : guilds) { for (Loan original : guild.getLoanHandler().getLoansGiven()) { if (!credits.containsKey(original.getBorrower()) || original.getIssuer() != guild) @@ -148,10 +154,6 @@ private void capture() { for (Guild guild : guilds) { Map income = new EnumMap<>(Cashflow.class); if (guild.isBase()) { - FactionUpkeepPlan costs = upkeep.get(guild.getFaction()); - MercenaryCompany company = companies.get(guild); - income.put(Cashflow.MILITARY_UPKEEP, -costs.armyLedgerCost() - (company == null || !company.isFormed() ? 0 : company.getSlotUpkeep())); - income.put(Cashflow.INSTALLATIONS, -costs.installationLedgerCost()); income.put(Cashflow.CITIZENS, Money.denars(Denars.balance(Denars.guildTax(guild.getIncarnationId())))); } SettlementLedgerView view = new SettlementLedgerView(ledgers, diff --git a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java index 9c0c9c98..c6f7c587 100644 --- a/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java +++ b/src/main/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementService.java @@ -38,7 +38,7 @@ public void settle(long day) throws IOException { var claims = CharacterDeaths.pendingPayments().stream().map(payment -> new RealmSettlementCalculation.Claim( payment.id(), payment.guildIncarnation(), payment.characterId(), payment.cents(), payment.kind())).toList(); return new RealmSettlementCalculation(day, prepared.timestamp(), FactionManager.getAllGuilds(), claims) - .withPreparedUpkeep(prepared.upkeep()).calculate((balances, legs, changes) -> VehicleSettlement.calculate(balances, legs, changes, prepared.timestamp())); + .calculate((balances, legs, changes) -> VehicleSettlement.calculate(balances, legs, changes, prepared.timestamp())); }, effects); if (result != Result.APPLIED) throw new IOException("Realm settlement " + day + " remains " + result); preparation.complete(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 6879d607..ce0af2f6 100644 --- a/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java +++ b/src/main/java/net/tfminecraft/simplefactions/guild/income/Ledger.java @@ -1006,6 +1006,26 @@ private double loanBudget(DailyGuildTransfers buffer) { } public void populateDailyTransfers(DailyGuildTransfers buffer) { + populateTransfersBeforeLoans(buffer); + populateLoanTransfers(buffer, loanBudget(buffer)); + if (settlement != null) return; + if (!skipsMoneyMovement()) { + citizenTaxes.clear(); + } + loanPayments.clear(); + 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(); + refunds.clear(); + clearSettledContractBuckets(); + } + + /** Every daily payment except the automatic loan payments, which come last. */ + public void populateTransfersBeforeLoans(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(); @@ -1027,30 +1047,21 @@ public void populateDailyTransfers(DailyGuildTransfers buffer) { if (cf == Cashflow.LOAN_PAYMENTS || cf == Cashflow.INTEREST_PAYMENTS) continue; applySettlementFor(cf, buffer); } - // Loans are paid last, from what is left after today's other payments. The lender is - // credited in full, so an unfunded payment would create money; any shortfall stays owed. + } + + /** + * Loans are paid last, from {@code budget}: what is left after today's other payments. The + * lender is credited in full, so an unfunded payment would create money; any shortfall stays owed. + */ + public void populateLoanTransfers(DailyGuildTransfers buffer, double budget) { loanPlan = guild.getLoanHandler() == null ? Map.of() - : LoanFunding.plan(loansTaken(), loanBudget(buffer)); + : LoanFunding.plan(loansTaken(), Math.max(0.0, budget)); try { applySettlementFor(Cashflow.LOAN_PAYMENTS, buffer); applySettlementFor(Cashflow.INTEREST_PAYMENTS, buffer); } finally { loanPlan = null; } - if (settlement != null) return; - if (!skipsMoneyMovement()) { - citizenTaxes.clear(); - } - loanPayments.clear(); - 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(); - refunds.clear(); - clearSettledContractBuckets(); } /** @@ -1113,7 +1124,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case TRADE_UPKEEP: buffer.addExternalDelta(guild, getIncome(cf)); return; - // INSTALLATIONS: charged by the pre-income upkeep plan, so getIncome() is ledger GUI display only. + // INSTALLATIONS: charged by the upkeep plan after income, so getIncome() is ledger GUI display only. // VEHICLE_UPKEEP: withdrawn in VehicleUpkeepService for pool and installation vehicles. case UPGRADES_UPKEEP: case PENALTIES: @@ -1131,7 +1142,7 @@ private void applySettlementFor(Cashflow cf, DailyGuildTransfers buffer) { case VEHICLE_FEES: return; - // The faction share of military upkeep is charged by the pre-income upkeep plan; + // The faction share of military upkeep is charged by the upkeep plan after income; // only the company's slot upkeep settles here. case MILITARY_UPKEEP: { double slots = getCompanySlotUpkeep(); diff --git a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java index 2c132bc6..690d6605 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/DailyPreparationJournalTest.java @@ -71,9 +71,9 @@ class DailyPreparationJournalTest { net.tfminecraft.simplefactions.guild.HeldRealms.hold(faction, leader, 1); var database = mock(Database.class); when(database.saveFactionDurably(faction)).thenReturn(true); - var prepared = new DailyPreparationJournal(disk.root.resolve("day-preparation"), database).prepare(2, 1000); + new DailyPreparationJournal(disk.root.resolve("day-preparation"), database).prepare(2, 1000); assertEquals(List.of(1, 2), faction.getProvinces()); - var upkeep = prepared.upkeep().getFirst(); + var upkeep = FactionUpkeepPlan.calculate(faction, 0); assertEquals(0, upkeep.charge()); assertEquals(Map.of("guard", 6), upkeep.regimentSlots()); } 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 449b32ab..32d3c367 100644 --- a/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java +++ b/src/test/java/net/tfminecraft/simplefactions/economy/settlement/RealmSettlementCalculationTest.java @@ -114,6 +114,53 @@ RealmSettlementChanges changes(SettlementJournal.Plan plan) { assertTrue(capital.getLedger().getCitizenTaxesCopy().isEmpty()); } + net.tfminecraft.simplefactions.army.Regiment guard(int slots) { + // 2 free slots; each paid slot costs 5.00 a day. + var guard = new net.tfminecraft.simplefactions.army.Regiment(fixture.regiment("guard", false, 2, 5)); + guard.setCurrentSlots(slots); + faction.getMilitary().getRegiments().add(guard); + return guard; + } + + FactionUpkeepPlan upkeep(SettlementJournal.Plan plan) { + return changes(plan).upkeep.stream().filter(u -> u.faction().equals(faction.getId())).findFirst().orElseThrow(); + } + + @Test void todaysIncomePaysTheArmyWhenTheTreasuryAloneCannot() { + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(capital.getBank(), 1.0); + var guard = guard(6); + denars.when(() -> Denars.balance(Denars.guildTax(capital.getIncarnationId()))).thenReturn(5000L); + var plan = plan(List.of()); + assertEquals(6, upkeep(plan).regimentSlots().get("guard")); + assertEquals(2000, upkeep(plan).charge()); + assertEquals(5000 - 2000, net(plan, capital.getBank().accountId())); + assertEquals(6, guard.getCurrentSlots()); + } + + @Test void paidSlotsGoOnlyWhenIncomeAndTreasuryTogetherFallShort() { + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(capital.getBank(), 1.0); + guard(6); + denars.when(() -> Denars.balance(Denars.guildTax(capital.getIncarnationId()))).thenReturn(1000L); + var plan = plan(List.of()); + // 11.00 covers two of the four paid slots. + assertEquals(4, upkeep(plan).regimentSlots().get("guard")); + assertEquals(1000, upkeep(plan).charge()); + assertEquals(0, net(plan, capital.getBank().accountId())); + } + + @Test void loansArePaidFromTodaysIncomeAfterUpkeep() { + net.tfminecraft.simplefactions.testsupport.TestTreasuries.set(capital.getBank(), 0.0); + guard(4); + Loan loan = new Loan("test", 100, guild, capital, 1000000000L - 86400000L * 3, 1, 7, 5, true); + guild.getLoanHandler().issueLoan(loan); + denars.when(() -> Denars.balance(Denars.guildTax(capital.getIncarnationId()))).thenReturn(5000L); + var plan = plan(List.of()); + assertEquals(4, upkeep(plan).regimentSlots().get("guard")); + long repaid = net(plan, guild.getBank().accountId()); + assertTrue(repaid > 0); + assertEquals(5000 - 1000 - repaid, net(plan, capital.getBank().accountId())); + } + @Test void debtIsPreservedAndDailyShortfallsLiquidateWithoutTouchingLiveBranches() { guild.getBranch(0).levelUp(); guild.getBranch(0).levelUp();