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();