From c8e4c1e5ad253d830d5aad7d22bc8cccd5857d75 Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sun, 10 May 2026 15:38:58 +0700 Subject: [PATCH 1/5] feat: add strategic economy summary and VenaTerra bridge --- docs/BANNERMOD_BACKLOG.sqlite | Bin 823296 -> 856064 bytes .../commands/admin/AdminDebugCommands.java | 37 +++ .../venaterra/VenaterraDepositCandidate.java | 26 ++ .../venaterra/VenaterraDepositCategory.java | 22 ++ .../venaterra/VenaterraDepositProvider.java | 189 +++++++++++ .../economy/ClaimStrategicEconomySummary.java | 55 ++++ .../ClaimStrategicEconomySummaryService.java | 297 ++++++++++++++++++ .../VenaterraDepositProviderTest.java | 189 +++++++++++ ...aimStrategicEconomySummaryServiceTest.java | 197 ++++++++++++ 9 files changed, 1012 insertions(+) create mode 100644 src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositCandidate.java create mode 100644 src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositCategory.java create mode 100644 src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositProvider.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java create mode 100644 src/test/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositProviderTest.java create mode 100644 src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java diff --git a/docs/BANNERMOD_BACKLOG.sqlite b/docs/BANNERMOD_BACKLOG.sqlite index a46d4d0231619df6321c251f2ce537e54a65949c..5b840daae75d64d8bcbd50a8bffc9c5119dd9061 100644 GIT binary patch delta 25273 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

ojpFV%n6)|86FlvoK4&^v+LmNX;@Q2AFDD15EV# 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 z z*NANFJW5ATkj);5xd6i~Nik^8Mj!Us8H9nYLK?bJBvN)AZ4HSxoG8bqIwTMG?(_s54jMn`-!2Nu2mWvLYM;vx`)&2y0-uFj9!36TW#d+h^K`65d1g!Y%ef zqT_vd(@*BUKD=DEPhL~ocQKp#)}=2U>U*RQudeOG%Ye@G@%6C7^7FaA*D|T^Ui$85 w`|j$0rG_8yV%ompOPN>Y)wQ)W-WoK>;Z@F6llSFXDZFdb!|7zgld@@}%5Y`aSf2rWmrZ<<0y3PQt8xf<>p$|W}hIR&wKUKBh~g9YMv z5#)#{U?8-sh!8|b5#E&J;rDV3{7#O7-^wa@Lymyg<#70o90t>JD7+?zz^k$Xek}*VD>4ATk^|vo z*&lvcC;Q^%3)vgmvL|em-C%?40x!uknLeowu7I`w(z`c1JB9U@T@F` zpUNWmiL~HpX~L7zfCahi}t5_!g~&Z_*lgkXFMtXcc^&R>Ie41$>p3!vnMozCugk%e15pFZ*aQ zl(YyEErbzT0PAQze2M14y)+lTNORyGnhkdoH|_wMG5hq|FaRL@lKm3i3`NRpBM}6R2;vCGO zUT`*X8fH;V{N`Itf-;bba>)x^n|M4XI?#F>~toQCnlX&6TZ za4dP@81lfn(ZoR*MG6dZH>x-VBlmz2yTG$Mz;I5$urR3H1cq(^<=pWAw|XFVx{O=g zk9#{{3FyPk?9W~7%kAsMee2B)>&cz!!7b{}U8(EF{peZ^x^M&hT=~vi@=jdqj$Gsp zT-{PGZb>C*&qZxF7(7)D+LnPf13+uk(^1TIEaC#T>H(~-z~ovLa*+zSMh1U{d|W4Q zTV5W*WqO9IRKZ1hx)2QFFU(-BlU|>zDs{bQ{+9pENF~dEQI&U7f7Z0I5+dZYQX;Ha z4QfG?*%(k%%U1nLi)o9Tp)ao(+F@@LZd zEg6H)R%a+wFJqu~2>-#5kcQ4eSWxl4>oliKvq=A8|gy zIEj4;HO|K~yjjGtCbIe2G+#N&OMX|2*&tkeESJAz&}F66bc>l15vP@MXCGDBqSnQi zvUnxRR|;T+KLv9kmvutTU_Q>b54)78ty-eRJS7Bf<`+#h%CyF?;BrO9LsJPgMzC@~ z#08!zo^D&jJVs{q~KYJR`JAnu;6bJUD7;Ew_d3tn|dRoVG1?IH#SB zjlQEzD=`WU#dpv5tM6x}+1br$nd8mjX29%Wme|odnxPcA);PG`|9QmD|D|0nbUkqJ z&*kGsY{y-V3dP0$&8hdab51oCyL3-$DR8R}{zCoxu+#Uo3dJgZ^8U%Rc%bE6N_Fi1 zLv2=}@1TR@_4pB++obJRy4N2s_s8m+w4#{Rtl3Hp-cPoR|JHtYo{s32hf{dW-Rg)( zzkTEneW+Vssn64=*drBvpuqpIYj!KQepg3Myr@-D=dcBRTzJ1v@uQbIk8?hrkL2eq zquD%~)zpL<;AUqr*&vLQs&d9YqZ$jn36b$}x)CGc#|{xRt_$Te`{r1q($`pPhyY@v zV=<#+F`d6-4tGIHc{a*e{9%G#r`6#0a_++UU4Cy%X9Ne+w!2n;gcA|o7xHpy!WgO{ l-setw@tM^)E28 debug() { .then(Commands.literal("counters") .then(Commands.literal("dump") .executes(AdminDebugCommands::countersDump))) + .then(Commands.literal("economy") + .then(Commands.literal("claim") + .then(Commands.argument("claimUuid", StringArgumentType.word()) + .executes(AdminDebugCommands::claimEconomySummary)))) .then(Commands.literal("save-versions") .executes(AdminDebugCommands::saveVersions)); } @@ -160,6 +169,34 @@ private static int saveVersions(CommandContext context) thro return reported; } + private static int claimEconomySummary(CommandContext context) throws CommandSyntaxException { + ServerLevel level = serverLevel(context.getSource()); + UUID claimUuid; + try { + claimUuid = UUID.fromString(StringArgumentType.getString(context, "claimUuid")); + } catch (IllegalArgumentException exception) { + context.getSource().sendFailure(Component.literal("claimUuid must be a UUID")); + return 0; + } + SettlementSnapshot snapshot = SettlementManager.get(level).getSnapshot(claimUuid); + if (snapshot == null) { + context.getSource().sendFailure(Component.literal("No settlement snapshot for claim " + claimUuid)); + return 0; + } + List validatedBuildings = ValidatedBuildingRegistryData.get(level).allRecords().stream() + .filter(record -> record.settlementId().equals(claimUuid)) + .toList(); + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + validatedBuildings, + BannerModTreasuryManager.get(level).getLedger(claimUuid) + ); + for (String line : summary.debugLines()) { + context.getSource().sendSuccess(() -> Component.literal(line), false); + } + return summary.resources().size(); + } + private static Integer currentVersion(Class savedDataClass) { try { Field field = savedDataClass.getDeclaredField("CURRENT_VERSION"); diff --git a/src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositCandidate.java b/src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositCandidate.java new file mode 100644 index 00000000..da0feeee --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositCandidate.java @@ -0,0 +1,26 @@ +package com.talhanation.bannermod.compat.venaterra; + +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; + +public record VenaterraDepositCandidate( + VenaterraDepositCategory category, + ResourceLocation oreId, + ResourceLocation dropItemId, + BlockPos center, + float richness, + double confidence, + SourceMetadata source +) { + public VenaterraDepositCandidate { + category = category == null ? VenaterraDepositCategory.UNKNOWN_OTHER : category; + center = center == null ? BlockPos.ZERO : center.immutable(); + source = source == null ? SourceMetadata.venaterra(null) : source; + } + + public record SourceMetadata(String modId, String apiClass, ResourceLocation dimension) { + static SourceMetadata venaterra(ResourceLocation dimension) { + return new SourceMetadata(VenaterraDepositProvider.MOD_ID, VenaterraDepositProvider.API_CLASS_NAME, dimension); + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositCategory.java b/src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositCategory.java new file mode 100644 index 00000000..308c5fee --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositCategory.java @@ -0,0 +1,22 @@ +package com.talhanation.bannermod.compat.venaterra; + +public enum VenaterraDepositCategory { + IRON, + INDUSTRIAL_FUEL, + PRECIOUS_COIN_VALUE, + QUARRY_STONE, + UNKNOWN_OTHER; + + static VenaterraDepositCategory fromApiCategory(Object apiCategory) { + if (apiCategory == null) { + return UNKNOWN_OTHER; + } + + try { + return valueOf(apiCategory.toString()); + } + catch (IllegalArgumentException ignored) { + return UNKNOWN_OTHER; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositProvider.java b/src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositProvider.java new file mode 100644 index 00000000..d6c0e2b7 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositProvider.java @@ -0,0 +1,189 @@ +package com.talhanation.bannermod.compat.venaterra; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.neoforged.fml.ModList; + +import javax.annotation.Nullable; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public interface VenaterraDepositProvider { + String MOD_ID = "venaterra"; + String API_CLASS_NAME = "ru.kaiserroman.venaterra.api.VenaterraDepositApi"; + String RESULT_CLASS_NAME = "ru.kaiserroman.venaterra.api.DepositSurveyResult"; + + List findClaimDeposits(ServerLevel level, RecruitsClaim claim); + + static VenaterraDepositProvider create() { + return create(ModList.get().isLoaded(MOD_ID), Class::forName); + } + + static VenaterraDepositProvider create(boolean venaterraLoaded, ReflectiveClassResolver classResolver) { + if (!venaterraLoaded) { + return EmptyVenaterraDepositProvider.INSTANCE; + } + + try { + Class apiClass = classResolver.resolve(API_CLASS_NAME); + Class resultClass = classResolver.resolve(RESULT_CLASS_NAME); + Method findDepositsInArea = apiClass.getMethod("findDepositsInArea", ServerLevel.class, BlockPos.class, int.class); + return new ReflectiveVenaterraDepositProvider(findDepositsInArea, resultClass); + } + catch (ReflectiveOperationException | LinkageError | RuntimeException exception) { + BannerModMain.LOGGER.warn("VenaTerra compatibility disabled; public deposit API is unavailable", exception); + return EmptyVenaterraDepositProvider.INSTANCE; + } + } + + @FunctionalInterface + interface ReflectiveClassResolver { + Class resolve(String className) throws ClassNotFoundException; + } + + final class EmptyVenaterraDepositProvider implements VenaterraDepositProvider { + static final EmptyVenaterraDepositProvider INSTANCE = new EmptyVenaterraDepositProvider(); + + private EmptyVenaterraDepositProvider() { + } + + @Override + public List findClaimDeposits(ServerLevel level, RecruitsClaim claim) { + return List.of(); + } + } + + final class ReflectiveVenaterraDepositProvider implements VenaterraDepositProvider { + private final Method findDepositsInArea; + private final Class resultClass; + + ReflectiveVenaterraDepositProvider(Method findDepositsInArea, Class resultClass) { + this.findDepositsInArea = findDepositsInArea; + this.resultClass = resultClass; + } + + @Override + public List findClaimDeposits(ServerLevel level, RecruitsClaim claim) { + if (claim == null || claim.getClaimedChunks().isEmpty()) { + return List.of(); + } + + ClaimQueryArea queryArea = ClaimQueryArea.fromClaim(claim); + try { + Object rawResults = findDepositsInArea.invoke(null, level, queryArea.center(), queryArea.horizontalRadius()); + if (!(rawResults instanceof Collection results)) { + return List.of(); + } + + return results.stream() + .filter(resultClass::isInstance) + .map(VenaterraDepositProvider::mapSurveyResult) + .filter(Objects::nonNull) + .filter(candidate -> queryArea.contains(candidate.center())) + .toList(); + } + catch (ReflectiveOperationException | LinkageError | RuntimeException exception) { + BannerModMain.LOGGER.warn("VenaTerra deposit query failed; returning no BannerMod deposit candidates", exception); + return List.of(); + } + } + } + + record ClaimQueryArea(BlockPos center, int horizontalRadius, int minBlockX, int maxBlockX, int minBlockZ, int maxBlockZ, Set claimedChunks) { + static ClaimQueryArea fromClaim(RecruitsClaim claim) { + int minChunkX = Integer.MAX_VALUE; + int maxChunkX = Integer.MIN_VALUE; + int minChunkZ = Integer.MAX_VALUE; + int maxChunkZ = Integer.MIN_VALUE; + Set claimedChunks = Set.copyOf(claim.getClaimedChunks()); + + for (ChunkPos chunk : claimedChunks) { + minChunkX = Math.min(minChunkX, chunk.x); + maxChunkX = Math.max(maxChunkX, chunk.x); + minChunkZ = Math.min(minChunkZ, chunk.z); + maxChunkZ = Math.max(maxChunkZ, chunk.z); + } + + int minBlockX = minChunkX << 4; + int maxBlockX = (maxChunkX << 4) + 15; + int minBlockZ = minChunkZ << 4; + int maxBlockZ = (maxChunkZ << 4) + 15; + int centerX = minBlockX + ((maxBlockX - minBlockX) / 2); + int centerZ = minBlockZ + ((maxBlockZ - minBlockZ) / 2); + int horizontalRadius = Math.max(maxBlockX - centerX, maxBlockZ - centerZ); + return new ClaimQueryArea(new BlockPos(centerX, 0, centerZ), horizontalRadius, minBlockX, maxBlockX, minBlockZ, maxBlockZ, claimedChunks); + } + + boolean contains(BlockPos pos) { + return pos.getX() >= minBlockX + && pos.getX() <= maxBlockX + && pos.getZ() >= minBlockZ + && pos.getZ() <= maxBlockZ + && claimedChunks.contains(new ChunkPos(pos)); + } + } + + @Nullable + static VenaterraDepositCandidate mapSurveyResult(Object result) { + try { + ResourceLocation oreId = invoke(result, "oreId", ResourceLocation.class); + ResourceLocation dropItemId = invoke(result, "dropItemId", ResourceLocation.class); + BlockPos center = invoke(result, "center", BlockPos.class); + Float richness = invokeNumber(result, "richness", Float.class); + Double chance = invokeNumber(result, "chance", Double.class); + ResourceLocation dimension = invoke(result, "dimension", ResourceLocation.class); + Object category = invoke(result, "category", Object.class); + + if (center == null || richness == null || chance == null) { + return null; + } + + return new VenaterraDepositCandidate( + VenaterraDepositCategory.fromApiCategory(category), + oreId, + dropItemId, + center, + richness, + chance, + VenaterraDepositCandidate.SourceMetadata.venaterra(dimension) + ); + } + catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { + return null; + } + } + + @Nullable + private static T invoke(Object target, String methodName, Class expectedType) throws ReflectiveOperationException { + Method method = target.getClass().getMethod(methodName); + method.setAccessible(true); + Object value = method.invoke(target); + return expectedType.isInstance(value) ? expectedType.cast(value) : null; + } + + @Nullable + private static T invokeNumber(Object target, String methodName, Class expectedType) throws ReflectiveOperationException { + Method method = target.getClass().getMethod(methodName); + method.setAccessible(true); + Object value = method.invoke(target); + if (expectedType.isInstance(value)) { + return expectedType.cast(value); + } + if (value instanceof Number number) { + if (expectedType == Float.class) { + return expectedType.cast(number.floatValue()); + } + if (expectedType == Double.class) { + return expectedType.cast(number.doubleValue()); + } + } + return null; + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java new file mode 100644 index 00000000..23e60ff5 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java @@ -0,0 +1,55 @@ +package com.talhanation.bannermod.settlement.economy; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public record ClaimStrategicEconomySummary( + UUID claimUuid, + int anchorChunkX, + int anchorChunkZ, + List resources, + boolean shortage, + boolean degraded, + boolean unknown +) { + public ClaimStrategicEconomySummary { + resources = List.copyOf(resources == null ? List.of() : resources); + } + + public List debugLines() { + List lines = new ArrayList<>(); + lines.add("Claim economy " + this.claimUuid + " chunk=" + this.anchorChunkX + "," + this.anchorChunkZ + + " shortage=" + this.shortage + " degraded=" + this.degraded + " unknown=" + this.unknown); + for (ResourceLine resource : this.resources) { + lines.add(resource.resourceId() + + " stockpile=" + resource.stockpileHint() + + "/" + resource.capacityHint() + + " production=" + resource.productionHint() + + " consumption=" + resource.consumptionHint() + + " shortage=" + resource.shortage() + + " degraded=" + resource.degraded() + + " unknown=" + resource.unknown()); + } + return lines; + } + + public record ResourceLine( + String resourceId, + int stockpileHint, + int capacityHint, + int productionHint, + int consumptionHint, + boolean shortage, + boolean degraded, + boolean unknown + ) { + public ResourceLine { + resourceId = resourceId == null ? "" : resourceId; + stockpileHint = Math.max(0, stockpileHint); + capacityHint = Math.max(0, capacityHint); + productionHint = Math.max(0, productionHint); + consumptionHint = Math.max(0, consumptionHint); + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java new file mode 100644 index 00000000..c6e603b4 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java @@ -0,0 +1,297 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.governance.BannerModTreasuryLedgerSnapshot; +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.SettlementStockpileSummary; +import com.talhanation.bannermod.settlement.SettlementSupplySignal; +import com.talhanation.bannermod.settlement.building.BuildingType; +import com.talhanation.bannermod.settlement.building.BuildingValidationState; +import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord; + +import javax.annotation.Nullable; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class ClaimStrategicEconomySummaryService { + private static final List RESOURCES = List.of( + ResourceKey.FOOD, + ResourceKey.IRON, + ResourceKey.WOOD, + ResourceKey.STONE, + ResourceKey.COINS + ); + + private ClaimStrategicEconomySummaryService() { + } + + public static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, + List validatedBuildings, + @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger) { + Map resources = new EnumMap<>(ResourceKey.class); + for (ResourceKey resource : RESOURCES) { + resources.put(resource, new MutableResource()); + } + + List safeValidatedBuildings = validatedBuildings == null ? List.of() : validatedBuildings; + Set validBuildingIds = new HashSet<>(); + Set invalidBuildingIds = new HashSet<>(); + for (ValidatedBuildingRecord record : safeValidatedBuildings) { + if (record.settlementId().equals(snapshot.claimUuid())) { + if (record.state() == BuildingValidationState.VALID) { + validBuildingIds.add(record.buildingId()); + } else { + invalidBuildingIds.add(record.buildingId()); + } + } + } + + applyStockpile(resources, snapshot.stockpileSummary()); + applySupplySignals(resources, snapshot); + applyMarket(resources, snapshot); + applyTreasury(resources, treasuryLedger); + applyBuildings(resources, snapshot, safeValidatedBuildings, validBuildingIds, invalidBuildingIds); + + boolean anyShortage = false; + boolean anyDegraded = false; + boolean anyUnknown = false; + List lines = RESOURCES.stream() + .map(resource -> { + MutableResource mutable = resources.get(resource); + boolean shortage = mutable.shortage || mutable.stockpileHint + mutable.productionHint < mutable.consumptionHint; + return new ClaimStrategicEconomySummary.ResourceLine( + resource.id, + mutable.stockpileHint, + mutable.capacityHint, + mutable.productionHint, + mutable.consumptionHint, + shortage, + mutable.degraded, + mutable.unknown + ); + }) + .toList(); + for (ClaimStrategicEconomySummary.ResourceLine line : lines) { + anyShortage |= line.shortage(); + anyDegraded |= line.degraded(); + anyUnknown |= line.unknown(); + } + + return new ClaimStrategicEconomySummary( + snapshot.claimUuid(), + snapshot.anchorChunkX(), + snapshot.anchorChunkZ(), + lines, + anyShortage, + anyDegraded, + anyUnknown + ); + } + + private static void applyStockpile(Map resources, SettlementStockpileSummary stockpileSummary) { + int sharedCapacityUnits = stockpileSummary.slotCapacity() / 27; + for (ResourceKey resource : List.of(ResourceKey.FOOD, ResourceKey.IRON, ResourceKey.WOOD, ResourceKey.STONE)) { + resources.get(resource).capacityHint += sharedCapacityUnits; + } + for (String typeId : stockpileSummary.authoredStorageTypeIds()) { + ResourceKey resource = resourceForGood(typeId); + if (resource != null) { + MutableResource mutable = resources.get(resource); + mutable.stockpileHint += 1; + mutable.capacityHint += Math.max(1, stockpileSummary.containerCount()); + } + } + if (stockpileSummary.storageBuildingCount() > 0 && stockpileSummary.authoredStorageTypeIds().isEmpty()) { + for (ResourceKey resource : List.of(ResourceKey.FOOD, ResourceKey.IRON, ResourceKey.WOOD, ResourceKey.STONE)) { + resources.get(resource).unknown = true; + } + } + } + + private static void applySupplySignals(Map resources, SettlementSnapshot snapshot) { + for (SettlementSupplySignal signal : snapshot.supplySignalState().signals()) { + ResourceKey resource = resourceForGood(signal.goodId()); + if (resource == null) { + continue; + } + MutableResource mutable = resources.get(resource); + mutable.stockpileHint += signal.coverageUnits(); + mutable.consumptionHint += signal.desiredUnits(); + mutable.shortage |= signal.shortageUnits() > 0; + } + } + + private static void applyMarket(Map resources, SettlementSnapshot snapshot) { + MutableResource coins = resources.get(ResourceKey.COINS); + coins.capacityHint += snapshot.marketState().totalStorageSlots() / 27; + coins.productionHint += snapshot.marketState().openMarketCount() + snapshot.marketState().readySellerDispatchCount(); + coins.consumptionHint += Math.max(0, snapshot.marketState().marketCount() - snapshot.marketState().openMarketCount()); + } + + private static void applyTreasury(Map resources, + @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger) { + MutableResource coins = resources.get(ResourceKey.COINS); + if (treasuryLedger == null) { + coins.unknown = true; + return; + } + coins.stockpileHint += treasuryLedger.treasuryBalance(); + coins.capacityHint += treasuryLedger.treasuryBalance(); + coins.productionHint += treasuryLedger.lastDepositAmount(); + coins.consumptionHint += treasuryLedger.lastArmyUpkeepDebitAmount(); + } + + private static void applyBuildings(Map resources, + SettlementSnapshot snapshot, + List validatedBuildings, + Set validBuildingIds, + Set invalidBuildingIds) { + resources.get(ResourceKey.FOOD).consumptionHint += snapshot.assignedResidentCount(); + if (snapshot.missingWorkAreaAssignmentCount() > 0 || snapshot.unassignedWorkerCount() > 0) { + for (MutableResource resource : resources.values()) { + resource.degraded = true; + } + } + + Set snapshotBuildingIds = new HashSet<>(); + for (SettlementBuildingRecord building : snapshot.buildings()) { + snapshotBuildingIds.add(building.buildingUuid()); + BuildingContribution contribution = contributionForBuilding(building); + if (contribution.resource() == null) { + continue; + } + MutableResource resource = resources.get(contribution.resource()); + boolean valid = validBuildingIds.contains(building.buildingUuid()); + boolean invalid = invalidBuildingIds.contains(building.buildingUuid()); + boolean staffed = building.assignedWorkerCount() > 0; + if (valid && staffed) { + resource.productionHint += Math.max(1, building.assignedWorkerCount()) * contribution.productionUnits(); + } else if (valid) { + resource.degraded = true; + resource.productionHint += contribution.degradedUnits(); + } else { + resource.degraded = true; + resource.unknown = true; + resource.productionHint += contribution.degradedUnits(); + if (invalid) { + resource.consumptionHint += 1; + } + } + for (Map.Entry consumption : contribution.consumption().entrySet()) { + resources.get(consumption.getKey()).consumptionHint += consumption.getValue(); + } + } + + for (ValidatedBuildingRecord record : validatedBuildings) { + if (!record.settlementId().equals(snapshot.claimUuid()) || snapshotBuildingIds.contains(record.buildingId())) { + continue; + } + BuildingContribution contribution = contributionForValidatedRecord(record); + if (contribution.resource() == null) { + if (record.state() == BuildingValidationState.VALID && record.type() == BuildingType.STORAGE) { + for (ResourceKey resource : List.of(ResourceKey.FOOD, ResourceKey.IRON, ResourceKey.WOOD, ResourceKey.STONE)) { + MutableResource mutable = resources.get(resource); + mutable.capacityHint += Math.max(1, record.capacity()); + mutable.degraded = true; + mutable.unknown = true; + } + } + continue; + } + MutableResource resource = resources.get(contribution.resource()); + resource.productionHint += contribution.degradedUnits(); + resource.degraded = true; + resource.unknown = true; + } + } + + private static BuildingContribution contributionForBuilding(SettlementBuildingRecord building) { + String typeId = building.buildingTypeId() == null ? "" : building.buildingTypeId().toLowerCase(Locale.ROOT); + if (typeId.contains("farm") || typeId.contains("crop") || typeId.contains("animal_pen") || typeId.contains("fishing")) { + return new BuildingContribution(ResourceKey.FOOD, 2, 1, Map.of()); + } + if (typeId.contains("lumber")) { + return new BuildingContribution(ResourceKey.WOOD, 2, 1, Map.of()); + } + if (typeId.contains("mine") || typeId.contains("mining_area")) { + return new BuildingContribution(ResourceKey.IRON, 1, 1, Map.of(ResourceKey.WOOD, 1)); + } + if (typeId.contains("smith")) { + return new BuildingContribution(ResourceKey.IRON, 1, 1, Map.of(ResourceKey.WOOD, 1)); + } + if (typeId.contains("architect") || typeId.contains("build_area")) { + return new BuildingContribution(ResourceKey.STONE, 1, 1, Map.of(ResourceKey.WOOD, 1)); + } + return new BuildingContribution(null, 0, 0, Map.of()); + } + + private static BuildingContribution contributionForValidatedRecord(ValidatedBuildingRecord record) { + return switch (record.type()) { + case FARM -> new BuildingContribution(ResourceKey.FOOD, 2, 1, Map.of()); + case LUMBER_CAMP -> new BuildingContribution(ResourceKey.WOOD, 2, 1, Map.of()); + case MINE -> new BuildingContribution(ResourceKey.IRON, 1, 1, Map.of(ResourceKey.WOOD, 1)); + case SMITHY -> new BuildingContribution(ResourceKey.IRON, 1, 1, Map.of(ResourceKey.WOOD, 1)); + case ARCHITECT_WORKSHOP -> new BuildingContribution(ResourceKey.STONE, 1, 1, Map.of(ResourceKey.WOOD, 1)); + default -> new BuildingContribution(null, 0, 0, Map.of()); + }; + } + + private static ResourceKey resourceForGood(String goodId) { + if (goodId == null || goodId.isBlank()) { + return null; + } + String normalized = goodId.toLowerCase(Locale.ROOT); + if (normalized.contains("food")) { + return ResourceKey.FOOD; + } + if (normalized.contains("iron")) { + return ResourceKey.IRON; + } + if (normalized.contains("wood") || normalized.contains("lumber")) { + return ResourceKey.WOOD; + } + if (normalized.contains("stone") || normalized.contains("construction_material")) { + return ResourceKey.STONE; + } + if (normalized.contains("coin") || normalized.contains("tax") || normalized.contains("market")) { + return ResourceKey.COINS; + } + return null; + } + + private enum ResourceKey { + FOOD("food"), + IRON("iron"), + WOOD("wood"), + STONE("stone"), + COINS("coins"); + + private final String id; + + ResourceKey(String id) { + this.id = id; + } + } + + private static final class MutableResource { + private int stockpileHint; + private int capacityHint; + private int productionHint; + private int consumptionHint; + private boolean shortage; + private boolean degraded; + private boolean unknown; + } + + private record BuildingContribution(@Nullable ResourceKey resource, + int productionUnits, + int degradedUnits, + Map consumption) { + } +} diff --git a/src/test/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositProviderTest.java b/src/test/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositProviderTest.java new file mode 100644 index 00000000..b024aa83 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/compat/venaterra/VenaterraDepositProviderTest.java @@ -0,0 +1,189 @@ +package com.talhanation.bannermod.compat.venaterra; + +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VenaterraDepositProviderTest { + + @Test + void disabledProviderReturnsNoCandidates() { + VenaterraDepositProvider provider = VenaterraDepositProvider.create(false, className -> FakeApi.class); + + assertTrue(provider.findClaimDeposits(null, claimAtOrigin()).isEmpty()); + } + + @Test + void missingApiClassReturnsNoCandidates() { + VenaterraDepositProvider provider = VenaterraDepositProvider.create(true, className -> { + throw new ClassNotFoundException(className); + }); + + assertTrue(provider.findClaimDeposits(null, claimAtOrigin()).isEmpty()); + } + + @Test + void mapsReflectiveSurveyResultToBannerModCandidate() { + VenaterraDepositCandidate candidate = VenaterraDepositProvider.mapSurveyResult(new FakeSurveyResult( + ResourceLocation.withDefaultNamespace("iron_ore"), + ResourceLocation.withDefaultNamespace("raw_iron"), + new BlockPos(5, 32, 6), + 0.8F, + 0.65D, + Level.OVERWORLD.location(), + FakeApiCategory.IRON + )); + + assertEquals(VenaterraDepositCategory.IRON, candidate.category()); + assertEquals(new BlockPos(5, 32, 6), candidate.center()); + assertEquals(0.8F, candidate.richness()); + assertEquals(0.65D, candidate.confidence()); + assertEquals(VenaterraDepositProvider.MOD_ID, candidate.source().modId()); + assertEquals(VenaterraDepositProvider.API_CLASS_NAME, candidate.source().apiClass()); + } + + @Test + void unknownReflectiveCategoryMapsToUnknownCandidate() { + VenaterraDepositCandidate candidate = VenaterraDepositProvider.mapSurveyResult(new FakeSurveyResult( + ResourceLocation.withDefaultNamespace("modded_ore"), + ResourceLocation.withDefaultNamespace("modded_drop"), + new BlockPos(5, 32, 6), + 0.2F, + 0.1D, + Level.OVERWORLD.location(), + FakeApiCategory.NEW_CATEGORY + )); + + assertEquals(VenaterraDepositCategory.UNKNOWN_OTHER, candidate.category()); + } + + @Test + void reflectiveProviderFiltersToClaimLocalCenters() { + VenaterraDepositProvider provider = VenaterraDepositProvider.create(true, className -> { + if (VenaterraDepositProvider.API_CLASS_NAME.equals(className)) { + return FakeApi.class; + } + if (VenaterraDepositProvider.RESULT_CLASS_NAME.equals(className)) { + return FakeSurveyResult.class; + } + throw new ClassNotFoundException(className); + }); + + List candidates = provider.findClaimDeposits(null, claimAtOrigin()); + + assertEquals(1, candidates.size()); + assertEquals(new BlockPos(4, 16, 4), candidates.getFirst().center()); + } + + @Test + void reflectiveProviderRejectsBoundingBoxDepositsOutsideClaimedChunks() { + VenaterraDepositProvider provider = VenaterraDepositProvider.create(true, className -> { + if (VenaterraDepositProvider.API_CLASS_NAME.equals(className)) { + return FakeApi.class; + } + if (VenaterraDepositProvider.RESULT_CLASS_NAME.equals(className)) { + return FakeSurveyResult.class; + } + throw new ClassNotFoundException(className); + }); + + List candidates = provider.findClaimDeposits(null, claimWithGap()); + + assertEquals(List.of(new BlockPos(4, 16, 4), new BlockPos(40, 16, 40)), candidates.stream().map(VenaterraDepositCandidate::center).toList()); + } + + + @Test + void failingApiCallReturnsNoCandidates() { + VenaterraDepositProvider provider = VenaterraDepositProvider.create(true, className -> { + if (VenaterraDepositProvider.API_CLASS_NAME.equals(className)) { + return FailingApi.class; + } + if (VenaterraDepositProvider.RESULT_CLASS_NAME.equals(className)) { + return FakeSurveyResult.class; + } + throw new ClassNotFoundException(className); + }); + + assertTrue(provider.findClaimDeposits(null, claimAtOrigin()).isEmpty()); + } + + private static RecruitsClaim claimAtOrigin() { + RecruitsClaim claim = new RecruitsClaim("test", null); + claim.addChunk(new ChunkPos(0, 0)); + return claim; + } + + private static RecruitsClaim claimWithGap() { + RecruitsClaim claim = new RecruitsClaim("test", null); + claim.addChunk(new ChunkPos(0, 0)); + claim.addChunk(new ChunkPos(2, 2)); + return claim; + } + + public static final class FakeApi { + public static List findDepositsInArea(ServerLevel level, BlockPos center, int horizontalRadius) { + return List.of( + new FakeSurveyResult( + ResourceLocation.withDefaultNamespace("iron_ore"), + ResourceLocation.withDefaultNamespace("raw_iron"), + new BlockPos(4, 16, 4), + 0.75F, + 0.5D, + Level.OVERWORLD.location(), + FakeApiCategory.IRON + ), + new FakeSurveyResult( + ResourceLocation.withDefaultNamespace("coal_ore"), + ResourceLocation.withDefaultNamespace("coal"), + new BlockPos(20, 16, 20), + 0.3F, + 0.2D, + Level.OVERWORLD.location(), + FakeApiCategory.INDUSTRIAL_FUEL + ), + new FakeSurveyResult( + ResourceLocation.withDefaultNamespace("coal_ore"), + ResourceLocation.withDefaultNamespace("coal"), + new BlockPos(40, 16, 40), + 0.3F, + 0.2D, + Level.OVERWORLD.location(), + FakeApiCategory.INDUSTRIAL_FUEL + ) + ); + } + } + + public static final class FailingApi { + public static List findDepositsInArea(ServerLevel level, BlockPos center, int horizontalRadius) { + throw new IllegalStateException("boom"); + } + } + + private record FakeSurveyResult( + ResourceLocation oreId, + ResourceLocation dropItemId, + BlockPos center, + float richness, + double chance, + ResourceLocation dimension, + FakeApiCategory category + ) { + } + + private enum FakeApiCategory { + IRON, + INDUSTRIAL_FUEL, + NEW_CATEGORY + } +} diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java new file mode 100644 index 00000000..3ee0780f --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java @@ -0,0 +1,197 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.governance.BannerModTreasuryLedgerSnapshot; +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementDesiredGoodsSnapshot; +import com.talhanation.bannermod.settlement.SettlementMarketState; +import com.talhanation.bannermod.settlement.SettlementProjectCandidateSnapshot; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.SettlementStockpileSummary; +import com.talhanation.bannermod.settlement.SettlementSupplySignalState; +import com.talhanation.bannermod.settlement.SettlementTradeRouteHandoffSnapshot; +import com.talhanation.bannermod.settlement.building.BuildingType; +import com.talhanation.bannermod.settlement.building.BuildingValidationState; +import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ClaimStrategicEconomySummaryServiceTest { + @Test + void validStaffedBuildingGrantsFullProductionHint() { + UUID claimUuid = UUID.randomUUID(); + UUID farmUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(building(farmUuid, "bannermod:validated_farm", 2)), 0, 2, 0); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(validated(claimUuid, farmUuid, BuildingType.FARM, BuildingValidationState.VALID)), + BannerModTreasuryLedgerSnapshot.create(claimUuid, snapshot.anchorChunk(), "blueguild").withDeposit(6, 10L) + ); + + ClaimStrategicEconomySummary.ResourceLine food = line(summary, "food"); + ClaimStrategicEconomySummary.ResourceLine coins = line(summary, "coins"); + assertEquals(4, food.productionHint()); + assertFalse(food.degraded()); + assertFalse(food.unknown()); + assertEquals(6, coins.stockpileHint()); + assertEquals(6, coins.productionHint()); + } + + @Test + void staleBuildingBindingOnlyGrantsDegradedUnknownCredit() { + UUID claimUuid = UUID.randomUUID(); + UUID farmUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(building(farmUuid, "bannermod:validated_farm", 2)), 0, 2, 1); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(), + null + ); + + ClaimStrategicEconomySummary.ResourceLine food = line(summary, "food"); + ClaimStrategicEconomySummary.ResourceLine coins = line(summary, "coins"); + assertEquals(1, food.productionHint()); + assertTrue(food.degraded()); + assertTrue(food.unknown()); + assertTrue(coins.unknown()); + assertTrue(summary.degraded()); + assertTrue(summary.unknown()); + } + + @Test + void invalidValidatedBuildingDoesNotCountAsFullProduction() { + UUID claimUuid = UUID.randomUUID(); + UUID mineUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(building(mineUuid, "bannermod:validated_mine", 3)), 0, 3, 0); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(validated(claimUuid, mineUuid, BuildingType.MINE, BuildingValidationState.INVALID)), + BannerModTreasuryLedgerSnapshot.create(claimUuid, snapshot.anchorChunk(), "blueguild") + ); + + ClaimStrategicEconomySummary.ResourceLine iron = line(summary, "iron"); + assertEquals(1, iron.productionHint()); + assertTrue(iron.degraded()); + assertTrue(iron.unknown()); + assertEquals(1, iron.consumptionHint()); + } + + @Test + void staleMiningAreaRuntimeIdGrantsDegradedUnknownIronCredit() { + UUID claimUuid = UUID.randomUUID(); + UUID mineUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(building(mineUuid, "bannermod:mining_area", 2)), 0, 2, 1); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(), + null + ); + + ClaimStrategicEconomySummary.ResourceLine iron = line(summary, "iron"); + assertEquals(1, iron.productionHint()); + assertTrue(iron.degraded()); + assertTrue(iron.unknown()); + assertEquals(1, line(summary, "wood").consumptionHint()); + } + + @Test + void validatedRecordMissingFromSnapshotIsDegradedUnknownHint() { + UUID claimUuid = UUID.randomUUID(); + UUID lumberUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(), 0, 0, 0); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(validated(claimUuid, lumberUuid, BuildingType.LUMBER_CAMP, BuildingValidationState.VALID)), + BannerModTreasuryLedgerSnapshot.create(claimUuid, snapshot.anchorChunk(), "blueguild") + ); + + ClaimStrategicEconomySummary.ResourceLine wood = line(summary, "wood"); + assertEquals(1, wood.productionHint()); + assertTrue(wood.degraded()); + assertTrue(wood.unknown()); + } + + private static ClaimStrategicEconomySummary.ResourceLine line(ClaimStrategicEconomySummary summary, String resourceId) { + return summary.resources().stream() + .filter(line -> line.resourceId().equals(resourceId)) + .findFirst() + .orElseThrow(); + } + + private static SettlementBuildingRecord building(UUID buildingUuid, String typeId, int assignedWorkers) { + return new SettlementBuildingRecord( + buildingUuid, + typeId, + new BlockPos(0, 64, 0), + UUID.randomUUID(), + "blueguild", + 0, + 1, + assignedWorkers, + List.of() + ); + } + + private static ValidatedBuildingRecord validated(UUID claimUuid, + UUID buildingUuid, + BuildingType type, + BuildingValidationState state) { + return new ValidatedBuildingRecord( + buildingUuid, + claimUuid, + type, + Level.OVERWORLD, + new BlockPos(0, 64, 0), + List.of(), + new AABB(0, 64, 0, 4, 68, 4), + state, + 1, + 100, + 10L, + 20L, + state == BuildingValidationState.VALID ? 0L : 20L + ); + } + + private static SettlementSnapshot snapshot(UUID claimUuid, + List buildings, + int assignedResidents, + int assignedWorkers, + int missingAssignments) { + return new SettlementSnapshot( + claimUuid, + 1, + 2, + "blueguild", + 100L, + assignedResidents, + assignedWorkers, + assignedWorkers, + assignedResidents, + 0, + missingAssignments, + new SettlementStockpileSummary(1, 2, 54, 0, 0, List.of("food", "wood")), + SettlementMarketState.empty(), + SettlementDesiredGoodsSnapshot.empty(), + SettlementProjectCandidateSnapshot.empty(), + SettlementTradeRouteHandoffSnapshot.empty(), + SettlementSupplySignalState.empty(), + List.of(), + buildings + ); + } +} From e7e32b5025326d126bb318a1a61f25da442c3d2b Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sun, 10 May 2026 15:52:07 +0700 Subject: [PATCH 2/5] feat: add strategic mine site records --- docs/BANNERMOD_BACKLOG.sqlite | Bin 856064 -> 856064 bytes .../commands/admin/AdminDebugCommands.java | 42 ++- .../settlement/economy/StrategicMineSite.java | 53 ++++ .../economy/StrategicMineSiteService.java | 185 ++++++++++++ .../economy/StrategicMineSiteServiceTest.java | 268 ++++++++++++++++++ 5 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSite.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java create mode 100644 src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java diff --git a/docs/BANNERMOD_BACKLOG.sqlite b/docs/BANNERMOD_BACKLOG.sqlite index 5b840daae75d64d8bcbd50a8bffc9c5119dd9061..0cfd5db64e0ebeea0bd4a83646fe37b499256087 100644 GIT binary patch delta 2911 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 Kf0K^?oB9tF!CKh> diff --git a/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java b/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java index 1a5d7055..49f2c309 100644 --- a/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java +++ b/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java @@ -13,6 +13,7 @@ import com.talhanation.bannermod.governance.BannerModGovernorManager; import com.talhanation.bannermod.governance.BannerModTreasuryManager; import com.talhanation.bannermod.persistence.military.RecruitPlayerUnitSaveData; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; import com.talhanation.bannermod.persistence.military.RecruitsClaimSaveData; import com.talhanation.bannermod.persistence.military.RecruitsGroupsSaveData; import com.talhanation.bannermod.settlement.SettlementManager; @@ -23,6 +24,8 @@ import com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchSavedData; import com.talhanation.bannermod.settlement.economy.ClaimStrategicEconomySummary; import com.talhanation.bannermod.settlement.economy.ClaimStrategicEconomySummaryService; +import com.talhanation.bannermod.settlement.economy.StrategicMineSite; +import com.talhanation.bannermod.settlement.economy.StrategicMineSiteService; import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentSavedData; import com.talhanation.bannermod.settlement.prefab.player.PlayerBuildingRegistrySavedData; import com.talhanation.bannermod.settlement.project.SettlementProjectSavedData; @@ -103,7 +106,10 @@ public static LiteralArgumentBuilder debug() { .then(Commands.literal("economy") .then(Commands.literal("claim") .then(Commands.argument("claimUuid", StringArgumentType.word()) - .executes(AdminDebugCommands::claimEconomySummary)))) + .executes(AdminDebugCommands::claimEconomySummary))) + .then(Commands.literal("mines") + .then(Commands.argument("claimUuid", StringArgumentType.word()) + .executes(AdminDebugCommands::claimMineSites)))) .then(Commands.literal("save-versions") .executes(AdminDebugCommands::saveVersions)); } @@ -197,6 +203,40 @@ private static int claimEconomySummary(CommandContext contex return summary.resources().size(); } + private static int claimMineSites(CommandContext context) throws CommandSyntaxException { + ServerLevel level = serverLevel(context.getSource()); + UUID claimUuid; + try { + claimUuid = UUID.fromString(StringArgumentType.getString(context, "claimUuid")); + } catch (IllegalArgumentException exception) { + context.getSource().sendFailure(Component.literal("claimUuid must be a UUID")); + return 0; + } + + RecruitsClaim claim = RecruitsClaimSaveData.get(level).getAllClaims().stream() + .filter(candidate -> candidate.getUUID().equals(claimUuid)) + .findFirst() + .orElse(null); + if (claim == null) { + context.getSource().sendFailure(Component.literal("No claim " + claimUuid)); + return 0; + } + + SettlementSnapshot snapshot = SettlementManager.get(level).getSnapshot(claimUuid); + List validatedBuildings = ValidatedBuildingRegistryData.get(level).allRecords().stream() + .filter(record -> record.settlementId().equals(claimUuid)) + .toList(); + List sites = StrategicMineSiteService.derive(level, claim, snapshot, validatedBuildings); + if (sites.isEmpty()) { + context.getSource().sendSuccess(() -> Component.literal("No strategic mine sites for claim " + claimUuid), false); + return 0; + } + for (StrategicMineSite site : sites) { + context.getSource().sendSuccess(() -> Component.literal(site.debugLine()), false); + } + return sites.size(); + } + private static Integer currentVersion(Class savedDataClass) { try { Field field = savedDataClass.getDeclaredField("CURRENT_VERSION"); diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSite.java b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSite.java new file mode 100644 index 00000000..e02fa83b --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSite.java @@ -0,0 +1,53 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.compat.venaterra.VenaterraDepositCategory; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; + +import javax.annotation.Nullable; +import java.util.UUID; + +public record StrategicMineSite( + UUID siteId, + UUID claimUuid, + @Nullable UUID ownerPoliticalEntityId, + ResourceKey dimension, + BlockPos center, + int radius, + SourceType sourceType, + VenaterraDepositCategory resourceCategory, + float richness, + boolean degraded, + boolean unknown +) { + public StrategicMineSite { + siteId = siteId == null ? UUID.randomUUID() : siteId; + claimUuid = claimUuid == null ? new UUID(0L, 0L) : claimUuid; + dimension = dimension == null ? Level.OVERWORLD : dimension; + center = center == null ? BlockPos.ZERO : center.immutable(); + radius = Math.max(1, radius); + sourceType = sourceType == null ? SourceType.CLAIM_MINE_WORK_AREA : sourceType; + resourceCategory = resourceCategory == null ? VenaterraDepositCategory.UNKNOWN_OTHER : resourceCategory; + richness = Math.max(0.0F, richness); + } + + public String debugLine() { + return "Mine site " + this.siteId + + " claim=" + this.claimUuid + + " owner=" + (this.ownerPoliticalEntityId == null ? "unowned" : this.ownerPoliticalEntityId) + + " dimension=" + this.dimension.location() + + " center=" + this.center.getX() + "," + this.center.getY() + "," + this.center.getZ() + + " radius=" + this.radius + + " source=" + this.sourceType + + " resource=" + this.resourceCategory + + " richness=" + this.richness + + " degraded=" + this.degraded + + " unknown=" + this.unknown; + } + + public enum SourceType { + VALIDATED_MINE_BUILDING, + CLAIM_MINE_WORK_AREA + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java new file mode 100644 index 00000000..d6cef96a --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java @@ -0,0 +1,185 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.compat.venaterra.VenaterraDepositCandidate; +import com.talhanation.bannermod.compat.venaterra.VenaterraDepositCategory; +import com.talhanation.bannermod.compat.venaterra.VenaterraDepositProvider; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.building.BuildingType; +import com.talhanation.bannermod.settlement.building.BuildingValidationState; +import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +public final class StrategicMineSiteService { + private static final int SNAPSHOT_MINE_RADIUS = 8; + + private StrategicMineSiteService() { + } + + public static List derive(ServerLevel level, + RecruitsClaim claim, + @Nullable SettlementSnapshot snapshot, + List validatedBuildings) { + return derive(level, claim, snapshot, validatedBuildings, VenaterraDepositProvider.create()); + } + + static List derive(@Nullable ServerLevel level, + RecruitsClaim claim, + @Nullable SettlementSnapshot snapshot, + List validatedBuildings, + VenaterraDepositProvider depositProvider) { + if (claim == null) { + return List.of(); + } + + List records = validatedBuildings == null ? List.of() : validatedBuildings; + List deposits = depositProvider == null ? List.of() : depositProvider.findClaimDeposits(level, claim); + deposits = deposits == null ? List.of() : deposits; + List sites = new ArrayList<>(); + Set includedBuildingIds = new HashSet<>(); + + for (ValidatedBuildingRecord record : records) { + if (!record.settlementId().equals(claim.getUUID()) + || record.type() != BuildingType.MINE + || record.state() != BuildingValidationState.VALID) { + continue; + } + includedBuildingIds.add(record.buildingId()); + sites.add(createSite( + record.buildingId(), + claim, + record.dimension(), + centerOf(record.bounds(), record.anchorPos()), + radiusOf(record.bounds()), + StrategicMineSite.SourceType.VALIDATED_MINE_BUILDING, + deposits + )); + } + + if (snapshot != null && snapshot.claimUuid().equals(claim.getUUID())) { + ResourceKey dimension = level == null ? Level.OVERWORLD : level.dimension(); + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (includedBuildingIds.contains(building.buildingUuid()) || !isMineLike(building.buildingTypeId())) { + continue; + } + sites.add(createSite( + building.buildingUuid(), + claim, + dimension, + building.originPos(), + SNAPSHOT_MINE_RADIUS, + StrategicMineSite.SourceType.CLAIM_MINE_WORK_AREA, + deposits + )); + } + } + + return List.copyOf(sites); + } + + private static StrategicMineSite createSite(UUID siteId, + RecruitsClaim claim, + ResourceKey dimension, + BlockPos center, + int radius, + StrategicMineSite.SourceType sourceType, + List deposits) { + VenaterraDepositCandidate deposit = closestReliableDeposit(dimension, center, radius, deposits); + if (deposit == null) { + return new StrategicMineSite( + siteId, + claim.getUUID(), + claim.getOwnerPoliticalEntityId(), + dimension, + center, + radius, + sourceType, + VenaterraDepositCategory.UNKNOWN_OTHER, + 0.0F, + true, + true + ); + } + + return new StrategicMineSite( + siteId, + claim.getUUID(), + claim.getOwnerPoliticalEntityId(), + dimension, + center, + radius, + sourceType, + deposit.category(), + deposit.richness(), + false, + false + ); + } + + @Nullable + private static VenaterraDepositCandidate closestReliableDeposit(ResourceKey dimension, + BlockPos center, + int radius, + List deposits) { + int radiusSquared = radius * radius; + return deposits.stream() + .filter(Objects::nonNull) + .filter(candidate -> candidate.category() != VenaterraDepositCategory.UNKNOWN_OTHER) + .filter(candidate -> candidate.confidence() > 0.0D) + .filter(candidate -> horizontalDistanceSquared(center, candidate.center()) <= radiusSquared) + .filter(candidate -> candidate.source().dimension() == null || candidate.source().dimension().equals(dimension.location())) + .min(Comparator.comparingInt((VenaterraDepositCandidate candidate) -> horizontalDistanceSquared(center, candidate.center())) + .thenComparing(Comparator.comparingDouble(VenaterraDepositCandidate::confidence).reversed()) + .thenComparing(Comparator.comparingDouble(VenaterraDepositCandidate::richness).reversed())) + .orElse(null); + } + + private static int horizontalDistanceSquared(BlockPos first, BlockPos second) { + int dx = first.getX() - second.getX(); + int dz = first.getZ() - second.getZ(); + return (dx * dx) + (dz * dz); + } + + private static boolean isMineLike(@Nullable String buildingTypeId) { + if (buildingTypeId == null || buildingTypeId.isBlank()) { + return false; + } + String normalized = buildingTypeId.toLowerCase(Locale.ROOT); + return normalized.contains("mine") || normalized.contains("mining_area"); + } + + private static BlockPos centerOf(AABB bounds, BlockPos fallback) { + if (bounds == null) { + return fallback; + } + return BlockPos.containing( + bounds.minX + ((bounds.maxX - bounds.minX) / 2.0D), + bounds.minY + ((bounds.maxY - bounds.minY) / 2.0D), + bounds.minZ + ((bounds.maxZ - bounds.minZ) / 2.0D) + ); + } + + private static int radiusOf(AABB bounds) { + if (bounds == null) { + return SNAPSHOT_MINE_RADIUS; + } + double xRadius = (bounds.maxX - bounds.minX) / 2.0D; + double zRadius = (bounds.maxZ - bounds.minZ) / 2.0D; + return Math.max(1, (int) Math.ceil(Math.max(xRadius, zRadius))); + } +} diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java new file mode 100644 index 00000000..41d5df3c --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java @@ -0,0 +1,268 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.compat.venaterra.VenaterraDepositCandidate; +import com.talhanation.bannermod.compat.venaterra.VenaterraDepositCategory; +import com.talhanation.bannermod.compat.venaterra.VenaterraDepositProvider; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.settlement.SettlementBuildingRecord; +import com.talhanation.bannermod.settlement.SettlementDesiredGoodsSnapshot; +import com.talhanation.bannermod.settlement.SettlementMarketState; +import com.talhanation.bannermod.settlement.SettlementProjectCandidateSnapshot; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.SettlementStockpileSummary; +import com.talhanation.bannermod.settlement.SettlementSupplySignalState; +import com.talhanation.bannermod.settlement.SettlementTradeRouteHandoffSnapshot; +import com.talhanation.bannermod.settlement.building.BuildingType; +import com.talhanation.bannermod.settlement.building.BuildingValidationState; +import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StrategicMineSiteServiceTest { + @Test + void validMineBuildingCreatesSite() { + RecruitsClaim claim = claim(UUID.randomUUID()); + UUID mineId = UUID.randomUUID(); + + List sites = StrategicMineSiteService.derive( + null, + claim, + snapshot(claim.getUUID(), List.of()), + List.of(validated(claim.getUUID(), mineId, BuildingType.MINE, BuildingValidationState.VALID)), + emptyDeposits() + ); + + assertEquals(1, sites.size()); + StrategicMineSite site = sites.getFirst(); + assertEquals(mineId, site.siteId()); + assertEquals(claim.getUUID(), site.claimUuid()); + assertEquals(claim.getOwnerPoliticalEntityId(), site.ownerPoliticalEntityId()); + assertEquals(Level.OVERWORLD, site.dimension()); + assertEquals(StrategicMineSite.SourceType.VALIDATED_MINE_BUILDING, site.sourceType()); + assertTrue(site.degraded()); + assertTrue(site.unknown()); + } + + @Test + void venaterraIronCandidateMapsCategoryAndRichness() { + RecruitsClaim claim = claim(UUID.randomUUID()); + UUID mineId = UUID.randomUUID(); + + List sites = StrategicMineSiteService.derive( + null, + claim, + snapshot(claim.getUUID(), List.of()), + List.of(validated(claim.getUUID(), mineId, BuildingType.MINE, BuildingValidationState.VALID)), + deposits(List.of(new VenaterraDepositCandidate( + VenaterraDepositCategory.IRON, + ResourceLocation.withDefaultNamespace("iron_ore"), + ResourceLocation.withDefaultNamespace("raw_iron"), + new BlockPos(2, 64, 2), + 0.75F, + 0.8D, + new VenaterraDepositCandidate.SourceMetadata("venaterra", "test", Level.OVERWORLD.location()) + ))) + ); + + StrategicMineSite site = sites.getFirst(); + assertEquals(VenaterraDepositCategory.IRON, site.resourceCategory()); + assertEquals(0.75F, site.richness()); + assertFalse(site.degraded()); + assertFalse(site.unknown()); + } + + @Test + void nearestReliableDepositWinsOverFartherHigherConfidenceDeposit() { + RecruitsClaim claim = claim(UUID.randomUUID()); + UUID mineId = UUID.randomUUID(); + + List sites = StrategicMineSiteService.derive( + null, + claim, + snapshot(claim.getUUID(), List.of()), + List.of(validated(claim.getUUID(), mineId, BuildingType.MINE, BuildingValidationState.VALID)), + deposits(List.of( + new VenaterraDepositCandidate( + VenaterraDepositCategory.IRON, + ResourceLocation.withDefaultNamespace("iron_ore"), + ResourceLocation.withDefaultNamespace("raw_iron"), + new BlockPos(2, 64, 2), + 0.35F, + 0.2D, + new VenaterraDepositCandidate.SourceMetadata("venaterra", "test", Level.OVERWORLD.location()) + ), + new VenaterraDepositCandidate( + VenaterraDepositCategory.INDUSTRIAL_FUEL, + ResourceLocation.withDefaultNamespace("coal_ore"), + ResourceLocation.withDefaultNamespace("coal"), + new BlockPos(4, 64, 2), + 0.9F, + 0.95D, + new VenaterraDepositCandidate.SourceMetadata("venaterra", "test", Level.OVERWORLD.location()) + ) + )) + ); + + StrategicMineSite site = sites.getFirst(); + assertEquals(VenaterraDepositCategory.IRON, site.resourceCategory()); + assertEquals(0.35F, site.richness()); + } + + @Test + void crossDimensionDepositDoesNotAttachToMineSite() { + RecruitsClaim claim = claim(UUID.randomUUID()); + UUID mineId = UUID.randomUUID(); + + List sites = StrategicMineSiteService.derive( + null, + claim, + snapshot(claim.getUUID(), List.of()), + List.of(validated(claim.getUUID(), mineId, BuildingType.MINE, BuildingValidationState.VALID)), + deposits(List.of(new VenaterraDepositCandidate( + VenaterraDepositCategory.IRON, + ResourceLocation.withDefaultNamespace("iron_ore"), + ResourceLocation.withDefaultNamespace("raw_iron"), + new BlockPos(2, 64, 2), + 0.75F, + 0.8D, + new VenaterraDepositCandidate.SourceMetadata("venaterra", "test", Level.NETHER.location()) + ))) + ); + + StrategicMineSite site = sites.getFirst(); + assertEquals(VenaterraDepositCategory.UNKNOWN_OTHER, site.resourceCategory()); + assertTrue(site.degraded()); + assertTrue(site.unknown()); + } + + @Test + void noReliableDepositGivesUnknownDegradedFallback() { + RecruitsClaim claim = claim(UUID.randomUUID()); + + List sites = StrategicMineSiteService.derive( + null, + claim, + snapshot(claim.getUUID(), List.of(miningArea(UUID.randomUUID()))), + List.of(), + deposits(List.of(new VenaterraDepositCandidate( + VenaterraDepositCategory.UNKNOWN_OTHER, + null, + null, + new BlockPos(0, 64, 0), + 0.5F, + 0.7D, + new VenaterraDepositCandidate.SourceMetadata("venaterra", "test", Level.OVERWORLD.location()) + ))) + ); + + StrategicMineSite site = sites.getFirst(); + assertEquals(StrategicMineSite.SourceType.CLAIM_MINE_WORK_AREA, site.sourceType()); + assertEquals(VenaterraDepositCategory.UNKNOWN_OTHER, site.resourceCategory()); + assertEquals(0.0F, site.richness()); + assertTrue(site.degraded()); + assertTrue(site.unknown()); + } + + @Test + void nonMineBuildingIsNotIncluded() { + RecruitsClaim claim = claim(UUID.randomUUID()); + + List sites = StrategicMineSiteService.derive( + null, + claim, + snapshot(claim.getUUID(), List.of(building(UUID.randomUUID(), "bannermod:validated_farm"))), + List.of(validated(claim.getUUID(), UUID.randomUUID(), BuildingType.FARM, BuildingValidationState.VALID)), + emptyDeposits() + ); + + assertTrue(sites.isEmpty()); + } + + private static RecruitsClaim claim(UUID owner) { + RecruitsClaim claim = new RecruitsClaim("claim", owner); + claim.addChunk(new ChunkPos(0, 0)); + return claim; + } + + private static ValidatedBuildingRecord validated(UUID claimUuid, + UUID buildingUuid, + BuildingType type, + BuildingValidationState state) { + return new ValidatedBuildingRecord( + buildingUuid, + claimUuid, + type, + Level.OVERWORLD, + new BlockPos(0, 64, 0), + List.of(), + new AABB(0, 64, 0, 4, 68, 4), + state, + 1, + 100, + 10L, + 20L, + state == BuildingValidationState.VALID ? 0L : 20L + ); + } + + private static SettlementBuildingRecord miningArea(UUID buildingUuid) { + return building(buildingUuid, "bannermod:mining_area"); + } + + private static SettlementBuildingRecord building(UUID buildingUuid, String typeId) { + return new SettlementBuildingRecord( + buildingUuid, + typeId, + new BlockPos(0, 64, 0), + UUID.randomUUID(), + "blueguild", + 0, + 1, + 1, + List.of() + ); + } + + private static SettlementSnapshot snapshot(UUID claimUuid, List buildings) { + return new SettlementSnapshot( + claimUuid, + 0, + 0, + "blueguild", + 100L, + 0, + 0, + 0, + 0, + 0, + 0, + SettlementStockpileSummary.empty(), + SettlementMarketState.empty(), + SettlementDesiredGoodsSnapshot.empty(), + SettlementProjectCandidateSnapshot.empty(), + SettlementTradeRouteHandoffSnapshot.empty(), + SettlementSupplySignalState.empty(), + List.of(), + buildings + ); + } + + private static VenaterraDepositProvider emptyDeposits() { + return deposits(List.of()); + } + + private static VenaterraDepositProvider deposits(List candidates) { + return (level, claim) -> candidates; + } +} From d75739d4696e3a2d186706ee8379c7dff3eaa3ea Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sun, 10 May 2026 16:06:19 +0700 Subject: [PATCH 3/5] feat: add strategic mine yield hints --- docs/BANNERMOD_BACKLOG.sqlite | Bin 856064 -> 856064 bytes .../commands/admin/AdminDebugCommands.java | 10 +- .../ClaimStrategicEconomySummaryService.java | 84 +++++++++++- .../economy/StrategicMineSiteService.java | 13 +- ...aimStrategicEconomySummaryServiceTest.java | 125 ++++++++++++++++++ .../economy/StrategicMineSiteServiceTest.java | 24 ++++ 6 files changed, 247 insertions(+), 9 deletions(-) diff --git a/docs/BANNERMOD_BACKLOG.sqlite b/docs/BANNERMOD_BACKLOG.sqlite index 0cfd5db64e0ebeea0bd4a83646fe37b499256087..b24a8e7dff5f9e491cf2a4305ee8fcf71742e9ef 100644 GIT binary patch delta 2912 zcmZvdYj7LY700!!-POY~+UCJ43Au!4AcoO92;_niN=3Z<2W(#qljrRCtljY`Y0_^U07uM&zp-!%4y9P)TPv(g5CD=qMl zOn@ik9`JYaL9i-!fxnSEz+cO4;Ee17eNSA);XtH7ViE5VYy9Q?7I5B@~X0}sowx%l{_oCBVc zCU{zg!9U0l_?8TSZ%YF_DK+p-se-?kfagc_IQV^f6#O2Ig3r(}_%!9fr|4nuyOaUH zL)_zU(;)aH?FYX_-0yGFz!*NhLH*zp#LbtK1Pjy$67_;(#N&9J_JUudhrq{(NAoD{ z1|K0F&;fb?93>vp2<-%iiHDV^?O=|2z%23L9;R;4As%0bwtBFCns|ys)Cs1DXE{js zgZqgmYSVq-0P$S=X*0Nwc)AvC0+Ylu?xS|Fm+k@+#Pg2R25>L&)E}ZKxQBT5yXj8w zL1G0wKv2M#XBV*ob`mRK2eAUS6Dy#HSOMFJ70^wrfG)Zf+)Au~7%c@miFMFHH-YyP zt6>XW58g+tiF=7Pv6%$8i57$H#QM047J(aS0XVjSSQ_hzr4c0-#X4d&+)1p4wbTrL zm4aX!`M@>AI#^8#xC-6#+`%dcJqD~i00c*X+gJf#aezn~_;L!kohRPHlWyffzsnO| zk>Co;d8oJYG?(!h-{bi$<-y&;6T68=bu*9XeV)n%}5BT}X{E$0ks4A$0KLI@l|NFt_7k`y< z>pug)SzRfP|Gjg5MT=huo7{9w8TB-s_CKe4l+ntO)_$dOtfwRjt^hh-p2J?|O{kz^RJ>Cq+1^5d;KVW|R54)}$b3 z7Ct=^z`A-6*`*+77h!5nkmIocP~fB($0LYWwYVE{I3(C>y56Ykt+Thts4->(T-7~8 zA%v=NoE)oQ>d_F_s=3FAh2X8@RJ!Xdz=_THmPQ$it9sqf2dnu}V%EuHJe<3_$`gJ%OzJG_6yN~+KnVRoXSh;7W?$fxmPc^5F zvJXW|@K^OBQ!Ds*N5==m5z}MvPHRzo*CP0?2{&%~%qg978U}Le2Cu9cymFQuW!F)H z17+E56b!&GPU8BxyCBb;Rj*EV5K860cq9INXbtHDNL9o5T>5~IjhE4XpT&5gTu z%(bJir;P|w4B;*|b)462hQ<}^8dt1od|pNw4be;5`S46p3w)}vTm)grDfT-$^_rE`&nm)lkMqDal__5-F~hKV*KhJG-$g7-rBxTA@X zrkwYLtUyS2l=FB5pD2}yQ~QIJcm8qMdnj&HjhMnmt+R+`+0;x`OluBirxhSNtpqh` zMTWzh&Z-v{&y-cZYDwiyq<1H;`AyKBCfOMIvafYI-|^M zOV}KDQrM2WcG0y9uI(sTZZtx?R}_B8o?&u!1Q{Vs?ev7+2)t_?Ry%Rcb!uf|YJaf$ zp4z+#_tbsLIgkI0at@)7l)y84RdjX$Xoq(pBS>!mdM$8D<{&KQISX{MPDY7%~`_9W|G5wc{`I919m!} z6F_GCujdwxi4i*?5@}%_a8h=kop+0LbB*PR(L~k~Nh@oQSRylGWmB0%a)sy~cAQK$ zZ=pEvo5|YPZz$o2&0U=xBA*de{upds}BmJ9~5aOxjw8t&$?v)xL3a zXHWOujh$ngJN9m0zoo}b>C4#ZT$|{((%AEh>+}xWsid9mcdH6JCvw<6Yvpp-e{{e~ zqwfE?J|CPDQ?`|C6T|62^aH7GYlafpLENf8n@C#8T`m6$j@tQw%y3>jVp}P!vC|`o zl${hVanGdNn4Cyv(A&JoS_#Lo5?PTS$YzH72ShHNaB>5P>`SCly@|fT@q?efKYr=r zM00N{(>KUB*k`Aclclr%<>MV68oH_-?v0=njZzgjuSTCVZ0 NgC6Cd6*F)?{2!9N1$+Pi delta 2478 zcmXYxS&$P|6o%8uy`6NrF%7b)fYa`{06}m8L?es1pc54YWsra%3W#walwcSA}&$i^d+SaK3JtGAFM(s@c&!q;p;Q!o_p@?|395lWm2g!sXWpe zw|>+NYg}L9_3@Tn_p(pB-L6>^i^aY-E5QTImEa(=6!gvIV8$#2hnP#j!Dc=<)XW7B zG#7zQlLv>J3&3G!4tS6`7aU>E1`jc3fd`uz;CJRsaFjU%9BHP3hnlJ2=x#Fwk8jPX zU|>!LJIo~TaC0Jfn8||Qm~r5d<^=Eva~ycI83P_=jt0LrM}T9@Vc;?5Q1Cc23Ov>v z0`4*ggD04Sz~jwuaI6^yjx$5SuT1xWcuX)uz=_5OPcj+s6yt#>8V5`p8=Pbm=$SZp zvH@bJ${pZjSqDy$wcu2_2|P`10H?`y;OTNLc!pd9=42IkrcmkWay2+ZuIk2PrmO&G z$#U>4Sq9FQE5NgbMw}x{!1H7=I7ev8`LYPSK)S$Ap+R}M47^Zi(?zlXyjW<~T)70C zC-cBdgvQO6i@^m#`!1CWV>tgZp^FQp6YLUtxk%0jFBdwxSk40rLSL82Ip9*EyI06; zaGB8K>Md1WoC!B!m zg%fatZ~|@=PQXpV3AkB~2iFQG;1)R+yj3^{>*N^lHsLhfE=PiQ2xp>OI1_hD0G6Z! zyh}JAcgtw-9vKOC-zyxA`-G!$zi=oX5KhB`!fAL&27wPt8hk{O;G@DhcuXwtS*RD= z$|-2v27G)Au>EFW8zBGx3pi1R8QSw)*^%jc!3f1j_xUn}T1Fuul*Cql_OaR^< z54<%NcoTAV#~!DU?@+Bd*>0N$91Jvm~%JczM3MtY@)ad;KfDQa! zKBP?RQ$U%Y%u`e<8IOZj=65jW{P$q^|6jc};co}{SzD=%e|8Q_Cv;!T`@xx_`|2-F z=+*VLOFGj%fn(S0{6Cq2c1i_RmGyj2SzWO~gPmV=kJW|BIad9PRZ~>hY21}jeQ5jM zzKo~hb7NlBadl68F3M)c!)+hB(-YyI!OqI`hN42BUt``nXO5m~{fdGgt?-q-?t(

;2vxzg{Ho5oeyEG?{!FNHQ6g}VXs4M7+_;asy8ANy3TvTmM(IYH zan4~$3vpG~(m;hMrm>WF_IFfO38BhH>xH!6ud%nPis+7OM-8H8ZHH&H*i1K^Jsn$0 zp1(e?a6NDPP-}d+<3?|?+mrX~&<-G4RpmT%Xhzq`d%fDqhOf@^=BInIX_T~Z*UifF ztE$F>TB*I69-n)gseQO@BgKPjq+LnjKDwMjqQp=Y5vl^#APTcNWbp#gmLQ7#C~hbB zcc680Thcl={PefvwA4T|sj#<^#A$6uLysi&Aj(83iFU;#Pc0;QYM$8~vsq-Tc7V|K zvDB}lsgSpOH~!%f8}exUqv--h2<6`&`rfw4Hd}@ zR_zMXc1i8dSTaynl&PThWGu6#sQiJvV$U2BJrxC)vWhMR(RLrL`W1WVb^=wc1W#@v zRE-2@rpAP>aIGAzl@flhDhB;((B8~IVDYT7Wd+#J>oh!GEf;58ie|SQ#f>=g#RLzk z#eoV@iXtxvbs<{MN43F!wTP?QXGNB1L0yd->88aiHgJM!dc0zZ3Ejdx>Kf0@;eVWE zF)l>Kd9+el?r3{#WBI+h&F(xRj~CL-psp+XGrg*TB0sujHsRNGjc4T$;U!Vq<^>8^ z?za<>h*4#W*)rZ7%HgW2;s9kL;MbXtzRof~+$=x)EPe;@v$QMb{p37g4~g&Mk4ik; P`mj4Y5x)PI`-k%{090ER diff --git a/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java b/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java index 49f2c309..64aa36dd 100644 --- a/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java +++ b/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java @@ -192,10 +192,18 @@ private static int claimEconomySummary(CommandContext contex List validatedBuildings = ValidatedBuildingRegistryData.get(level).allRecords().stream() .filter(record -> record.settlementId().equals(claimUuid)) .toList(); + RecruitsClaim claim = RecruitsClaimSaveData.get(level).getAllClaims().stream() + .filter(candidate -> candidate.getUUID().equals(claimUuid)) + .findFirst() + .orElse(null); + List mineSites = claim == null + ? List.of() + : StrategicMineSiteService.derive(level, claim, snapshot, validatedBuildings); ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( snapshot, validatedBuildings, - BannerModTreasuryManager.get(level).getLedger(claimUuid) + BannerModTreasuryManager.get(level).getLedger(claimUuid), + mineSites ); for (String line : summary.debugLines()) { context.getSource().sendSuccess(() -> Component.literal(line), false); diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java index c6e603b4..4c163251 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java @@ -1,5 +1,6 @@ package com.talhanation.bannermod.settlement.economy; +import com.talhanation.bannermod.compat.venaterra.VenaterraDepositCategory; import com.talhanation.bannermod.governance.BannerModTreasuryLedgerSnapshot; import com.talhanation.bannermod.settlement.SettlementBuildingRecord; import com.talhanation.bannermod.settlement.SettlementSnapshot; @@ -34,12 +35,28 @@ private ClaimStrategicEconomySummaryService() { public static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, List validatedBuildings, @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger) { + return derive(snapshot, validatedBuildings, treasuryLedger, List.of(), false); + } + + public static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, + List validatedBuildings, + @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, + @Nullable List mineSites) { + return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, true); + } + + private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, + List validatedBuildings, + @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, + @Nullable List mineSites, + boolean strategicMineSitesProvided) { Map resources = new EnumMap<>(ResourceKey.class); for (ResourceKey resource : RESOURCES) { resources.put(resource, new MutableResource()); } List safeValidatedBuildings = validatedBuildings == null ? List.of() : validatedBuildings; + List safeMineSites = mineSites == null ? List.of() : mineSites; Set validBuildingIds = new HashSet<>(); Set invalidBuildingIds = new HashSet<>(); for (ValidatedBuildingRecord record : safeValidatedBuildings) { @@ -56,7 +73,8 @@ public static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, applySupplySignals(resources, snapshot); applyMarket(resources, snapshot); applyTreasury(resources, treasuryLedger); - applyBuildings(resources, snapshot, safeValidatedBuildings, validBuildingIds, invalidBuildingIds); + applyBuildings(resources, snapshot, safeValidatedBuildings, validBuildingIds, invalidBuildingIds, strategicMineSitesProvided); + applyMines(resources, snapshot, safeMineSites); boolean anyShortage = false; boolean anyDegraded = false; @@ -151,7 +169,8 @@ private static void applyBuildings(Map resources, SettlementSnapshot snapshot, List validatedBuildings, Set validBuildingIds, - Set invalidBuildingIds) { + Set invalidBuildingIds, + boolean suppressGenericMineContribution) { resources.get(ResourceKey.FOOD).consumptionHint += snapshot.assignedResidentCount(); if (snapshot.missingWorkAreaAssignmentCount() > 0 || snapshot.unassignedWorkerCount() > 0) { for (MutableResource resource : resources.values()) { @@ -162,6 +181,9 @@ private static void applyBuildings(Map resources, Set snapshotBuildingIds = new HashSet<>(); for (SettlementBuildingRecord building : snapshot.buildings()) { snapshotBuildingIds.add(building.buildingUuid()); + if (suppressGenericMineContribution && isMineLike(building.buildingTypeId())) { + continue; + } BuildingContribution contribution = contributionForBuilding(building); if (contribution.resource() == null) { continue; @@ -192,6 +214,9 @@ private static void applyBuildings(Map resources, if (!record.settlementId().equals(snapshot.claimUuid()) || snapshotBuildingIds.contains(record.buildingId())) { continue; } + if (suppressGenericMineContribution && record.type() == BuildingType.MINE) { + continue; + } BuildingContribution contribution = contributionForValidatedRecord(record); if (contribution.resource() == null) { if (record.state() == BuildingValidationState.VALID && record.type() == BuildingType.STORAGE) { @@ -211,6 +236,35 @@ private static void applyBuildings(Map resources, } } + private static void applyMines(Map resources, + SettlementSnapshot snapshot, + List mineSites) { + for (StrategicMineSite site : mineSites) { + if (site == null || !site.claimUuid().equals(snapshot.claimUuid()) || site.ownerPoliticalEntityId() == null) { + continue; + } + ResourceKey resource = resourceForMineCategory(site.resourceCategory()); + if (resource == null) { + if (site.degraded() || site.unknown()) { + MutableResource unknownMineResource = resources.get(ResourceKey.IRON); + unknownMineResource.degraded = true; + unknownMineResource.unknown = true; + } + continue; + } + + MutableResource mutable = resources.get(resource); + if (site.degraded()) { + mutable.degraded = true; + } + if (site.unknown()) { + mutable.unknown = true; + continue; + } + mutable.productionHint += mineProductionHint(site); + } + } + private static BuildingContribution contributionForBuilding(SettlementBuildingRecord building) { String typeId = building.buildingTypeId() == null ? "" : building.buildingTypeId().toLowerCase(Locale.ROOT); if (typeId.contains("farm") || typeId.contains("crop") || typeId.contains("animal_pen") || typeId.contains("fishing")) { @@ -231,6 +285,14 @@ private static BuildingContribution contributionForBuilding(SettlementBuildingRe return new BuildingContribution(null, 0, 0, Map.of()); } + private static boolean isMineLike(@Nullable String buildingTypeId) { + if (buildingTypeId == null || buildingTypeId.isBlank()) { + return false; + } + String normalized = buildingTypeId.toLowerCase(Locale.ROOT); + return normalized.contains("mine") || normalized.contains("mining_area"); + } + private static BuildingContribution contributionForValidatedRecord(ValidatedBuildingRecord record) { return switch (record.type()) { case FARM -> new BuildingContribution(ResourceKey.FOOD, 2, 1, Map.of()); @@ -265,6 +327,24 @@ private static ResourceKey resourceForGood(String goodId) { return null; } + @Nullable + private static ResourceKey resourceForMineCategory(VenaterraDepositCategory category) { + return switch (category) { + case IRON -> ResourceKey.IRON; + case QUARRY_STONE -> ResourceKey.STONE; + case PRECIOUS_COIN_VALUE -> ResourceKey.COINS; + default -> null; + }; + } + + private static int mineProductionHint(StrategicMineSite site) { + if (site.degraded()) { + return 1; + } + int richnessYield = (int) Math.floor(site.richness() * 3.0F); + return Math.max(1, Math.min(3, richnessYield)); + } + private enum ResourceKey { FOOD("food"), IRON("iron"), diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java index d6cef96a..7d3f9209 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java @@ -51,15 +51,16 @@ static List derive(@Nullable ServerLevel level, List deposits = depositProvider == null ? List.of() : depositProvider.findClaimDeposits(level, claim); deposits = deposits == null ? List.of() : deposits; List sites = new ArrayList<>(); - Set includedBuildingIds = new HashSet<>(); + Set knownMineBuildingIds = new HashSet<>(); for (ValidatedBuildingRecord record : records) { - if (!record.settlementId().equals(claim.getUUID()) - || record.type() != BuildingType.MINE - || record.state() != BuildingValidationState.VALID) { + if (!record.settlementId().equals(claim.getUUID()) || record.type() != BuildingType.MINE) { + continue; + } + knownMineBuildingIds.add(record.buildingId()); + if (record.state() != BuildingValidationState.VALID) { continue; } - includedBuildingIds.add(record.buildingId()); sites.add(createSite( record.buildingId(), claim, @@ -74,7 +75,7 @@ static List derive(@Nullable ServerLevel level, if (snapshot != null && snapshot.claimUuid().equals(claim.getUUID())) { ResourceKey dimension = level == null ? Level.OVERWORLD : level.dimension(); for (SettlementBuildingRecord building : snapshot.buildings()) { - if (includedBuildingIds.contains(building.buildingUuid()) || !isMineLike(building.buildingTypeId())) { + if (knownMineBuildingIds.contains(building.buildingUuid()) || !isMineLike(building.buildingTypeId())) { continue; } sites.add(createSite( diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java index 3ee0780f..ebe984ef 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java @@ -1,5 +1,6 @@ package com.talhanation.bannermod.settlement.economy; +import com.talhanation.bannermod.compat.venaterra.VenaterraDepositCategory; import com.talhanation.bannermod.governance.BannerModTreasuryLedgerSnapshot; import com.talhanation.bannermod.settlement.SettlementBuildingRecord; import com.talhanation.bannermod.settlement.SettlementDesiredGoodsSnapshot; @@ -125,6 +126,109 @@ void validatedRecordMissingFromSnapshotIsDegradedUnknownHint() { assertTrue(wood.unknown()); } + @Test + void strategicMineSitesAddCategoryProductionHints() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(), 0, 0, 0); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(), + null, + List.of( + mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.IRON, 0.75F, false, false), + mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.QUARRY_STONE, 0.45F, false, false), + mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.PRECIOUS_COIN_VALUE, 1.0F, false, false) + ) + ); + + assertEquals(2, line(summary, "iron").productionHint()); + assertEquals(1, line(summary, "stone").productionHint()); + assertEquals(3, line(summary, "coins").productionHint()); + } + + @Test + void strategicMineSiteCategoryReplacesGenericMineIronHint() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + UUID mineUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(building(mineUuid, "bannermod:validated_mine", 2)), 0, 2, 0); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(validated(claimUuid, mineUuid, BuildingType.MINE, BuildingValidationState.VALID)), + null, + List.of(mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.QUARRY_STONE, 0.75F, false, false)) + ); + + assertEquals(0, line(summary, "iron").productionHint()); + assertEquals(2, line(summary, "stone").productionHint()); + } + + @Test + void unknownMineSiteDoesNotGrantYield() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(), 0, 0, 0); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(), + null, + List.of(mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.UNKNOWN_OTHER, 1.0F, true, true)) + ); + + ClaimStrategicEconomySummary.ResourceLine iron = line(summary, "iron"); + assertEquals(0, iron.productionHint()); + assertTrue(iron.degraded()); + assertTrue(iron.unknown()); + } + + @Test + void removedOrInvalidatedMineSiteStopsContributingOnNextDerivation() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + UUID mineUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(building(mineUuid, "bannermod:validated_mine", 2)), 0, 2, 0); + + ClaimStrategicEconomySummary before = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(validated(claimUuid, mineUuid, BuildingType.MINE, BuildingValidationState.VALID)), + null, + List.of(mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.IRON, 0.75F, false, false)) + ); + ClaimStrategicEconomySummary after = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(validated(claimUuid, mineUuid, BuildingType.MINE, BuildingValidationState.INVALID)), + null, + List.of() + ); + + assertEquals(2, line(before, "iron").productionHint()); + assertEquals(0, line(after, "iron").productionHint()); + } + + @Test + void unownedOrOtherClaimMineSiteDoesNotContribute() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(), 0, 0, 0); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(), + null, + List.of( + mineSite(claimUuid, null, VenaterraDepositCategory.IRON, 1.0F, false, false), + mineSite(UUID.randomUUID(), ownerUuid, VenaterraDepositCategory.QUARRY_STONE, 1.0F, false, false) + ) + ); + + assertEquals(0, line(summary, "iron").productionHint()); + assertEquals(0, line(summary, "stone").productionHint()); + } + private static ClaimStrategicEconomySummary.ResourceLine line(ClaimStrategicEconomySummary summary, String resourceId) { return summary.resources().stream() .filter(line -> line.resourceId().equals(resourceId)) @@ -167,6 +271,27 @@ private static ValidatedBuildingRecord validated(UUID claimUuid, ); } + private static StrategicMineSite mineSite(UUID claimUuid, + UUID ownerUuid, + VenaterraDepositCategory category, + float richness, + boolean degraded, + boolean unknown) { + return new StrategicMineSite( + UUID.randomUUID(), + claimUuid, + ownerUuid, + Level.OVERWORLD, + BlockPos.ZERO, + 8, + StrategicMineSite.SourceType.VALIDATED_MINE_BUILDING, + category, + richness, + degraded, + unknown + ); + } + private static SettlementSnapshot snapshot(UUID claimUuid, List buildings, int assignedResidents, diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java index 41d5df3c..0b56d730 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java @@ -174,6 +174,30 @@ void noReliableDepositGivesUnknownDegradedFallback() { assertTrue(site.unknown()); } + @Test + void invalidValidatedMineDoesNotReappearThroughSnapshotFallback() { + RecruitsClaim claim = claim(UUID.randomUUID()); + UUID mineId = UUID.randomUUID(); + + List sites = StrategicMineSiteService.derive( + null, + claim, + snapshot(claim.getUUID(), List.of(miningArea(mineId))), + List.of(validated(claim.getUUID(), mineId, BuildingType.MINE, BuildingValidationState.INVALID)), + deposits(List.of(new VenaterraDepositCandidate( + VenaterraDepositCategory.IRON, + ResourceLocation.withDefaultNamespace("iron_ore"), + ResourceLocation.withDefaultNamespace("raw_iron"), + new BlockPos(0, 64, 0), + 0.75F, + 0.8D, + new VenaterraDepositCandidate.SourceMetadata("venaterra", "test", Level.OVERWORLD.location()) + ))) + ); + + assertTrue(sites.isEmpty()); + } + @Test void nonMineBuildingIsNotIncluded() { RecruitsClaim claim = claim(UUID.randomUUID()); From 97caeb2947a0dd12bcbf28757d8ec1082892a288 Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sun, 10 May 2026 16:32:08 +0700 Subject: [PATCH 4/5] docs: document strategic economy integration --- .../25-STRATEGIC-ECONOMY-SECOND-ACT.md | 360 ++++++++++++++++++ AGENTS.md | 13 + docs/README.md | 4 + docs/STATUS.md | 4 +- docs/STRATEGIC_ECONOMY_INTEGRATION.md | 149 ++++++++ 5 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/25-treasury-taxes-and-army-upkeep/25-STRATEGIC-ECONOMY-SECOND-ACT.md create mode 100644 docs/STRATEGIC_ECONOMY_INTEGRATION.md diff --git a/.planning/phases/25-treasury-taxes-and-army-upkeep/25-STRATEGIC-ECONOMY-SECOND-ACT.md b/.planning/phases/25-treasury-taxes-and-army-upkeep/25-STRATEGIC-ECONOMY-SECOND-ACT.md new file mode 100644 index 00000000..9aa0c5bb --- /dev/null +++ b/.planning/phases/25-treasury-taxes-and-army-upkeep/25-STRATEGIC-ECONOMY-SECOND-ACT.md @@ -0,0 +1,360 @@ +# Phase 25 Strategic Economy: The Second Act + +> Planning context, not an active task queue. The executable decomposition now lives in `docs/BANNERMOD_BACKLOG.sqlite`; use `tools/backlog show ` for canonical scope and acceptance. + +## Goal + +Turn BannerMod's current first act into a repeatable strategy loop: + +```text +Fort -> workers -> food/wood/stone -> smithy/market/barracks -> mine -> weapons/armor/money + -> army -> raids/blockades/object control -> more income -> fort upgrades/status/new forts +``` + +The design target is not more generic PvP. The target is dependency: land produces resources, resources support power, power controls land, and other players can interrupt that chain without needing to destroy a main base. + +## Current Foundation + +BannerMod already has enough substrate to build this as slices rather than a rewrite: + +- Claims, political entities, starter-fort founding, trusted members, and claim protection already define territorial ownership. +- Validated buildings already formalize farms, mines, lumber camps, smithies, storage, markets, barracks, and starter forts. +- Settlement snapshots already collect buildings, residents, stockpile summary, market state, desired goods, supply signals, and growth/project hints. +- Work orders already let farms, mines, lumber camps, build areas, fishing, animal pens, and storage transport publish operational tasks. +- Treasury ledgers, governor heartbeat accounting, army-upkeep debits, occupation tax, war declarations, siege standards, occupations, tribute outcomes, vassal status, revolts, and ally flow already exist. +- Strategic mines should integrate with the custom VenaTerra mod on disk at `/home/kaiserroman/venaterra`: it already owns realistic ore deposits through `OreDepositLookup`, `OreDeposit`, `OreDefinition`, `OreDepositSavedData`, prospector scans, and debug commands. BannerMod should consume a stable VenaTerra API/compat facade instead of duplicating deposit generation or reading VenaTerra internals directly. + +The missing layer is one coherent strategic read model and rule set that turns these existing operational systems into scarcity, specialization, maintenance, and object-centered conflict. + +## Design Principles + +- One fort must not efficiently produce everything. +- Mines and resource sites must be strategic objects, not just prettier ore spots. +- Armies must be expensive enough that economy matters. +- Territory must produce income only while controlled, staffed, supplied, and not disrupted. +- War must have useful stages below total base destruction: raid, blockade, outpost capture, caravan robbery, contested mine, occupation, tribute. +- Money must be a required system input, not a cosmetic extra. +- Low online population must still have demand through NPC contracts. +- Implementation must stay claim-bound, server-authoritative, heartbeat/coarse-cycle driven, and additive over current Phase 24/25 systems. + +## Strategic Resource Model + +Use five first-class resources for beta balance: + +| Resource | Primary meaning | Main sinks | +| --- | --- | --- | +| Food | Keeps workers and armies alive | worker upkeep, army upkeep, garrison readiness, settlement growth | +| Iron | Military and industrial backbone | weapons, armor, tools, heavy soldiers, fort repairs | +| Wood | Construction and mine support | buildings, outposts, roads, siege works, mine maintenance | +| Stone | Fortification and upgrades | walls, fort levels, towers, city upgrades | +| Coins | Liquid political power | wages, contracts, licenses, NPC orders, diplomacy, mercenaries | + +Do not start with every ore, alloy, food type, and luxury good. Normalize physical items into these resource buckets in the strategic layer first. Add special goods later only when they create a new decision. + +VenaTerra may expose richer geology metadata such as coal, copper, tin, bronze inputs, or custom ores. Phase 25 should preserve that metadata for future balance, but the MVP must map it into the five strategic buckets or `unknown/other` until a downstream task gives it a concrete sink. + +## Strategic Objects + +Objects should be explicit records or projections over existing validated buildings/claims before adding new block systems. + +| Object | Gameplay meaning | Existing base to reuse | +| --- | --- | --- | +| Fort | Political center and authority radius | starter fort, claim, settlement snapshot, political entity | +| Mine | Military-industrial source and war target | VenaTerra deposits, `MINE`, `MiningArea`, work orders, claim ownership | +| Farm | Food and army-supply source | `FARM`, crop work orders, food stockpile | +| Lumber camp | Construction and mine-support source | `LUMBER_CAMP`, lumber work orders | +| Trading post/market | Coins, NPC demand, contracts | `MARKET`, `MarketArea`, merchant/seller dispatch | +| Storage/stockpile | Resource inventory and route endpoint | `StorageArea`, stockpile summary, logistics routes | +| Road/bridge | Supply-line/chokepoint target | logistics route endpoints first, authored route state later | +| Outpost | Small capturable control node | claim sub-object or siege-standard-like objective | + +## Phase Ownership + +This document defines the second-act gameplay program, but not every slice belongs to Phase 25. + +| Owner | Responsibility in this plan | +| --- | --- | +| Phase 25 | Strategic economy read model, fort levels, resource caps, mine yield/maintenance, NPC contracts, economy-pressure inputs for existing upkeep, and governance/economy records for treaty payments if implemented before full war gating. | +| Phase 26 | Soft war objectives, object control, raid/blockade effects, and any recruit/combat behavior around those objectives. | +| Phase 27 | Settlement/War Room/contract-board UI and player operation surfaces over stable read models. | +| Phase 28 | Balance presets, telemetry, rollout controls, migration safety, and operator tuning. | + +Phase 25 should therefore produce data and server-side rules that later phases can consume. It should not implement full war-objective campaigns, broad UI, or final rollout tooling inside the settlement economy slice. + +## Current Authority Constraint + +`docs/STATUS.md` still calls out a split source of truth between entity-side `currentWorkArea` and registry-side `ValidatedBuildingRecord`. Any strategic summary, cap, mine ownership, or worker assignment rule must handle that explicitly: + +- Prefer `ValidatedBuildingRecord` and settlement snapshot data for strategic projections when available. +- Treat live entity/work-area state as operational evidence, not the long-term authority, until the assignment binding cleanup is complete. +- Mark stale, missing, or conflicting building/worker bindings as degraded/unknown instead of granting full production or cap credit. + +## Implementation Slices + +### Slice 1: Strategic Economy Read Model + +Create a server-side, claim-keyed strategic projection derived from existing settlement snapshots and treasury ledgers. + +Deliverables: + +- Add a compact settlement economy summary with the five resource buckets, production hints, consumption hints, storage capacity, and shortage flags. +- Derive resource buckets from current stockpiles and known item tags where safe; fall back to building/profile signals when exact inventory is unavailable. +- Keep it read-model first: no new global economy manager, no per-tick simulation. +- Surface the summary through existing snapshot/debug/UI paths enough for verification. + +Acceptance checks: + +- A fort with farm/mine/lumber/storage buildings reports distinct food/iron/wood/stone/coin capacity or production signals. +- Removing or invalidating a building changes the next strategic summary. +- The projection remains claim-bound and survives reload if persisted fields are added. + +### Slice 2: Settlement Specialization And Fort Levels + +Make forts progress through clear levels and make local geography/building mix matter. + +Proposed levels: + +| Level | Name | Workers | Soldiers | Mines | Outposts | Meaning | +| --- | --- | ---: | ---: | ---: | ---: | --- | +| 1 | Outpost/Zastava | 4 | 4 | 0-1 | 0 | foothold, starter economy | +| 2 | Fort | 8 | 10 | 1 | 1 | first military economy | +| 3 | Stronghold | 16 | 25 | 2 | 2 | regional power | +| 4 | Town/City | 32 | 50 | 3 | 4 | political center | + +Deliverables: + +- Add fort-level state to settlement/claim/political read model with upgrade requirements expressed in the five resources. +- Gate worker, soldier, mine, and outpost limits by fort level. +- Connect growth/project scoring to the next fort-level requirement instead of vague “more buildings”. +- Make upgrade cost consume from treasury/stockpile only through server-authoritative actions. + +Acceptance checks: + +- A starter fort has visible next-level requirements. +- A level blocks over-limit mines/outposts/recruit expansion with a clear denial reason. +- Upgrading changes the limits and survives reload. + +### Slice 3: Mine Ownership, Yield, And Maintenance + +Turn mines into named strategic assets. + +Deliverables: + +- Define mine site records from validated mine buildings or claim-bound mine cores. +- Assign each mine a category from VenaTerra deposit metadata when available: iron, coal, copper/tin/bronze, silver/gold, stone/quarry. +- Add periodic strategic yield into the owning settlement economy summary or treasury pipeline. +- Add maintenance costs: workers, food, wood supports, tools, guard/safety readiness. +- Add soft depletion: overworking reduces efficiency until maintenance/restoration catches up. +- Reserve a read-only external disruption field so Phase 26 can later feed contested/blocked state into yield calculations without Phase 25 owning object-control rules. +- Keep a vanilla-safe fallback for worlds without VenaTerra or for deposits with unknown metadata. + +First beta balance: + +- Small iron mine: modest iron every cycle. +- Large/rich iron mine: higher iron and rare bonus chance. +- Silver/gold mine: lower military value, higher coin/tax/trade value. +- Quarry: stone for fort upgrades and walls. + +Acceptance checks: + +- Owning a mine changes iron/stone/coin income in the strategic summary. +- Unstaffed or unmaintained mines degrade output instead of staying infinite. +- A mine can report normal/degraded/unknown economic state, and later Phase 26 disruption input can reduce yield without deleting the main fort. + +### Slice 4: Army Upkeep As Economy Pressure + +Make army size an economic decision. + +Deliverables: + +- Extend existing upkeep accounting so recruits consume food and/or coins from their home/upkeep claim on a coarse cycle. +- Add heavier maintenance classes for heavy infantry/cavalry/ranged elite when equipment or recruit type can be inferred. +- Add bounded penalties: unpaid, hungry, undersupplied, recruitment blocked, desertion risk only after repeated failures. +- Connect soldier cap to fort level and barracks availability. + +Acceptance checks: + +- A settlement with no food/coins cannot indefinitely support a growing army. +- Upkeep failures are visible in governor/war/settlement status. +- Penalties are bounded and do not instantly delete armies on one missed cycle. + +### Slice 5: NPC Demand And Contracts + +Create demand that works with 3-5 online players. + +Deliverables: + +- Add periodic NPC contract records: buyer, requested resource bucket/item set, amount, deadline, reward, reputation/influence reward. +- Start with server records plus command/debug/status output rather than full caravan simulation or a contract-board UI. +- Pay coins, rare goods, reputation/influence, or unlock rights. +- Let markets/trading posts increase contract quality or frequency. + +Example contracts: + +- City buys 128 iron. +- Monastery needs bread and leather. +- Mercenary company buys weapons. +- Port city trades salt for silver. +- Guild buys wool or timber. + +Acceptance checks: + +- Contracts spawn even with one player online. +- Completing a contract consumes goods and pays a useful reward. +- Markets/trading posts make contracts strategically better than isolated forts. + +### Slice 6: Soft War Objectives Around Economy + +Phase owner: Phase 26, consuming Phase 25 economy/object records. + +Add conflict targets below full siege. + +Deliverables: + +- Add objective records tied to war or local hostility: mine dispute, road blockade, outpost capture, caravan raid, farm raid, stockpile raid. +- Reuse siege-standard concepts for objective presence/control instead of inventing a second combat-objective framework. +- Add “blocked” and “raided” states that reduce income/yield for a limited time instead of destroying player bases. +- Expose server-side objective state/read-model fields for later Phase 27 War Room/map/HUD presentation. + +Acceptance checks: + +- A mine can be contested and have reduced output while the fort remains intact. +- A road/storage route can be marked blocked and lower supply effectiveness. +- Raids produce limited spoils and cooldowns, not unlimited grief. + +### Slice 7: Tribute, Vassalage, And Casus Belli + +Phase owner: cross-phase. Phase 25/governance owns ongoing tribute/vassal obligation records and treasury payment execution; Phase 26 owns CB/war-goal gating and war consequences; Phase 27 owns player-facing negotiation UI. + +Turn existing one-shot outcomes into ongoing politics. + +Deliverables: + +Phase 25/governance-economy: + +- Add tribute treaty records: payer, receiver, amount/resource, interval, missed payments, source claim. +- Add vassal relationship records with overlord id, obligations, tribute terms, protection terms, and revolt/liberation hooks. +- Emit missed-payment and broken-term facts for the war layer. + +Phase 26/war: + +- Add typed casus belli records: mine dispute, unpaid tribute, caravan raid, worker killing, border violation, outpost capture, occupied land liberation. +- Gate war goals and peace terms by CB type. + +Acceptance checks: + +- Refusing or missing tribute creates a visible CB. +- Vassal status points to an overlord and produces periodic obligations, not just a label. +- War declarations explain “why this war is valid” in-world. + +### Slice 8: UI And Player Operations + +Phase owner: Phase 27, after Phase 25/26 read models are stable. + +Make the system readable before making it deeper. + +Deliverables: + +- Settlement/fort screen: level, resource buckets, income/consumption, shortages, next upgrade. +- Strategic-object list: mines, farms, lumber camps, markets, outposts, blocked/raided/contested state. +- War Room: objectives, pressure, allowed terms, locked-term explanations. +- Contract board: NPC demand, rewards, deadlines, delivery status. + +Acceptance checks: + +- A new player can answer: “What do I lack, what should I build/capture/trade for, and what is threatening me?” +- Denied actions give server-backed reasons. +- The UI does not mutate gameplay state client-side. + +### Slice 9: Balance, Telemetry, And Rollout + +Phase owner: Phase 28, after the economy and conflict loops have measurable runtime behavior. + +Stabilize the beta economy before adding complexity. + +Deliverables: + +- Configurable cycle lengths, yields, upkeep, caps, and contract frequency. +- Debug commands/report for claim economy summary and active strategic objects. +- Telemetry counters for production, consumption, raids, blockades, contracts, tribute payments, missed payments, and desertion-risk triggers. +- Beta presets for small servers. + +Acceptance checks: + +- Admins can inspect why a settlement is rich, starving, blocked, or over-militarized. +- Balance values can be changed without code edits. +- The system can be disabled or kept read-only if a beta save needs emergency mitigation. + +## Narrow Phase 25 MVP + +The smallest Phase 25 deliverable should be: + +1. Strategic economy summary with food, iron, wood, stone, coins. +2. Fort levels with worker/soldier/mine/outpost caps. +3. Mine ownership and periodic yield. +4. Army upkeep consuming food/coins and blocking reckless recruitment. +5. NPC contracts that buy goods for coins/reputation. + +Nearest beta can run with manual/admin rules for raids, tribute, and mine disputes while Phase 26/27/28 turn those into full systems. + +## Full Second-Act MVP + +The smallest playable second act across phases should be: + +1. Phase 25 strategic economy summary with food, iron, wood, stone, coins. +2. Phase 25 fort levels with worker/soldier/mine/outpost caps. +3. Phase 25 mine ownership and periodic yield. +4. Phase 25 army upkeep pressure consuming food/coins and blocking reckless recruitment. +5. Phase 25 NPC contracts that buy goods for coins/reputation. +6. Phase 26 mine/outpost/route objective states: normal, contested, blocked, raided. +7. Phase 25/26/27 manual first, then UI-assisted tribute treaties before a full diplomacy suite. + +This is enough to create dependencies: + +- Mountain fort has iron and stone, but needs food. +- River settlement has food and contracts, but needs protection. +- Forest outpost has wood/leather/mobile troops, but needs metal. +- Raiders can profit by cutting roads or caravans without ending a player's save. + +## Canonical Backlog Decomposition + +The executable queue lives in `docs/BANNERMOD_BACKLOG.sqlite`; use `tools/backlog show ` for full scope and acceptance. The first decomposition is: + +| Area | Tasks | +| --- | --- | +| VenaTerra cross-repo prerequisites | `VEN-001`, `VEN-002` | +| BannerMod Phase 25 economy MVP | `ECON-020` through `ECON-030` | +| Resource mutation prerequisite | `ECON-031` | +| Treaty/economy politics | `POL-020` | +| Phase 26 economic war objectives | `WAR-020` through `WAR-026` | +| Phase 27 player-facing surfaces | `UI-020` through `UI-023` | + +`VEN-001` and `VEN-002` are intentionally tracked in the BannerMod backlog as cross-repo prerequisites because BannerMod's mine economy depends on VenaTerra. Their implementation and verification happen in `/home/kaiserroman/venaterra`; BannerMod-side closure must reference the VenaTerra commit/test evidence plus the BannerMod compat consumer evidence. + +Do not create new work from this document by copying old draft IDs. Add or split tasks with `tools/backlog` so dependencies and validation remain canonical. + +## Not In The First Pass + +- Full CK-style diplomacy, dynasties, claims fabrication, or law systems. +- Global market simulation with dynamic prices for every item. +- Fully physical caravan hauling for all taxes and contracts. +- Infinite resource taxonomy with dozens of goods before the five-resource loop works. +- Automatic total-war resolution without admin/operator tools during beta. +- Punitive grief mechanics that can erase a settlement through one offline raid. + +## Verification Strategy + +- Start with pure unit tests for strategic summary, caps, yields, upkeep costs, contract generation, and treaty payment rules. +- Add focused server/GameTest coverage only when a slice mutates live entities, claims, work areas, or storage. +- Use `ctx log -- ./gradlew compileJava` as the cheap gate for wiring slices. +- Use player-facing UAT scenarios for the beta loop: mountain fort, river settlement, forest outpost, contested mine, NPC contract, tribute default. + +## Player-Facing Documentation Impact + +When any slice becomes live gameplay, update these in the same implementation batch: + +- `MULTIPLAYER_GUIDE_RU.md` +- `MULTIPLAYER_GUIDE_EN.md` +- `docs/BANNERMOD_ALMANAC.html` + +The player message should stay simple: build a fort, identify your shortage, secure a resource object, trade or raid to cover deficits, keep your army supplied, and use politics to avoid or profit from war. diff --git a/AGENTS.md b/AGENTS.md index 1f864349..dcc97033 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,19 @@ After coding, provide: - If the user invokes `/backlog-execute [N]`, treat it as an execution command, not a planning request: default `N=5`, run `tools/backlog ready N`, pick that batch as the active queue, and begin execution under the backlog dependency, worktree, feature-branch, and finish-or-split rules. - For `/backlog-execute [N]` with parallel subagents, create one dedicated worktree and one dedicated feature branch per task before any edits. For dependency chains, complete the first task and branch the dependent task from the updated tip of the first task branch. +Documentation index for agents: +- `docs/README.md` - top-level documentation index. +- `docs/STATUS.md` - current developer status, known open areas, and live system references. +- `docs/CONTRIBUTING.md` - contribution, verification, and review flow. +- `docs/DEVELOPMENT.md` - codebase map, hot spots, and validation shortcuts. +- `docs/TOOLS.md` - local tools, context proxy, backlog helper, and repo skills. +- `docs/BANNERMOD_BACKLOG.sqlite` - canonical backlog; use `tools/backlog`, not raw SQLite reads/writes. +- `docs/BANNERMOD_TECHNICAL_DESIGN.md` - architecture, state model, authority model, invariants, and target end state. +- `docs/STRATEGIC_ECONOMY_INTEGRATION.md` - VenaTerra-backed strategic economy, mine-site, yield, backlog, and verification notes. +- `docs/AI_MINECRAFT_UI_STYLE_GUIDE.md` - required style rules for Minecraft-native UI/HUD work. +- `MULTIPLAYER_GUIDE_RU.md` / `MULTIPLAYER_GUIDE_EN.md` - player-facing guides; update them for shipped player-visible mechanics. +- `docs/BANNERMOD_ALMANAC.html` - compact player almanac; update it with player-facing UI/mechanic changes. + 12. Minecraft UI design - For Minecraft GUI/HUD work, load and apply the repo skill `minecraft-ui-design` from `.agents/skills/minecraft-ui-design/SKILL.md`. - UI must be Minecraft-native, minimal, readable, server-authoritative for gameplay mutations, localized, and checked for overlap with hotbar/chat/crosshair/boss bars/existing BannerMod overlays. diff --git a/docs/README.md b/docs/README.md index b1c5c913..915cebfa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,8 +10,12 @@ This directory contains active module documentation. Root-level Markdown is inte - `STATUS.md` - current developer-facing project status. - `CONTRIBUTING.md` - contribution flow for developers and agents. - `DEVELOPMENT.md` - codebase map, hot spots, and validation shortcuts. +- `BANNERMOD_TECHNICAL_DESIGN.md` - architecture, state model, authority model, invariants, and target end state. +- `STRATEGIC_ECONOMY_INTEGRATION.md` - VenaTerra-backed strategic economy and mine integration notes. +- `AI_MINECRAFT_UI_STYLE_GUIDE.md` - style rules for Minecraft-native GUI/HUD work. - `BANNERMOD_BACKLOG.sqlite` - canonical unfinished-work queue; use `../tools/backlog` to inspect or update it. - `BANNERMOD_BACKLOG.md` - short pointer to the SQLite backlog and tool commands. +- `BANNERMOD_ALMANAC.html` - compact player-facing almanac derived from the guides and current runtime state. - `TOOLS.md` - local developer tools, including the context proxy multitool. - Agent guardrail hooks/plugins are documented in `TOOLS.md` and live under `tools/agent-hooks/`, `.codex/`, and `.opencode/`. diff --git a/docs/STATUS.md b/docs/STATUS.md index dd9e1e90..b1fbfdce 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Developer Status -Last updated: 2026-04-28. +Last updated: 2026-05-10. ## Runtime State @@ -34,12 +34,14 @@ Last updated: 2026-04-28. - War-state sync now uses dirty/version tracking and cached payloads instead of serializing all war records every idle second for hash polling. - True-async pathfinding now checks scheduler capacity before snapshot capture and rejects over-budget snapshots with profiling counters. - GameTests are being hardened around claim worker growth, dedicated-server ownership, player cycle, settlement degradation, and upkeep currency sourcing. +- Strategic economy second-act groundwork is in progress on `feature/strategic-economy-second-act`: VenaTerra now exposes a read-only deposit survey API in `/home/kaiserroman/venaterra`, BannerMod has an optional reflection bridge, claim-level food/iron/wood/stone/coin summary hints, strategic mine site records, coarse mine yield hints, and admin debug commands for `/bannermod debug economy claim ` plus `/bannermod debug economy mines `. See `docs/STRATEGIC_ECONOMY_INTEGRATION.md`. ## Known Open Areas - Full settlement onboarding remains incomplete: a new player still needs clearer starter-fort/town-hall, surveyor, wand, citizen, and profession guidance in-game. - Worker assignment binding is split between two sources of truth: the entity-side `currentWorkArea` (cached on `AbstractWorkerEntity`) and the registry-side `ValidatedBuildingRecord` in the settlement validated-building registry. The remaining gap is making `ValidatedBuildingRecord` the authoritative assignment source and reducing `currentWorkArea` to a derived runtime cache. - Sea-trade production/consumption is documented as hints, but not yet a full gameplay loop. +- Strategic mine output is currently a summary/read-model hint only. It does not yet mutate storage, treasury, or item stacks; mine maintenance/depletion (`ECON-024`), fort caps (`ECON-025`), NPC contracts (`ECON-029`), and strategic resource debit/credit (`ECON-031`) remain open. - Remaining war gaps are depth/coverage gaps, not absent systems: some outcomes are still command/admin-heavy, occupation control is lighter than a full governance loop, revolt resolution depends on objective presence rather than a richer objective campaign, siege-standard AI is basic attack/escort behavior, and morale plus ranged-backline polish still need follow-up. - `verifyGameTestStage` last had a known failure around `reconnectedOwnerRecoversAuthorityAfterOwnershipRoundTrip` in a Better Combat-present smoke; do not claim full GameTest green until rerun successfully. diff --git a/docs/STRATEGIC_ECONOMY_INTEGRATION.md b/docs/STRATEGIC_ECONOMY_INTEGRATION.md new file mode 100644 index 00000000..04c6b60d --- /dev/null +++ b/docs/STRATEGIC_ECONOMY_INTEGRATION.md @@ -0,0 +1,149 @@ +# Strategic Economy And VenaTerra Integration + +Last updated: 2026-05-10. + +## Purpose + +This document captures the non-obvious integration facts discovered and implemented during the strategic economy second-act work. It is developer-facing context for BannerMod agents and humans working on `VEN-*`, `ECON-*`, `POL-*`, `WAR-*`, and `UI-*` backlog tasks. + +## Current Branch State + +- Active BannerMod feature branch for this work: `feature/strategic-economy-second-act`. +- Landed BannerMod commits in that branch: + - `c8e4c1e5 feat: add strategic economy summary and VenaTerra bridge` + - `e7e32b50 feat: add strategic mine site records` + - `d75739d4 feat: add strategic mine yield hints` +- VenaTerra changes were made in `/home/kaiserroman/venaterra`, which is not a git repository in this workspace. Treat those changes as cross-repo workspace state until VenaTerra gets its own VCS/patch flow. + +## Backlog State + +Completed in this session: + +- `VEN-001` - public VenaTerra deposit survey API. +- `VEN-002` - strategic ore bucket metadata. +- `ECON-020` - optional BannerMod VenaTerra bridge. +- `ECON-021` - claim strategic economy summary. +- `ECON-022` - strategic mine site records. +- `ECON-023` - mine yield cycle into economy summary hints. + +Ready next after those closures: + +- `ECON-024` - mine maintenance and soft depletion. +- `ECON-025` - fort levels and strategic caps. +- `ECON-029` - NPC contract records. +- `ECON-031` - server-authoritative strategic resource debit pipeline. + +Use `tools/backlog show ` for canonical acceptance. Do not copy task text out of planning docs. + +## VenaTerra Workspace Facts + +- VenaTerra lives at `/home/kaiserroman/venaterra`. +- It is a custom mod, not an archive tree under BannerMod. +- It is not a git repository in this workspace. +- It builds with its own Gradle wrapper. +- Useful existing VenaTerra internals before the public API: + - `ru.kaiserroman.venaterra.deposit.OreDepositLookup.findMatches(ServerLevel, BlockPos)` finds deposits affecting one position. + - `OreDepositLookup.findNearby(ServerLevel, BlockPos, int)` performs prospector-style nearby scans. + - `OreDepositSavedData.getOrCreateRegion(...)` generates/migrates regions and calls `setDirty()`; do not use it from BannerMod compat read paths. + - `OreDepositSavedData.consumeGeneratedOre(...)` is destructive and is used by the ore drop path; never use it for strategic surveying. + - `OreDepositGenerator.generate(...)` can build transient deterministic regions without saving them. + - `DepositMath.densityAt(...)`, `surveyScore(...)`, and `horizontalDistanceSquared(...)` are the reusable scoring helpers. + - `ProspectorToolItem` and `/realisticores debug here|scan` are existing examples of deposit consumers. + +## VenaTerra Public API Contract + +The public API added for BannerMod lives under `ru.kaiserroman.venaterra.api`: + +- `VenaterraDepositApi.findDepositsAt(ServerLevel, BlockPos)`. +- `VenaterraDepositApi.findDepositsNear(ServerLevel, BlockPos, int)`. +- `VenaterraDepositApi.findDepositsInArea(ServerLevel, BlockPos, int)`. +- `DepositSurveyResult` exposes `oreId`, `dropItemId`, `center`, `radius`, `coreRadius`, `halfHeight`, `richness`, `chance`, `minY`, `maxY`, `dimension`, and `category`. +- `StrategicOreCategory` values are `IRON`, `INDUSTRIAL_FUEL`, `PRECIOUS_COIN_VALUE`, `QUARRY_STONE`, and `UNKNOWN_OTHER`. +- `StrategicOreClassifier` maps vanilla iron, coal/fuel, gold/copper value, quarry/stone materials, and unknown/custom ores into the strategic categories. + +Important API invariants: + +- The public survey API must stay read-only with respect to saved deposit data. +- The API uses transient `OreDepositGenerator.generate(...)` output, not `OreDepositLookup` or `OreDepositSavedData`. +- The API must never call `consumeGeneratedOre(...)`. +- Ignored dimensions return an empty result. +- `DepositSurveyResult.center()` defensively stores an immutable `BlockPos`. +- `redstone` and `glowstone` must not become `QUARRY_STONE` merely because their names contain `stone`. +- The current structural test guards that the API source does not reference `OreDepositLookup` or `OreDepositSavedData`; this is useful but not a substitute for a future runtime saved-data mutation test. + +## BannerMod Optional VenaTerra Bridge + +BannerMod does not hard-depend on VenaTerra. The bridge lives under `com.talhanation.bannermod.compat.venaterra`: + +- `VenaterraDepositProvider.create()` checks `ModList` for mod id `venaterra` and resolves the VenaTerra API reflectively. +- Missing mod, missing API classes, malformed result records, and API call failures degrade to an empty provider or skipped result instead of crashing. Unknown/future category enum names map to `UNKNOWN_OTHER` candidates. +- `findClaimDeposits(ServerLevel, RecruitsClaim)` queries a claim bounding area, then filters candidates by exact claimed `ChunkPos`, not only by bounding rectangle. +- `VenaterraDepositCandidate` carries `category`, `oreId`, `dropItemId`, immutable `center`, `richness`, `confidence`, and `SourceMetadata`. +- `VenaterraDepositCategory.fromApiCategory(...)` maps unknown/future VenaTerra enum names to `UNKNOWN_OTHER`. + +## Claim Strategic Economy Summary + +The claim summary read model lives in `com.talhanation.bannermod.settlement.economy`: + +- `ClaimStrategicEconomySummary` contains one `ResourceLine` each for `food`, `iron`, `wood`, `stone`, and `coins`. +- Each resource line carries `stockpileHint`, `capacityHint`, `productionHint`, `consumptionHint`, `shortage`, `degraded`, and `unknown`. +- `ClaimStrategicEconomySummaryService.derive(snapshot, validatedBuildings, treasuryLedger)` keeps the original building-derived behavior. +- `ClaimStrategicEconomySummaryService.derive(snapshot, validatedBuildings, treasuryLedger, mineSites)` includes strategic mine yield hints. +- Stale, missing, invalid, or conflicting building/work-area data should produce degraded/unknown credit, not full production. +- When strategic mine sites are supplied, generic mine-like building iron contribution is suppressed. This prevents quarry or precious mines from also producing phantom iron. + +## Strategic Mine Site Model + +Strategic mine site records also live under `com.talhanation.bannermod.settlement.economy`: + +- `StrategicMineSite` binds a mine to `siteId`, `claimUuid`, owner political entity id, dimension, center, radius, source type, VenaTerra category, richness, degraded flag, and unknown flag. +- Source types are `VALIDATED_MINE_BUILDING` and `CLAIM_MINE_WORK_AREA`. +- `StrategicMineSiteService` derives sites from valid `BuildingType.MINE` records and mine-like snapshot records such as `mine` or `mining_area`. +- Invalid validated mine records are remembered as known mine ids, so they do not reappear through snapshot fallback. +- VenaTerra candidates are matched by dimension and horizontal distance. The nearest reliable candidate wins; confidence and richness are tie-breakers. +- Unknown or unsupported candidates produce a degraded/unknown site with no inflated yield. + +## Mine Yield Hints + +`ECON-023` intentionally writes mine output into strategic economy summary production hints only. It does not mutate storage, treasury, or item stacks. + +- `IRON` mine sites add iron production hints. +- `QUARRY_STONE` mine sites add stone production hints. +- `PRECIOUS_COIN_VALUE` mine sites add coin/value production hints. +- Known valid mines produce `1..3` hint units based on richness. +- Known degraded mines currently produce `1` hint unit. +- Unknown mines add no production and mark the relevant summary state as degraded/unknown. +- Removed or invalidated mine sites stop contributing on the next derivation. + +`ECON-024` is the next task that should turn this into maintenance/depletion pressure. It must not implement war/object-control rules; those belong to later `WAR-*` tasks. + +## Admin Debug Surface + +The current operator-facing verification paths are in `AdminDebugCommands`: + +- `/bannermod debug economy claim ` prints the claim strategic economy summary and includes strategic mine yield when a claim can be resolved. +- `/bannermod debug economy mines ` prints strategic mine site debug lines. + +These are debug/status paths, not player-facing UI. Player-facing screens are deferred to `UI-020` through `UI-023`. + +## Verification Commands Used + +VenaTerra: + +- `./gradlew test` with working directory `/home/kaiserroman/venaterra`. +- `./gradlew compileJava` with working directory `/home/kaiserroman/venaterra`. + +BannerMod focused gates: + +- `./gradlew test --tests com.talhanation.bannermod.compat.venaterra.VenaterraDepositProviderTest` +- `./gradlew test --tests com.talhanation.bannermod.settlement.economy.ClaimStrategicEconomySummaryServiceTest` +- `./gradlew test --tests com.talhanation.bannermod.settlement.economy.StrategicMineSiteServiceTest` +- Combined focused runs for the three tests above. +- `./gradlew compileJava` +- `tools/backlog validate` + +Known verification gap: + +- No in-game execution of `/bannermod debug economy claim ` or `/bannermod debug economy mines ` has been recorded yet. +- No runtime test with a real loaded VenaTerra jar on BannerMod classpath has been recorded yet; BannerMod bridge tests use reflective fakes. +- Full test suite was not run during this session. From e45533da80d4b307cef978be105507beeb46a37b Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sun, 10 May 2026 17:43:59 +0700 Subject: [PATCH 5/5] feat: add strategic economy progression systems --- MULTIPLAYER_GUIDE_EN.md | 2 + MULTIPLAYER_GUIDE_RU.md | 2 + docs/BANNERMOD_ALMANAC.html | 2 + docs/BANNERMOD_BACKLOG.sqlite | Bin 856064 -> 860160 bytes docs/STATUS.md | 4 +- docs/STRATEGIC_ECONOMY_INTEGRATION.md | 64 +++-- .../commands/admin/AdminDebugCommands.java | 34 ++- .../commands/war/BannerModWarCommands.java | 1 + .../commands/war/EconomyStatusCommands.java | 60 +++++ .../persistence/military/RecruitsClaim.java | 14 ++ .../SettlementClaimTickService.java | 1 + .../settlement/SettlementOrchestrator.java | 31 ++- .../economy/ClaimStrategicEconomySummary.java | 11 + .../ClaimStrategicEconomySummaryService.java | 63 ++++- .../economy/FortLevelDefinition.java | 62 +++++ .../settlement/economy/NpcDemandContract.java | 120 ++++++++++ .../economy/NpcDemandContractSavedData.java | 41 ++++ .../economy/NpcDemandContractService.java | 221 ++++++++++++++++++ .../settlement/economy/StrategicMineSite.java | 3 + .../economy/StrategicMineSiteService.java | 51 ++++ .../StrategicResourceAccountSnapshot.java | 109 +++++++++ .../StrategicResourceAccountingManager.java | 88 +++++++ .../StrategicResourceAccountingService.java | 146 ++++++++++++ .../economy/StrategicResourceBucket.java | 23 ++ ...aimStrategicEconomySummaryServiceTest.java | 113 ++++++++- .../economy/FortLevelDefinitionTest.java | 78 +++++++ .../economy/NpcDemandContractServiceTest.java | 160 +++++++++++++ .../economy/StrategicMineSiteServiceTest.java | 65 +++++- ...trategicResourceAccountingServiceTest.java | 175 ++++++++++++++ 29 files changed, 1709 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/talhanation/bannermod/commands/war/EconomyStatusCommands.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/FortLevelDefinition.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContract.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractSavedData.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractService.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountSnapshot.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingManager.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingService.java create mode 100644 src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceBucket.java create mode 100644 src/test/java/com/talhanation/bannermod/settlement/economy/FortLevelDefinitionTest.java create mode 100644 src/test/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractServiceTest.java create mode 100644 src/test/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingServiceTest.java diff --git a/MULTIPLAYER_GUIDE_EN.md b/MULTIPLAYER_GUIDE_EN.md index 36fe5d0f..05fa5763 100644 --- a/MULTIPLAYER_GUIDE_EN.md +++ b/MULTIPLAYER_GUIDE_EN.md @@ -144,6 +144,8 @@ Messenger, noble-trade, governor, patrol, and scout screens now keep their prima The logistics panel also labels the settlement's strategic role and route cost. Farms plus storage can make a surplus hub, market plus routes can make a junction market, fort plus route storage can make a chokepoint fort, and ports/water access become a water gate. Landlocked settlements with food or material production may show a specialty such as preserved food or worked materials. These labels are warnings and planning hints first: they expose logistics objectives and loyalty pressure before applying destructive penalties. +Strategic economy status now treats the starter fort as level 1 and exposes the next fort-level requirements in food, iron, wood, stone, and coins through server status/debug output. Mines are not infinite passive income: a mine with no assigned worker, missing food, missing wood supports, missing tools, or a reserved disruption pressure can show degraded yield. Server-side NPC demand contracts can appear even with one player online; markets and trading posts improve their cadence or quality. Use `/bannermod economy contracts ` for the server-backed contract list, or the admin debug economy commands for deeper inspection. + ### Code-backed settlement mechanics reference The settlement stack is not a single magic block. It is a pipeline of records and snapshots: diff --git a/MULTIPLAYER_GUIDE_RU.md b/MULTIPLAYER_GUIDE_RU.md index 69387bd7..dab80f62 100644 --- a/MULTIPLAYER_GUIDE_RU.md +++ b/MULTIPLAYER_GUIDE_RU.md @@ -163,6 +163,8 @@ BannerMod добавляет поселения, рабочих, армии, г Панель логистики также показывает стратегическую роль поселения и стоимость снабжения. Фермы плюс склад могут стать surplus hub, рынок плюс маршруты — junction market, форт плюс маршрутный склад — chokepoint fort, а порт/доступ к воде — water gate. У landlocked-поселений с едой или материалами может появиться специализация вроде preserved food или worked materials. Это сначала подсказки и предупреждения: они показывают логистические цели войны и давление на лояльность до жестких штрафов. +Стратегическая экономика теперь считает стартовый форт уровнем 1 и через серверный статус/debug показывает требования к следующему уровню в еде, железе, дереве, камне и монетах. Шахты больше не считаются бесконечным пассивным доходом: если нет назначенного работника, еды, деревянных крепей, инструментов или действует зарезервированное давление нарушения, добыча может стать degraded. NPC-контракты спроса могут появляться даже при одном игроке онлайн; рынок и торговый пост улучшают частоту или качество таких контрактов. Список серверных контрактов смотри командой `/bannermod economy contracts `, а глубокую проверку — через admin debug economy команды. + ### Справочник механик поселения по коду Поселение — это не один магический блок, а цепочка записей и снапшотов: diff --git a/docs/BANNERMOD_ALMANAC.html b/docs/BANNERMOD_ALMANAC.html index a4d67a5a..ec05e821 100644 --- a/docs/BANNERMOD_ALMANAC.html +++ b/docs/BANNERMOD_ALMANAC.html @@ -92,6 +92,7 @@

Land and sea logistics

Long land routes may use a nearby approved free mount. If no mount is available, the courier continues on foot. Sea routes can use compatible ship carriers when available; the governor logistics mirror shows route id, carrier id or unassigned, phase, cargo progress, and blocked reason.

Taxes and strategy

Read tax lines as collected / due. Paid taxes fund the treasury; unpaid taxes show why payment failed and may stall upkeep or defense funding. Strategic labels such as surplus hub, junction market, chokepoint fort, and water gate are derived from real settlement conditions, not decorative text.

+

Starter forts begin at strategic fort level 1. Server status/debug output shows the next level's food, iron, wood, stone, and coin requirements plus current caps. Mines need assigned workers and basic support inputs; missing food, wood supports, tools, or reserved disruption pressure can degrade strategic yield. NPC demand contracts are server records, can spawn with one player online, and improve when the claim has a market or trading post. Use /bannermod economy contracts <claimUuid> to inspect them.

Messenger, governor, noble-trade, patrol, and scout screens now carry their own status ribbon. Read it before closing the screen: it tells you whether you still need a recipient, contract, route, or other requirement, and it also confirms when the order was accepted and sent onward.

@@ -237,6 +238,7 @@

Суша и вода

На длинных сухопутных путях перевозчик может автоматически сесть на ближайшее свободное разрешённое животное. Если такого нет, он идёт пешком. Морские пути могут использовать совместимые суда, если они есть; в сведениях правителя показываются номер пути, судно или отсутствие судна, этап, ход груза и причина остановки.

Налоги и стратегические роли

Налоги читаются как «собрано / требуется». Оплаченные налоги пополняют казну; неоплаченные показывают причину и могут остановить жалованье или оборонные расходы. Роли «избыточный пищевой узел», «рыночная развилка», «пограничный форт» и «водные ворота» берутся из реальных условий поселения.

+

Стартовый форт начинает как стратегический форт уровня 1. Серверный статус/debug показывает требования следующего уровня в еде, железе, дереве, камне и монетах, а также текущие лимиты. Шахтам нужны назначенные работники и базовое снабжение; нехватка еды, деревянных крепей, инструментов или зарезервированное нарушение снижают стратегическую добычу. NPC-контракты спроса хранятся на сервере, могут появляться при одном игроке онлайн и становятся лучше у клейма с рынком или торговым постом. Проверяйте их через /bannermod economy contracts <claimUuid>.

diff --git a/docs/BANNERMOD_BACKLOG.sqlite b/docs/BANNERMOD_BACKLOG.sqlite index b24a8e7dff5f9e491cf2a4305ee8fcf71742e9ef..46a132a28a0d53e40a0560ac533699a41c3a78ed 100644 GIT binary patch delta 2697 zcmbW2TWlQF8OL|$vhnOp@CM?5i9;qAa%1h-B&C#;+K!E>L*i_`1}ANy9?u?oN6gMl z=3>XHke5@b2oRFfK!yTIHx22QUhSB82&G`46w(`R`@|D|;~@{VS_!Fj759I}4$VWQ zN<7SOj?VeccfRxgzH?5wvbQ|DcVfM`Cy_|}<%HUr+%q-s@^1b;`SkRv=4X-|{%2dn z^og7}{wB4V5jDZz&?5L2Er9RP0q_!i7@Vcw0N2f zLs|#EK=-Z1?+5fI@HD*4*XsN zc0K~^`X;db>%fj9z#kq0ZfDYOW6Ez~!f!nU{P6)`Q#Y{rezy9?`v8?`{wF5+2B!FJ zOz`zg?uIV`A2ONiS%uu%0^GbCxTO)Oy8~Fa6PVi$+;lszb~CVMBXHvepmsfwVVbUH zlHR}+{WDWihJ7%H)C+&dTd zW;pcDv!P}VZf4;IE;(kVa;)v3T@@!4V1ii;#J!VoZ$)8K;*JVBE{Z&xF`>XNrSex1 zP#cecG6*^no8!R&o(<;Eu8IpBrmSA9J=K;>O{XT7YNy3HK$zq*)2YhU+9!lE9|;wK z%_s|3H$EXu3iw2PDSWG{!eq@rRjA0Bi-OFSXp@#&tHOkYHOms~n3GsQZP+Qah}(|x zj*zTG@B7NVa#a|>284?A@7kHNA~GQ)V3r}1X8zJl)CK0RIgMab>8o%zkY?^wj6of= zt70w}%ab9G&P!Up996(ddp*PKQpCr$hc@$A6W3C!%k@(pV^PuvkhCw;{8F~hLb z0vR-X)i1hsFkDo9muOz#j%8KLwi>T97;^2B>Ji^JNr_m#s>L41naiv?q!}hwUUp>? zZG?F0pk}hLJT-ly|gI%e!XQkL}xmf7mh&i{wmgjJVyF6JYZt&4m^$m!8*Ma$IE6>vGBXv4B|m zSi4pDTA-PRKh|NF91S^Up1M~S>atyUKpX|AnzOwXH(i127qP&Kw`(zUXa%?=S=IA3 zKkyDB67Rm_j_r|U@%L-U%C(JU{XDE-jIxV2>LHZ8UL=LUB>4bI86iD`elIdu=nJ)* z`D~nu=*>6e{d}~zMhf_uS(%-(jFOSKGl}%use8CHsQn2pa_Xd)cLwHfS;pSVBMZ-@dYa@3Z~8h67cD5^_1#Nd|-ASlfKj9%bIJMh`je z_mX3Q;l?K@oI20%iuID7+D9K@^+`gjK{^t?b$=?p0m9~IWHI_jgS7bn)TsNP*FZS+ zoV+2N{Xka60}Bca4UxhjBs_2Umb*ITga-8 z8F>G_uC7IQ{ec=`L9r9X1|Rf@cNlkssW}cjW z2#*|)w}rXOvM@6*r!r|ZA^)u=6pwK#YB9q_EuYFCO3^>3vQ^zbg|ddj0S6DE}XB^+-Da delta 1503 zcmXBSTWl0n7zgm3nRBL{owMw;3x(cb3Kq((-Ijt?#4aH!Eh>#SiWgXe1dL)fAy^Y* zx_l;9V=yblE)gkrVsPm({Ik4%fDYd z*BnnZCn|Y^&*!`Lq1&o8oQs}nFvjeGY*F}-mOa^6uzE9%q3#Q`4^wjVGW?!)!^89< z9HAHB5A+kp&+u*nK6da}};5XC(d+BjFM32HOwZjZ;hUe&Ec%C-FBeW5INv-fJ z+5o?%_3)!^x*tDhsRe#c>);pE4Ev}Fo~AJTjOyVTS_PA|0;Z`Bo}#62fR@0cR0B`a zLO4iO@KdUQA5%FzK?`7t%HVOD2m5I*JVt9n`1yrO;m_p2%M^q^k${)Tf@5UDQDX2S z>F~$w-wzzu0$UEMo!bwpo!fffesFttOE-q=-U8xH(EJAQzYd!AgL`8j{2EyMDp;c; zZ`cDGRqW%tK)s57^$xJ|d9dnP@LMNX{tQ^5qF<(xuT#PQ{urp;0+zOcC6B1x?|BIP zp)#*gkuO$>FH(WKD(^q<1Jx?#g=;`%BdAh6a&LFvtQ@&cp{h|$Eix%IC(YxE2~|y@nr0IMb8}cga!2>3VVveUffaQWJG`Q+t71lg zhz=5psCpmw-lwp(_KDq=;4y6)kxgmx{af|{L-v=7PYmmwr)ejz)(@2@ zH05o`f;Da))3@T@t-AH@X4;3#B)XHG z*X>tk?>cqe?)PpJlC`10OV;5St`xUta45%e5Z)c17`0kVzjgq9eB!i4W}`lW1<&g8 zy{bT$A>Af%)f$cnj{Z^Y5?Av(yv9h)<|ziac14~!Ah!AYCW;t(!cr}0U1Zv?^2(UC j!ImuVP0gt8F=MKGcvv-C-fQHH*P$^LkmD7?#@*om(7S)^ diff --git a/docs/STATUS.md b/docs/STATUS.md index b1fbfdce..381c648b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -34,14 +34,14 @@ Last updated: 2026-05-10. - War-state sync now uses dirty/version tracking and cached payloads instead of serializing all war records every idle second for hash polling. - True-async pathfinding now checks scheduler capacity before snapshot capture and rejects over-budget snapshots with profiling counters. - GameTests are being hardened around claim worker growth, dedicated-server ownership, player cycle, settlement degradation, and upkeep currency sourcing. -- Strategic economy second-act groundwork is in progress on `feature/strategic-economy-second-act`: VenaTerra now exposes a read-only deposit survey API in `/home/kaiserroman/venaterra`, BannerMod has an optional reflection bridge, claim-level food/iron/wood/stone/coin summary hints, strategic mine site records, coarse mine yield hints, and admin debug commands for `/bannermod debug economy claim ` plus `/bannermod debug economy mines `. See `docs/STRATEGIC_ECONOMY_INTEGRATION.md`. +- Strategic economy second-act groundwork is in progress on `feature/strategic-economy-second-act`: VenaTerra now exposes a read-only deposit survey API in `/home/kaiserroman/venaterra`, BannerMod has an optional reflection bridge, claim-level food/iron/wood/stone/coin summary hints, strategic mine site records, mine maintenance/degraded-yield pressure, fort level/cap/readiness output, persisted NPC demand contracts, server-authoritative strategic resource accounting, and economy debug/status commands. See `docs/STRATEGIC_ECONOMY_INTEGRATION.md`. ## Known Open Areas - Full settlement onboarding remains incomplete: a new player still needs clearer starter-fort/town-hall, surveyor, wand, citizen, and profession guidance in-game. - Worker assignment binding is split between two sources of truth: the entity-side `currentWorkArea` (cached on `AbstractWorkerEntity`) and the registry-side `ValidatedBuildingRecord` in the settlement validated-building registry. The remaining gap is making `ValidatedBuildingRecord` the authoritative assignment source and reducing `currentWorkArea` to a derived runtime cache. - Sea-trade production/consumption is documented as hints, but not yet a full gameplay loop. -- Strategic mine output is currently a summary/read-model hint only. It does not yet mutate storage, treasury, or item stacks; mine maintenance/depletion (`ECON-024`), fort caps (`ECON-025`), NPC contracts (`ECON-029`), and strategic resource debit/credit (`ECON-031`) remain open. +- Strategic mine output is still a summary/read-model hint only and does not yet mutate storage, treasury, or item stacks. Fort caps are defined and exposed but not enforced at all creation/assignment points, and strategic resource accounting is available as a backend for later upgrade, contract, treaty, and upkeep callers. - Remaining war gaps are depth/coverage gaps, not absent systems: some outcomes are still command/admin-heavy, occupation control is lighter than a full governance loop, revolt resolution depends on objective presence rather than a richer objective campaign, siege-standard AI is basic attack/escort behavior, and morale plus ranged-backline polish still need follow-up. - `verifyGameTestStage` last had a known failure around `reconnectedOwnerRecoversAuthorityAfterOwnershipRoundTrip` in a Better Combat-present smoke; do not claim full GameTest green until rerun successfully. diff --git a/docs/STRATEGIC_ECONOMY_INTEGRATION.md b/docs/STRATEGIC_ECONOMY_INTEGRATION.md index 04c6b60d..4eccce85 100644 --- a/docs/STRATEGIC_ECONOMY_INTEGRATION.md +++ b/docs/STRATEGIC_ECONOMY_INTEGRATION.md @@ -25,13 +25,12 @@ Completed in this session: - `ECON-021` - claim strategic economy summary. - `ECON-022` - strategic mine site records. - `ECON-023` - mine yield cycle into economy summary hints. +- `ECON-024` - mine maintenance and soft depletion pressure. +- `ECON-025` - fort level state, caps, and next-level requirements. +- `ECON-029` - server-side NPC demand contract records. +- `ECON-031` - server-authoritative strategic resource debit/credit pipeline. -Ready next after those closures: - -- `ECON-024` - mine maintenance and soft depletion. -- `ECON-025` - fort levels and strategic caps. -- `ECON-029` - NPC contract records. -- `ECON-031` - server-authoritative strategic resource debit pipeline. +Use `tools/backlog ready ` for the next executable batch after these closures. Use `tools/backlog show ` for canonical acceptance. Do not copy task text out of planning docs. @@ -89,6 +88,7 @@ The claim summary read model lives in `com.talhanation.bannermod.settlement.econ - Each resource line carries `stockpileHint`, `capacityHint`, `productionHint`, `consumptionHint`, `shortage`, `degraded`, and `unknown`. - `ClaimStrategicEconomySummaryService.derive(snapshot, validatedBuildings, treasuryLedger)` keeps the original building-derived behavior. - `ClaimStrategicEconomySummaryService.derive(snapshot, validatedBuildings, treasuryLedger, mineSites)` includes strategic mine yield hints. +- `ClaimStrategicEconomySummaryService.derive(snapshot, validatedBuildings, treasuryLedger, mineSites, fortLevel)` also attaches fort level/caps/requirements to debug/status output. - Stale, missing, invalid, or conflicting building/work-area data should produce degraded/unknown credit, not full production. - When strategic mine sites are supplied, generic mine-like building iron contribution is suppressed. This prevents quarry or precious mines from also producing phantom iron. @@ -96,26 +96,56 @@ The claim summary read model lives in `com.talhanation.bannermod.settlement.econ Strategic mine site records also live under `com.talhanation.bannermod.settlement.economy`: -- `StrategicMineSite` binds a mine to `siteId`, `claimUuid`, owner political entity id, dimension, center, radius, source type, VenaTerra category, richness, degraded flag, and unknown flag. +- `StrategicMineSite` binds a mine to `siteId`, `claimUuid`, owner political entity id, dimension, center, radius, source type, VenaTerra category, richness, assigned worker count, degraded flag, and unknown flag. - Source types are `VALIDATED_MINE_BUILDING` and `CLAIM_MINE_WORK_AREA`. - `StrategicMineSiteService` derives sites from valid `BuildingType.MINE` records and mine-like snapshot records such as `mine` or `mining_area`. - Invalid validated mine records are remembered as known mine ids, so they do not reappear through snapshot fallback. +- Validated mines are matched against overlapping live mine work-area records when present, so live worker counts are used and the same mine does not double-count as both validated and snapshot fallback. - VenaTerra candidates are matched by dimension and horizontal distance. The nearest reliable candidate wins; confidence and richness are tie-breakers. - Unknown or unsupported candidates produce a degraded/unknown site with no inflated yield. ## Mine Yield Hints -`ECON-023` intentionally writes mine output into strategic economy summary production hints only. It does not mutate storage, treasury, or item stacks. +`ECON-023` and `ECON-024` intentionally write mine output into strategic economy summary production hints only. They do not mutate storage, treasury, or item stacks. - `IRON` mine sites add iron production hints. - `QUARRY_STONE` mine sites add stone production hints. - `PRECIOUS_COIN_VALUE` mine sites add coin/value production hints. -- Known valid mines produce `1..3` hint units based on richness. -- Known degraded mines currently produce `1` hint unit. +- Known staffed valid mines produce `1..3` hint units based on richness. +- Unstaffed mines, missing food, missing wood supports, or missing tools move the relevant resource line into degraded state and cap hint output at low efficiency. +- Reserved `mine_disruption`/`disruption` supply-signal units reduce yield without deleting the fort or mine record. This is a read-only hook for later contested/object-control work. - Unknown mines add no production and mark the relevant summary state as degraded/unknown. - Removed or invalidated mine sites stop contributing on the next derivation. -`ECON-024` is the next task that should turn this into maintenance/depletion pressure. It must not implement war/object-control rules; those belong to later `WAR-*` tasks. +War/object-control rules are still out of scope for this layer; those belong to later `WAR-*` tasks. + +## Fort Levels And Strategic Caps + +`ECON-025` adds persisted claim fort level state: + +- `RecruitsClaim` persists `fortLevel`, clamped to `1..4`. +- `FortLevelDefinition` defines beta-table worker, soldier, mine, and outpost caps plus next-level requirements in `food`, `iron`, `wood`, `stone`, and `coins`. +- `/bannermod debug economy claim ` prints fort level, caps, and next-level requirements. +- This slice defines and exposes caps; enforcement at worker/recruit/mine/outpost creation points remains future gameplay wiring. + +## NPC Demand Contracts + +`ECON-029` adds server-side NPC demand contract records: + +- `NpcDemandContract` records buyer, resource bucket, requested item ids, amount, created tick, deadline tick, reward coins, and status. +- `NpcDemandContractSavedData` persists open/expired contracts and next eligible spawn ticks. +- `NpcDemandContractService` ticks per settlement snapshot; isolated forts can receive contracts, while markets and trading posts improve cadence and quality. +- `/bannermod debug economy contracts ` and `/bannermod economy contracts ` expose server-backed status output without a contract-board UI. + +## Strategic Resource Accounting + +`ECON-031` adds the server-authoritative debit/credit backend: + +- `StrategicResourceBucket` covers `food`, `iron`, `wood`, `stone`, and `coins`. +- `StrategicResourceAccountingManager` persists non-coin strategic account balances by claim. +- `StrategicResourceAccountingService` routes coin credit/debit through `BannerModTreasuryManager` and non-coin buckets through settlement-owned strategic accounting. +- Failed debits return structured `SHORTAGE` or `SOURCE_UNAVAILABLE` results without changing balances. +- There are no client packet entry points; callers must provide `ServerLevel`, claim id, bucket, amount, and server tick. ## Admin Debug Surface @@ -123,8 +153,10 @@ The current operator-facing verification paths are in `AdminDebugCommands`: - `/bannermod debug economy claim ` prints the claim strategic economy summary and includes strategic mine yield when a claim can be resolved. - `/bannermod debug economy mines ` prints strategic mine site debug lines. +- `/bannermod debug economy contracts ` prints NPC demand contract debug lines. +- `/bannermod economy contracts ` prints the server-backed contract status path outside the admin debug tree. -These are debug/status paths, not player-facing UI. Player-facing screens are deferred to `UI-020` through `UI-023`. +These are command/status paths, not full player-facing UI. Player-facing screens are deferred to `UI-020` through `UI-023`. ## Verification Commands Used @@ -138,12 +170,16 @@ BannerMod focused gates: - `./gradlew test --tests com.talhanation.bannermod.compat.venaterra.VenaterraDepositProviderTest` - `./gradlew test --tests com.talhanation.bannermod.settlement.economy.ClaimStrategicEconomySummaryServiceTest` - `./gradlew test --tests com.talhanation.bannermod.settlement.economy.StrategicMineSiteServiceTest` -- Combined focused runs for the three tests above. +- `./gradlew test --tests com.talhanation.bannermod.settlement.economy.FortLevelDefinitionTest` +- `./gradlew test --tests com.talhanation.bannermod.settlement.economy.NpcDemandContractServiceTest` +- `./gradlew test --tests com.talhanation.bannermod.settlement.economy.StrategicResourceAccountingServiceTest` +- Combined focused runs for the tests above. - `./gradlew compileJava` - `tools/backlog validate` Known verification gap: -- No in-game execution of `/bannermod debug economy claim ` or `/bannermod debug economy mines ` has been recorded yet. +- No in-game execution of `/bannermod debug economy claim `, `/bannermod debug economy mines `, `/bannermod debug economy contracts `, or `/bannermod economy contracts ` has been recorded yet. - No runtime test with a real loaded VenaTerra jar on BannerMod classpath has been recorded yet; BannerMod bridge tests use reflective fakes. +- No live world save/reload smoke has been recorded for NPC demand contracts or strategic resource accounting; focused NBT/unit coverage exists. - Full test suite was not run during this session. diff --git a/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java b/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java index 64aa36dd..be5532eb 100644 --- a/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java +++ b/src/main/java/com/talhanation/bannermod/commands/admin/AdminDebugCommands.java @@ -17,6 +17,7 @@ import com.talhanation.bannermod.persistence.military.RecruitsClaimSaveData; import com.talhanation.bannermod.persistence.military.RecruitsGroupsSaveData; import com.talhanation.bannermod.settlement.SettlementManager; +import com.talhanation.bannermod.settlement.SettlementOrchestrator; import com.talhanation.bannermod.settlement.SettlementSnapshot; import com.talhanation.bannermod.settlement.bootstrap.SettlementRegistryData; import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord; @@ -24,6 +25,9 @@ import com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchSavedData; import com.talhanation.bannermod.settlement.economy.ClaimStrategicEconomySummary; import com.talhanation.bannermod.settlement.economy.ClaimStrategicEconomySummaryService; +import com.talhanation.bannermod.settlement.economy.FortLevelDefinition; +import com.talhanation.bannermod.settlement.economy.NpcDemandContractSavedData; +import com.talhanation.bannermod.settlement.economy.NpcDemandContractService; import com.talhanation.bannermod.settlement.economy.StrategicMineSite; import com.talhanation.bannermod.settlement.economy.StrategicMineSiteService; import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentSavedData; @@ -84,6 +88,7 @@ public final class AdminDebugCommands { BuildingInvalidationQueueData.class, ValidatedBuildingRegistryData.class, SettlementProjectSavedData.class, + NpcDemandContractSavedData.class, PlayerBuildingRegistrySavedData.class ); @@ -109,7 +114,10 @@ public static LiteralArgumentBuilder debug() { .executes(AdminDebugCommands::claimEconomySummary))) .then(Commands.literal("mines") .then(Commands.argument("claimUuid", StringArgumentType.word()) - .executes(AdminDebugCommands::claimMineSites)))) + .executes(AdminDebugCommands::claimMineSites))) + .then(Commands.literal("contracts") + .then(Commands.argument("claimUuid", StringArgumentType.word()) + .executes(AdminDebugCommands::claimContracts)))) .then(Commands.literal("save-versions") .executes(AdminDebugCommands::saveVersions)); } @@ -203,7 +211,8 @@ private static int claimEconomySummary(CommandContext contex snapshot, validatedBuildings, BannerModTreasuryManager.get(level).getLedger(claimUuid), - mineSites + mineSites, + claim == null ? FortLevelDefinition.MIN_LEVEL : claim.getFortLevel() ); for (String line : summary.debugLines()) { context.getSource().sendSuccess(() -> Component.literal(line), false); @@ -245,6 +254,27 @@ private static int claimMineSites(CommandContext context) th return sites.size(); } + private static int claimContracts(CommandContext context) throws CommandSyntaxException { + ServerLevel level = serverLevel(context.getSource()); + UUID claimUuid; + try { + claimUuid = UUID.fromString(StringArgumentType.getString(context, "claimUuid")); + } catch (IllegalArgumentException exception) { + context.getSource().sendFailure(Component.literal("claimUuid must be a UUID")); + return 0; + } + NpcDemandContractService service = SettlementOrchestrator.npcDemandContractService(level); + if (service == null) { + context.getSource().sendFailure(Component.literal("NPC demand contract service unavailable")); + return 0; + } + List lines = service.statusLines(claimUuid); + for (String line : lines) { + context.getSource().sendSuccess(() -> Component.literal(line), false); + } + return Math.max(0, lines.size() - 1); + } + private static Integer currentVersion(Class savedDataClass) { try { Field field = savedDataClass.getDeclaredField("CURRENT_VERSION"); diff --git a/src/main/java/com/talhanation/bannermod/commands/war/BannerModWarCommands.java b/src/main/java/com/talhanation/bannermod/commands/war/BannerModWarCommands.java index b85e27b0..8c8c42d5 100644 --- a/src/main/java/com/talhanation/bannermod/commands/war/BannerModWarCommands.java +++ b/src/main/java/com/talhanation/bannermod/commands/war/BannerModWarCommands.java @@ -23,6 +23,7 @@ private static LiteralArgumentBuilder root() { .then(AdminRecoveryCommands.treasury()) .then(AdminRecoveryCommands.claim()) .then(AdminRecoveryCommands.worker()) + .then(EconomyStatusCommands.build()) .then(VisualScenarioCommands.build()) .then(PoliticalRegistryCommands.build()) .then(WarDeclarationCommands.build() diff --git a/src/main/java/com/talhanation/bannermod/commands/war/EconomyStatusCommands.java b/src/main/java/com/talhanation/bannermod/commands/war/EconomyStatusCommands.java new file mode 100644 index 00000000..0abc497a --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/commands/war/EconomyStatusCommands.java @@ -0,0 +1,60 @@ +package com.talhanation.bannermod.commands.war; + +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import com.talhanation.bannermod.settlement.SettlementOrchestrator; +import com.talhanation.bannermod.settlement.economy.NpcDemandContractService; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; + +import java.util.List; +import java.util.UUID; + +public final class EconomyStatusCommands { + private static final SimpleCommandExceptionType SERVER_ONLY = new SimpleCommandExceptionType( + Component.literal("This command can only run on a server") + ); + + private EconomyStatusCommands() { + } + + public static LiteralArgumentBuilder build() { + return Commands.literal("economy") + .then(Commands.literal("contracts") + .then(Commands.argument("claimUuid", StringArgumentType.word()) + .executes(EconomyStatusCommands::contracts))); + } + + private static int contracts(CommandContext context) throws CommandSyntaxException { + ServerLevel level = serverLevel(context.getSource()); + UUID claimUuid; + try { + claimUuid = UUID.fromString(StringArgumentType.getString(context, "claimUuid")); + } catch (IllegalArgumentException exception) { + context.getSource().sendFailure(Component.literal("claimUuid must be a UUID")); + return 0; + } + NpcDemandContractService service = SettlementOrchestrator.npcDemandContractService(level); + if (service == null) { + context.getSource().sendFailure(Component.literal("NPC demand contract service unavailable")); + return 0; + } + List lines = service.statusLines(claimUuid); + for (String line : lines) { + context.getSource().sendSuccess(() -> Component.literal(line), false); + } + return Math.max(0, lines.size() - 1); + } + + private static ServerLevel serverLevel(CommandSourceStack source) throws CommandSyntaxException { + if (source.getLevel().isClientSide()) { + throw SERVER_ONLY.create(); + } + return source.getLevel(); + } +} diff --git a/src/main/java/com/talhanation/bannermod/persistence/military/RecruitsClaim.java b/src/main/java/com/talhanation/bannermod/persistence/military/RecruitsClaim.java index 6df6d630..b712006b 100644 --- a/src/main/java/com/talhanation/bannermod/persistence/military/RecruitsClaim.java +++ b/src/main/java/com/talhanation/bannermod/persistence/military/RecruitsClaim.java @@ -3,6 +3,7 @@ import net.minecraft.nbt.ListTag; import net.minecraft.nbt.Tag; import net.minecraft.world.level.ChunkPos; +import com.talhanation.bannermod.settlement.economy.FortLevelDefinition; import javax.annotation.Nullable; import java.util.*; @@ -17,6 +18,7 @@ public class RecruitsClaim { private boolean allowBlockInteraction = false; private boolean allowBlockPlacement = false; private boolean allowBlockBreaking = false; + private int fortLevel = FortLevelDefinition.MIN_LEVEL; private final List trustedPlayers = new ArrayList<>(); public ChunkPos center; public RecruitsPlayerInfo playerInfo; @@ -97,6 +99,10 @@ public List getTrustedPlayers() { return trustedPlayers; } + public int getFortLevel() { + return fortLevel; + } + public boolean isTrustedPlayer(@Nullable UUID playerUuid) { if (playerUuid == null) { return false; @@ -133,6 +139,10 @@ public void setBlockBreakingAllowed(boolean allow) { this.allowBlockBreaking = allow; } + public void setFortLevel(int fortLevel) { + this.fortLevel = FortLevelDefinition.clampLevel(fortLevel); + } + public void setTrustedPlayers(@Nullable List players) { this.trustedPlayers.clear(); if (players == null) { @@ -169,6 +179,7 @@ public CompoundTag toNBT() { nbt.putBoolean("allowInteraction", allowBlockInteraction); nbt.putBoolean("allowPlacement", allowBlockPlacement); nbt.putBoolean("allowBreaking", allowBlockBreaking); + nbt.putInt("fortLevel", fortLevel); nbt.putBoolean("isAdmin", isAdmin); nbt.putBoolean("isRemoved", isRemoved); ListTag chunkList = new ListTag(); @@ -203,6 +214,9 @@ public static RecruitsClaim fromNBT(CompoundTag nbt) { claim.setBlockInteractionAllowed(nbt.getBoolean("allowInteraction")); claim.setBlockPlacementAllowed(nbt.getBoolean("allowPlacement")); claim.setBlockBreakingAllowed(nbt.getBoolean("allowBreaking")); + if (nbt.contains("fortLevel", Tag.TAG_INT)) { + claim.setFortLevel(nbt.getInt("fortLevel")); + } claim.setAdminClaim(nbt.getBoolean("isAdmin")); claim.isRemoved = nbt.getBoolean("isRemoved"); diff --git a/src/main/java/com/talhanation/bannermod/settlement/SettlementClaimTickService.java b/src/main/java/com/talhanation/bannermod/settlement/SettlementClaimTickService.java index 04a08b35..1423d8ba 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/SettlementClaimTickService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/SettlementClaimTickService.java @@ -64,6 +64,7 @@ static void tickSnapshot(SettlementOrchestrator.LevelRuntimeState state, state.marketStateSupplier.set(snapshot.marketState()); tickSellerDispatches(state.sellerRuntime, snapshot.marketState(), gameTime); publishBuildingWorkOrders(state, snapshot, level, gameTime); + state.npcDemandContractService.tickClaim(snapshot, gameTime); for (SettlementResidentRecord resident : snapshot.residents()) { if (resident == null || resident.residentUuid() == null) { diff --git a/src/main/java/com/talhanation/bannermod/settlement/SettlementOrchestrator.java b/src/main/java/com/talhanation/bannermod/settlement/SettlementOrchestrator.java index 6b4b8ee0..6de3e030 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/SettlementOrchestrator.java +++ b/src/main/java/com/talhanation/bannermod/settlement/SettlementOrchestrator.java @@ -4,6 +4,8 @@ import com.talhanation.bannermod.governance.BannerModGovernorSnapshot; import com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchRuntime; import com.talhanation.bannermod.settlement.dispatch.BannerModSellerDispatchSavedData; +import com.talhanation.bannermod.settlement.economy.NpcDemandContractSavedData; +import com.talhanation.bannermod.settlement.economy.NpcDemandContractService; import com.talhanation.bannermod.settlement.goal.BannerModResidentGoalScheduler; import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentRuntime; import com.talhanation.bannermod.settlement.household.BannerModHomeAssignmentSavedData; @@ -91,6 +93,13 @@ public static SettlementWorkOrderRuntime workOrderRuntime(ServerLevel level) { return runtimeState(level).workOrderRuntime; } + public static NpcDemandContractService npcDemandContractService(ServerLevel level) { + if (level == null) { + return null; + } + return runtimeState(level).npcDemandContractService; + } + static LevelRuntimeState detachedStateForTests(JobHandlerRegistry jobHandlerRegistry) { return LevelRuntimeState.create(SettlementProjectRuntime.detachedForTests(), jobHandlerRegistry); } @@ -117,7 +126,8 @@ private static synchronized LevelRuntimeState runtimeState(ServerLevel level) { JobHandlerRegistry.defaults(), SettlementWorkOrderSavedData.get(level).runtime(), BannerModHomeAssignmentSavedData.get(level).runtime(), - BannerModSellerDispatchSavedData.get(level).runtime() + BannerModSellerDispatchSavedData.get(level).runtime(), + NpcDemandContractSavedData.get(level).service() )); } @@ -131,6 +141,7 @@ static final class LevelRuntimeState { final Map jobCooldownExpiries; final SettlementWorkOrderRuntime workOrderRuntime; final SettlementWorkOrderPublisherRegistry publisherRegistry; + final NpcDemandContractService npcDemandContractService; private final List orchestratorSnapshotOrder = new ArrayList<>(); private LevelRuntimeState(SettlementProjectRuntime projectRuntime, @@ -141,7 +152,8 @@ private LevelRuntimeState(SettlementProjectRuntime projectRuntime, JobHandlerRegistry jobHandlerRegistry, Map jobCooldownExpiries, SettlementWorkOrderRuntime workOrderRuntime, - SettlementWorkOrderPublisherRegistry publisherRegistry) { + SettlementWorkOrderPublisherRegistry publisherRegistry, + NpcDemandContractService npcDemandContractService) { this.projectRuntime = projectRuntime; this.homeRuntime = homeRuntime; this.sellerRuntime = sellerRuntime; @@ -151,6 +163,7 @@ private LevelRuntimeState(SettlementProjectRuntime projectRuntime, this.jobCooldownExpiries = jobCooldownExpiries; this.workOrderRuntime = workOrderRuntime; this.publisherRegistry = publisherRegistry; + this.npcDemandContractService = npcDemandContractService; } List snapshotOrderForBatch(SettlementManager settlementManager, int startIndex) { @@ -179,11 +192,20 @@ private static LevelRuntimeState create(SettlementProjectRuntime projectRuntime, new BannerModHomeAssignmentRuntime(), new BannerModSellerDispatchRuntime()); } + private static LevelRuntimeState create(SettlementProjectRuntime projectRuntime, + JobHandlerRegistry jobHandlerRegistry, + SettlementWorkOrderRuntime workOrderRuntime, + BannerModHomeAssignmentRuntime homeRuntime, + BannerModSellerDispatchRuntime sellerRuntime) { + return create(projectRuntime, jobHandlerRegistry, workOrderRuntime, homeRuntime, sellerRuntime, new NpcDemandContractService()); + } + private static LevelRuntimeState create(SettlementProjectRuntime projectRuntime, JobHandlerRegistry jobHandlerRegistry, SettlementWorkOrderRuntime workOrderRuntime, BannerModHomeAssignmentRuntime homeRuntime, - BannerModSellerDispatchRuntime sellerRuntime) { + BannerModSellerDispatchRuntime sellerRuntime, + NpcDemandContractService npcDemandContractService) { BannerModHomeAssignmentRuntime effectiveHomeRuntime = homeRuntime == null ? new BannerModHomeAssignmentRuntime() : homeRuntime; @@ -200,7 +222,8 @@ private static LevelRuntimeState create(SettlementProjectRuntime projectRuntime, jobHandlerRegistry == null ? JobHandlerRegistry.defaults() : jobHandlerRegistry, new HashMap<>(), workOrderRuntime == null ? new SettlementWorkOrderRuntime() : workOrderRuntime, - SettlementWorkOrderPublisherRegistry.defaults() + SettlementWorkOrderPublisherRegistry.defaults(), + npcDemandContractService == null ? new NpcDemandContractService() : npcDemandContractService ); } } diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java index 23e60ff5..b7f71d73 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java @@ -8,12 +8,14 @@ public record ClaimStrategicEconomySummary( UUID claimUuid, int anchorChunkX, int anchorChunkZ, + FortLevelDefinition fortLevel, List resources, boolean shortage, boolean degraded, boolean unknown ) { public ClaimStrategicEconomySummary { + fortLevel = fortLevel == null ? FortLevelDefinition.forLevel(FortLevelDefinition.MIN_LEVEL) : fortLevel; resources = List.copyOf(resources == null ? List.of() : resources); } @@ -21,6 +23,15 @@ public List debugLines() { List lines = new ArrayList<>(); lines.add("Claim economy " + this.claimUuid + " chunk=" + this.anchorChunkX + "," + this.anchorChunkZ + " shortage=" + this.shortage + " degraded=" + this.degraded + " unknown=" + this.unknown); + lines.add("Fort level " + this.fortLevel.level() + " " + this.fortLevel.name() + + " caps workers=" + this.fortLevel.workerCap() + + " soldiers=" + this.fortLevel.soldierCap() + + " mines=" + this.fortLevel.mineCap() + + " outposts=" + this.fortLevel.outpostCap()); + FortLevelDefinition.UpgradeRequirement requirement = this.fortLevel.nextLevelRequirement(); + lines.add(requirement == null + ? "Next fort level requirements: max level" + : "Next fort level requirements: " + requirement.debugString()); for (ResourceLine resource : this.resources) { lines.add(resource.resourceId() + " stockpile=" + resource.stockpileHint() diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java index 4c163251..ff5cc562 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java @@ -42,7 +42,15 @@ public static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, List validatedBuildings, @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, @Nullable List mineSites) { - return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, true); + return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, FortLevelDefinition.MIN_LEVEL); + } + + public static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, + List validatedBuildings, + @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, + @Nullable List mineSites, + int fortLevel) { + return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, true, fortLevel); } private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, @@ -50,6 +58,15 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, @Nullable List mineSites, boolean strategicMineSitesProvided) { + return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, strategicMineSitesProvided, FortLevelDefinition.MIN_LEVEL); + } + + private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, + List validatedBuildings, + @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, + @Nullable List mineSites, + boolean strategicMineSitesProvided, + int fortLevel) { Map resources = new EnumMap<>(ResourceKey.class); for (ResourceKey resource : RESOURCES) { resources.put(resource, new MutableResource()); @@ -74,7 +91,7 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, applyMarket(resources, snapshot); applyTreasury(resources, treasuryLedger); applyBuildings(resources, snapshot, safeValidatedBuildings, validBuildingIds, invalidBuildingIds, strategicMineSitesProvided); - applyMines(resources, snapshot, safeMineSites); + applyMines(resources, snapshot, safeMineSites, mineMaintenance(resources, snapshot)); boolean anyShortage = false; boolean anyDegraded = false; @@ -105,6 +122,7 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, snapshot.claimUuid(), snapshot.anchorChunkX(), snapshot.anchorChunkZ(), + FortLevelDefinition.forLevel(fortLevel), lines, anyShortage, anyDegraded, @@ -238,7 +256,8 @@ private static void applyBuildings(Map resources, private static void applyMines(Map resources, SettlementSnapshot snapshot, - List mineSites) { + List mineSites, + MineMaintenance maintenance) { for (StrategicMineSite site : mineSites) { if (site == null || !site.claimUuid().equals(snapshot.claimUuid()) || site.ownerPoliticalEntityId() == null) { continue; @@ -254,17 +273,34 @@ private static void applyMines(Map resources, } MutableResource mutable = resources.get(resource); - if (site.degraded()) { + boolean lowEfficiency = site.degraded() || site.assignedWorkerCount() <= 0 || maintenance.missingInputs(); + boolean degraded = lowEfficiency || maintenance.disruptionUnits() > 0; + if (degraded) { mutable.degraded = true; } if (site.unknown()) { mutable.unknown = true; continue; } - mutable.productionHint += mineProductionHint(site); + mutable.productionHint += mineProductionHint(site, lowEfficiency, maintenance.disruptionUnits()); } } + private static MineMaintenance mineMaintenance(Map resources, SettlementSnapshot snapshot) { + boolean missingFood = resources.get(ResourceKey.FOOD).shortage; + boolean missingWood = resources.get(ResourceKey.WOOD).shortage; + boolean missingTools = false; + int disruptionUnits = 0; + for (SettlementSupplySignal signal : snapshot.supplySignalState().signals()) { + String goodId = signal.goodId().toLowerCase(Locale.ROOT); + missingTools |= goodId.contains("tool") && signal.shortageUnits() > 0; + if (goodId.contains("mine_disruption") || goodId.contains("disruption")) { + disruptionUnits += signal.reservationHintUnits(); + } + } + return new MineMaintenance(missingFood || missingWood || missingTools, disruptionUnits); + } + private static BuildingContribution contributionForBuilding(SettlementBuildingRecord building) { String typeId = building.buildingTypeId() == null ? "" : building.buildingTypeId().toLowerCase(Locale.ROOT); if (typeId.contains("farm") || typeId.contains("crop") || typeId.contains("animal_pen") || typeId.contains("fishing")) { @@ -315,6 +351,9 @@ private static ResourceKey resourceForGood(String goodId) { if (normalized.contains("iron")) { return ResourceKey.IRON; } + if (normalized.contains("tool")) { + return ResourceKey.IRON; + } if (normalized.contains("wood") || normalized.contains("lumber")) { return ResourceKey.WOOD; } @@ -337,12 +376,16 @@ private static ResourceKey resourceForMineCategory(VenaterraDepositCategory cate }; } - private static int mineProductionHint(StrategicMineSite site) { - if (site.degraded()) { - return 1; - } + private static int mineProductionHint(StrategicMineSite site, boolean lowEfficiency, int disruptionUnits) { int richnessYield = (int) Math.floor(site.richness() * 3.0F); - return Math.max(1, Math.min(3, richnessYield)); + int baseYield = Math.max(1, Math.min(3, richnessYield)); + if (lowEfficiency) { + baseYield = Math.min(baseYield, 1); + } + return Math.max(0, baseYield - disruptionUnits); + } + + private record MineMaintenance(boolean missingInputs, int disruptionUnits) { } private enum ResourceKey { diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/FortLevelDefinition.java b/src/main/java/com/talhanation/bannermod/settlement/economy/FortLevelDefinition.java new file mode 100644 index 00000000..c481c0ef --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/FortLevelDefinition.java @@ -0,0 +1,62 @@ +package com.talhanation.bannermod.settlement.economy; + +import javax.annotation.Nullable; +import java.util.Map; + +public record FortLevelDefinition( + int level, + String name, + int workerCap, + int soldierCap, + int mineCap, + int outpostCap, + @Nullable UpgradeRequirement nextLevelRequirement +) { + public static final int MIN_LEVEL = 1; + public static final int MAX_LEVEL = 4; + + private static final Map DEFINITIONS = Map.of( + 1, new FortLevelDefinition(1, "Outpost/Zastava", 4, 4, 1, 0, + new UpgradeRequirement(120, 40, 160, 120, 250)), + 2, new FortLevelDefinition(2, "Fort", 8, 10, 1, 1, + new UpgradeRequirement(280, 120, 360, 280, 750)), + 3, new FortLevelDefinition(3, "Stronghold", 16, 25, 2, 2, + new UpgradeRequirement(640, 320, 800, 640, 1800)), + 4, new FortLevelDefinition(4, "Town/City", 32, 50, 3, 4, null) + ); + + public FortLevelDefinition { + level = clampLevel(level); + name = name == null ? "" : name; + workerCap = Math.max(0, workerCap); + soldierCap = Math.max(0, soldierCap); + mineCap = Math.max(0, mineCap); + outpostCap = Math.max(0, outpostCap); + } + + public static FortLevelDefinition forLevel(int level) { + return DEFINITIONS.get(clampLevel(level)); + } + + public static int clampLevel(int level) { + return Math.max(MIN_LEVEL, Math.min(MAX_LEVEL, level)); + } + + public record UpgradeRequirement(int food, int iron, int wood, int stone, int coins) { + public UpgradeRequirement { + food = Math.max(0, food); + iron = Math.max(0, iron); + wood = Math.max(0, wood); + stone = Math.max(0, stone); + coins = Math.max(0, coins); + } + + public String debugString() { + return "food=" + this.food + + " iron=" + this.iron + + " wood=" + this.wood + + " stone=" + this.stone + + " coins=" + this.coins; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContract.java b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContract.java new file mode 100644 index 00000000..22115344 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContract.java @@ -0,0 +1,120 @@ +package com.talhanation.bannermod.settlement.economy; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.nbt.Tag; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public record NpcDemandContract( + UUID contractUuid, + UUID claimUuid, + String buyer, + String resourceBucket, + List requestedItems, + int amount, + long createdAtGameTime, + long deadlineGameTime, + int rewardCoins, + Status status +) { + public NpcDemandContract { + contractUuid = contractUuid == null ? UUID.randomUUID() : contractUuid; + claimUuid = claimUuid == null ? new UUID(0L, 0L) : claimUuid; + buyer = buyer == null || buyer.isBlank() ? "wandering_buyer" : buyer; + resourceBucket = resourceBucket == null || resourceBucket.isBlank() ? "supplies" : resourceBucket; + requestedItems = List.copyOf(requestedItems == null ? List.of() : requestedItems); + amount = Math.max(1, amount); + deadlineGameTime = Math.max(createdAtGameTime + 1, deadlineGameTime); + rewardCoins = Math.max(1, rewardCoins); + status = status == null ? Status.OPEN : status; + } + + public NpcDemandContract expired(long gameTime) { + if (this.status != Status.OPEN || gameTime < this.deadlineGameTime) { + return this; + } + return new NpcDemandContract( + this.contractUuid, + this.claimUuid, + this.buyer, + this.resourceBucket, + this.requestedItems, + this.amount, + this.createdAtGameTime, + this.deadlineGameTime, + this.rewardCoins, + Status.EXPIRED + ); + } + + public String debugLine() { + return "contract=" + this.contractUuid + + " claim=" + this.claimUuid + + " buyer=" + this.buyer + + " bucket=" + this.resourceBucket + + " items=" + String.join(",", this.requestedItems) + + " amount=" + this.amount + + " deadline=" + this.deadlineGameTime + + " reward=" + this.rewardCoins + + " status=" + this.status; + } + + CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("ContractUuid", this.contractUuid); + tag.putUUID("ClaimUuid", this.claimUuid); + tag.putString("Buyer", this.buyer); + tag.putString("ResourceBucket", this.resourceBucket); + ListTag items = new ListTag(); + for (String item : this.requestedItems) { + items.add(StringTag.valueOf(item)); + } + tag.put("RequestedItems", items); + tag.putInt("Amount", this.amount); + tag.putLong("CreatedAtGameTime", this.createdAtGameTime); + tag.putLong("DeadlineGameTime", this.deadlineGameTime); + tag.putInt("RewardCoins", this.rewardCoins); + tag.putString("Status", this.status.name()); + return tag; + } + + static NpcDemandContract fromTag(CompoundTag tag) { + List items = new ArrayList<>(); + if (tag.contains("RequestedItems", Tag.TAG_LIST)) { + ListTag itemTags = tag.getList("RequestedItems", Tag.TAG_STRING); + for (Tag itemTag : itemTags) { + items.add(itemTag.getAsString()); + } + } + return new NpcDemandContract( + tag.hasUUID("ContractUuid") ? tag.getUUID("ContractUuid") : UUID.randomUUID(), + tag.hasUUID("ClaimUuid") ? tag.getUUID("ClaimUuid") : new UUID(0L, 0L), + tag.getString("Buyer"), + tag.getString("ResourceBucket"), + items, + tag.getInt("Amount"), + tag.getLong("CreatedAtGameTime"), + tag.getLong("DeadlineGameTime"), + tag.getInt("RewardCoins"), + statusFromTag(tag.getString("Status")) + ); + } + + private static Status statusFromTag(String statusName) { + try { + return Status.valueOf(statusName); + } catch (RuntimeException exception) { + return Status.OPEN; + } + } + + public enum Status { + OPEN, + FULFILLED, + EXPIRED + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractSavedData.java b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractSavedData.java new file mode 100644 index 00000000..ca7c4e5c --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractSavedData.java @@ -0,0 +1,41 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.persistence.SavedDataVersioning; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +public final class NpcDemandContractSavedData extends SavedData { + private static final String FILE_ID = "bannermodNpcDemandContracts"; + private static final SavedData.Factory FACTORY = new SavedData.Factory<>(NpcDemandContractSavedData::new, NpcDemandContractSavedData::load); + private static final int CURRENT_VERSION = 1; + + private final NpcDemandContractService service; + + public NpcDemandContractSavedData() { + this.service = new NpcDemandContractService(this::setDirty); + } + + public static NpcDemandContractSavedData get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID); + } + + public static NpcDemandContractSavedData load(CompoundTag tag, HolderLookup.Provider registries) { + SavedDataVersioning.migrate(tag, CURRENT_VERSION, "NpcDemandContractSavedData"); + NpcDemandContractSavedData data = new NpcDemandContractSavedData(); + data.service.loadFromTag(tag); + return data; + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + SavedDataVersioning.putVersion(tag, CURRENT_VERSION); + this.service.saveToTag(tag); + return tag; + } + + public NpcDemandContractService service() { + return this.service; + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractService.java new file mode 100644 index 00000000..23c891b6 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractService.java @@ -0,0 +1,221 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public final class NpcDemandContractService { + private static final long ISOLATED_INTERVAL_TICKS = 24_000L; + private static final long MARKET_INTERVAL_TICKS = 12_000L; + private static final long TRADING_POST_INTERVAL_TICKS = 9_000L; + private static final long CONTRACT_DEADLINE_TICKS = 72_000L; + private static final int MAX_OPEN_CONTRACTS_PER_CLAIM = 3; + private static final List TEMPLATES = List.of( + new ContractTemplate("frontier_quartermaster", "food", List.of("minecraft:wheat", "minecraft:bread"), 48, 96), + new ContractTemplate("mason_guild", "stone", List.of("minecraft:cobblestone", "minecraft:stone"), 64, 112), + new ContractTemplate("carpenter_guild", "wood", List.of("minecraft:oak_log", "minecraft:spruce_log"), 64, 108), + new ContractTemplate("smith_factor", "iron", List.of("minecraft:iron_ingot"), 24, 160) + ); + + private final Map> contractsByClaim = new HashMap<>(); + private final Map nextEligibleTickByClaim = new HashMap<>(); + private final Runnable dirtyCallback; + + public NpcDemandContractService() { + this(null); + } + + NpcDemandContractService(Runnable dirtyCallback) { + this.dirtyCallback = dirtyCallback == null ? () -> { } : dirtyCallback; + } + + public void tickClaim(SettlementSnapshot snapshot, long gameTime) { + if (snapshot == null || snapshot.claimUuid() == null) { + return; + } + UUID claimUuid = snapshot.claimUuid(); + expireContracts(claimUuid, gameTime); + if (openContracts(claimUuid).size() >= MAX_OPEN_CONTRACTS_PER_CLAIM) { + scheduleNext(claimUuid, snapshot, gameTime); + return; + } + long nextEligibleTick = this.nextEligibleTickByClaim.getOrDefault(claimUuid, Long.MIN_VALUE); + if (gameTime < nextEligibleTick) { + return; + } + addContract(claimUuid, createContract(snapshot, gameTime)); + scheduleNext(claimUuid, snapshot, gameTime); + } + + public List contracts(UUID claimUuid) { + if (claimUuid == null) { + return List.of(); + } + return this.contractsByClaim.getOrDefault(claimUuid, List.of()).stream() + .sorted(Comparator.comparingLong(NpcDemandContract::createdAtGameTime)) + .toList(); + } + + public List statusLines(UUID claimUuid) { + List contracts = contracts(claimUuid); + if (contracts.isEmpty()) { + return List.of("No NPC demand contracts for claim " + claimUuid); + } + List lines = new ArrayList<>(); + lines.add("NPC demand contracts for claim " + claimUuid + ": " + contracts.size()); + for (NpcDemandContract contract : contracts) { + lines.add(contract.debugLine()); + } + return lines; + } + + private void expireContracts(UUID claimUuid, long gameTime) { + List contracts = this.contractsByClaim.get(claimUuid); + if (contracts == null || contracts.isEmpty()) { + return; + } + boolean changed = false; + for (int i = 0; i < contracts.size(); i++) { + NpcDemandContract current = contracts.get(i); + NpcDemandContract expired = current.expired(gameTime); + if (expired != current) { + contracts.set(i, expired); + changed = true; + } + } + if (changed) { + markDirty(); + } + } + + private List openContracts(UUID claimUuid) { + return this.contractsByClaim.getOrDefault(claimUuid, List.of()).stream() + .filter(contract -> contract.status() == NpcDemandContract.Status.OPEN) + .toList(); + } + + private void addContract(UUID claimUuid, NpcDemandContract contract) { + this.contractsByClaim.computeIfAbsent(claimUuid, ignored -> new ArrayList<>()).add(contract); + markDirty(); + } + + private void scheduleNext(UUID claimUuid, SettlementSnapshot snapshot, long gameTime) { + this.nextEligibleTickByClaim.put(claimUuid, gameTime + contractInterval(snapshot)); + markDirty(); + } + + void loadFromTag(CompoundTag tag) { + this.contractsByClaim.clear(); + this.nextEligibleTickByClaim.clear(); + if (tag.contains("Contracts", Tag.TAG_LIST)) { + ListTag contracts = tag.getList("Contracts", Tag.TAG_COMPOUND); + for (Tag entry : contracts) { + NpcDemandContract contract = NpcDemandContract.fromTag((CompoundTag) entry); + this.contractsByClaim.computeIfAbsent(contract.claimUuid(), ignored -> new ArrayList<>()).add(contract); + } + } + if (tag.contains("NextEligibleTicks", Tag.TAG_LIST)) { + ListTag ticks = tag.getList("NextEligibleTicks", Tag.TAG_COMPOUND); + for (Tag entry : ticks) { + CompoundTag tickTag = (CompoundTag) entry; + if (tickTag.hasUUID("ClaimUuid")) { + this.nextEligibleTickByClaim.put(tickTag.getUUID("ClaimUuid"), tickTag.getLong("GameTime")); + } + } + } + } + + void saveToTag(CompoundTag tag) { + ListTag contracts = new ListTag(); + for (List claimContracts : this.contractsByClaim.values()) { + for (NpcDemandContract contract : claimContracts) { + contracts.add(contract.toTag()); + } + } + tag.put("Contracts", contracts); + + ListTag ticks = new ListTag(); + this.nextEligibleTickByClaim.forEach((claimUuid, gameTime) -> { + CompoundTag tickTag = new CompoundTag(); + tickTag.putUUID("ClaimUuid", claimUuid); + tickTag.putLong("GameTime", gameTime); + ticks.add(tickTag); + }); + tag.put("NextEligibleTicks", ticks); + } + + private void markDirty() { + this.dirtyCallback.run(); + } + + private NpcDemandContract createContract(SettlementSnapshot snapshot, long gameTime) { + int index = Math.floorMod(snapshot.claimUuid().hashCode() + (int) (gameTime / 20L), TEMPLATES.size()); + ContractTemplate template = TEMPLATES.get(index); + int qualityBonus = qualityBonus(snapshot); + int amount = template.baseAmount() + qualityBonus * 8; + int reward = template.baseReward() + qualityBonus * 32; + return new NpcDemandContract( + deterministicContractUuid(snapshot.claimUuid(), gameTime), + snapshot.claimUuid(), + template.buyer(), + template.resourceBucket(), + template.requestedItems(), + amount, + gameTime, + gameTime + CONTRACT_DEADLINE_TICKS, + reward, + NpcDemandContract.Status.OPEN + ); + } + + private long contractInterval(SettlementSnapshot snapshot) { + if (hasTradingPostAccess(snapshot)) { + return TRADING_POST_INTERVAL_TICKS; + } + if (hasMarketAccess(snapshot)) { + return MARKET_INTERVAL_TICKS; + } + return ISOLATED_INTERVAL_TICKS; + } + + private int qualityBonus(SettlementSnapshot snapshot) { + int bonus = 0; + if (hasMarketAccess(snapshot)) { + bonus++; + } + if (hasTradingPostAccess(snapshot)) { + bonus += 2; + } + return bonus; + } + + private boolean hasMarketAccess(SettlementSnapshot snapshot) { + return snapshot.marketState().openMarketCount() > 0 || snapshot.marketState().readySellerDispatchCount() > 0; + } + + private boolean hasTradingPostAccess(SettlementSnapshot snapshot) { + return snapshot.tradeRouteHandoffSnapshot().portEntrypointCount() > 0 + || snapshot.tradeRouteHandoffSnapshot().routedStorageCount() > 0 + || snapshot.tradeRouteHandoffSnapshot().readySellerDispatchCount() > 0; + } + + private UUID deterministicContractUuid(UUID claimUuid, long gameTime) { + return UUID.nameUUIDFromBytes((claimUuid + ":npc_contract:" + gameTime).getBytes(StandardCharsets.UTF_8)); + } + + private record ContractTemplate(String buyer, + String resourceBucket, + List requestedItems, + int baseAmount, + int baseReward) { + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSite.java b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSite.java index e02fa83b..92c811be 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSite.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSite.java @@ -18,6 +18,7 @@ public record StrategicMineSite( SourceType sourceType, VenaterraDepositCategory resourceCategory, float richness, + int assignedWorkerCount, boolean degraded, boolean unknown ) { @@ -30,6 +31,7 @@ public record StrategicMineSite( sourceType = sourceType == null ? SourceType.CLAIM_MINE_WORK_AREA : sourceType; resourceCategory = resourceCategory == null ? VenaterraDepositCategory.UNKNOWN_OTHER : resourceCategory; richness = Math.max(0.0F, richness); + assignedWorkerCount = Math.max(0, assignedWorkerCount); } public String debugLine() { @@ -42,6 +44,7 @@ public String debugLine() { + " source=" + this.sourceType + " resource=" + this.resourceCategory + " richness=" + this.richness + + " workers=" + this.assignedWorkerCount + " degraded=" + this.degraded + " unknown=" + this.unknown; } diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java index 7d3f9209..de7ce913 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteService.java @@ -18,9 +18,11 @@ import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; @@ -52,12 +54,17 @@ static List derive(@Nullable ServerLevel level, deposits = deposits == null ? List.of() : deposits; List sites = new ArrayList<>(); Set knownMineBuildingIds = new HashSet<>(); + Map assignedWorkerCounts = assignedWorkersByBuildingId(snapshot); for (ValidatedBuildingRecord record : records) { if (!record.settlementId().equals(claim.getUUID()) || record.type() != BuildingType.MINE) { continue; } + SettlementBuildingRecord snapshotMine = snapshotMineForRecord(record, snapshot); knownMineBuildingIds.add(record.buildingId()); + if (snapshotMine != null) { + knownMineBuildingIds.add(snapshotMine.buildingUuid()); + } if (record.state() != BuildingValidationState.VALID) { continue; } @@ -68,6 +75,7 @@ static List derive(@Nullable ServerLevel level, centerOf(record.bounds(), record.anchorPos()), radiusOf(record.bounds()), StrategicMineSite.SourceType.VALIDATED_MINE_BUILDING, + snapshotMine == null ? assignedWorkerCounts.getOrDefault(record.buildingId(), 0) : snapshotMine.assignedWorkerCount(), deposits )); } @@ -85,6 +93,7 @@ static List derive(@Nullable ServerLevel level, building.originPos(), SNAPSHOT_MINE_RADIUS, StrategicMineSite.SourceType.CLAIM_MINE_WORK_AREA, + building.assignedWorkerCount(), deposits )); } @@ -93,12 +102,32 @@ static List derive(@Nullable ServerLevel level, return List.copyOf(sites); } + @Nullable + private static SettlementBuildingRecord snapshotMineForRecord(ValidatedBuildingRecord record, + @Nullable SettlementSnapshot snapshot) { + if (snapshot == null) { + return null; + } + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (record.buildingId().equals(building.buildingUuid())) { + return building; + } + } + for (SettlementBuildingRecord building : snapshot.buildings()) { + if (isMineLike(building.buildingTypeId()) && contains(record.bounds(), building.originPos())) { + return building; + } + } + return null; + } + private static StrategicMineSite createSite(UUID siteId, RecruitsClaim claim, ResourceKey dimension, BlockPos center, int radius, StrategicMineSite.SourceType sourceType, + int assignedWorkerCount, List deposits) { VenaterraDepositCandidate deposit = closestReliableDeposit(dimension, center, radius, deposits); if (deposit == null) { @@ -112,6 +141,7 @@ private static StrategicMineSite createSite(UUID siteId, sourceType, VenaterraDepositCategory.UNKNOWN_OTHER, 0.0F, + assignedWorkerCount, true, true ); @@ -127,11 +157,23 @@ private static StrategicMineSite createSite(UUID siteId, sourceType, deposit.category(), deposit.richness(), + assignedWorkerCount, false, false ); } + private static Map assignedWorkersByBuildingId(@Nullable SettlementSnapshot snapshot) { + if (snapshot == null) { + return Map.of(); + } + Map assignedWorkers = new HashMap<>(); + for (SettlementBuildingRecord building : snapshot.buildings()) { + assignedWorkers.put(building.buildingUuid(), building.assignedWorkerCount()); + } + return assignedWorkers; + } + @Nullable private static VenaterraDepositCandidate closestReliableDeposit(ResourceKey dimension, BlockPos center, @@ -156,6 +198,15 @@ private static int horizontalDistanceSquared(BlockPos first, BlockPos second) { return (dx * dx) + (dz * dz); } + private static boolean contains(AABB bounds, BlockPos pos) { + double x = pos.getX() + 0.5D; + double y = pos.getY() + 0.5D; + double z = pos.getZ() + 0.5D; + return x >= bounds.minX && x <= bounds.maxX + && y >= bounds.minY && y <= bounds.maxY + && z >= bounds.minZ && z <= bounds.maxZ; + } + private static boolean isMineLike(@Nullable String buildingTypeId) { if (buildingTypeId == null || buildingTypeId.isBlank()) { return false; diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountSnapshot.java b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountSnapshot.java new file mode 100644 index 00000000..96f6aa8b --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountSnapshot.java @@ -0,0 +1,109 @@ +package com.talhanation.bannermod.settlement.economy; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; + +import java.util.UUID; + +public record StrategicResourceAccountSnapshot( + UUID claimUuid, + int food, + int iron, + int wood, + int stone, + String lastBucketId, + int lastCreditAmount, + long lastCreditTick, + int lastDebitAmount, + long lastDebitTick +) { + public StrategicResourceAccountSnapshot { + food = Math.max(0, food); + iron = Math.max(0, iron); + wood = Math.max(0, wood); + stone = Math.max(0, stone); + lastBucketId = lastBucketId == null ? "" : lastBucketId; + lastCreditAmount = Math.max(0, lastCreditAmount); + lastDebitAmount = Math.max(0, lastDebitAmount); + } + + public int balance(StrategicResourceBucket bucket) { + return switch (bucket) { + case FOOD -> this.food; + case IRON -> this.iron; + case WOOD -> this.wood; + case STONE -> this.stone; + case COINS -> 0; + }; + } + + public StrategicResourceAccountSnapshot withCredit(StrategicResourceBucket bucket, int amount, long tick) { + int normalizedAmount = Math.max(0, amount); + return withBalance(bucket, balance(bucket) + normalizedAmount, normalizedAmount, tick, this.lastDebitAmount, this.lastDebitTick); + } + + public StrategicResourceAccountSnapshot withDebit(StrategicResourceBucket bucket, int amount, long tick) { + int normalizedAmount = Math.max(0, amount); + return withBalance(bucket, balance(bucket) - normalizedAmount, this.lastCreditAmount, this.lastCreditTick, normalizedAmount, tick); + } + + private StrategicResourceAccountSnapshot withBalance(StrategicResourceBucket bucket, + int balance, + int creditAmount, + long creditTick, + int debitAmount, + long debitTick) { + int updatedFood = bucket == StrategicResourceBucket.FOOD ? balance : this.food; + int updatedIron = bucket == StrategicResourceBucket.IRON ? balance : this.iron; + int updatedWood = bucket == StrategicResourceBucket.WOOD ? balance : this.wood; + int updatedStone = bucket == StrategicResourceBucket.STONE ? balance : this.stone; + return new StrategicResourceAccountSnapshot( + this.claimUuid, + updatedFood, + updatedIron, + updatedWood, + updatedStone, + bucket.id(), + creditAmount, + creditAmount > 0 ? creditTick : this.lastCreditTick, + debitAmount, + debitAmount > 0 ? debitTick : this.lastDebitTick + ); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("ClaimUuid", this.claimUuid); + tag.putInt("Food", this.food); + tag.putInt("Iron", this.iron); + tag.putInt("Wood", this.wood); + tag.putInt("Stone", this.stone); + if (!this.lastBucketId.isBlank()) { + tag.putString("LastBucketId", this.lastBucketId); + } + tag.putInt("LastCreditAmount", this.lastCreditAmount); + tag.putLong("LastCreditTick", this.lastCreditTick); + tag.putInt("LastDebitAmount", this.lastDebitAmount); + tag.putLong("LastDebitTick", this.lastDebitTick); + return tag; + } + + public static StrategicResourceAccountSnapshot empty(UUID claimUuid) { + return new StrategicResourceAccountSnapshot(claimUuid, 0, 0, 0, 0, "", 0, 0L, 0, 0L); + } + + public static StrategicResourceAccountSnapshot fromTag(CompoundTag tag) { + return new StrategicResourceAccountSnapshot( + tag.getUUID("ClaimUuid"), + tag.getInt("Food"), + tag.getInt("Iron"), + tag.getInt("Wood"), + tag.getInt("Stone"), + tag.contains("LastBucketId", Tag.TAG_STRING) ? tag.getString("LastBucketId") : "", + tag.getInt("LastCreditAmount"), + tag.getLong("LastCreditTick"), + tag.getInt("LastDebitAmount"), + tag.getLong("LastDebitTick") + ); + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingManager.java b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingManager.java new file mode 100644 index 00000000..3fbd2b69 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingManager.java @@ -0,0 +1,88 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.persistence.SavedDataVersioning; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +import javax.annotation.Nullable; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +public class StrategicResourceAccountingManager extends SavedData { + private static final String FILE_ID = "bannermodStrategicResourceAccounting"; + private static final SavedData.Factory FACTORY = new SavedData.Factory<>(StrategicResourceAccountingManager::new, StrategicResourceAccountingManager::load); + + private static final int CURRENT_VERSION = 1; + private final Map accounts = new LinkedHashMap<>(); + + public static StrategicResourceAccountingManager get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID); + } + + public static StrategicResourceAccountingManager load(CompoundTag tag, HolderLookup.Provider registries) { + SavedDataVersioning.migrate(tag, CURRENT_VERSION, "StrategicResourceAccountingManager"); + StrategicResourceAccountingManager manager = new StrategicResourceAccountingManager(); + if (tag.contains("Accounts", Tag.TAG_LIST)) { + ListTag accounts = tag.getList("Accounts", Tag.TAG_COMPOUND); + for (Tag entry : accounts) { + StrategicResourceAccountSnapshot account = StrategicResourceAccountSnapshot.fromTag((CompoundTag) entry); + manager.accounts.put(account.claimUuid(), account); + } + } + return manager; + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + SavedDataVersioning.putVersion(tag, CURRENT_VERSION); + ListTag list = new ListTag(); + for (StrategicResourceAccountSnapshot account : this.accounts.values()) { + list.add(account.toTag()); + } + tag.put("Accounts", list); + return tag; + } + + @Nullable + public StrategicResourceAccountSnapshot getAccount(UUID claimUuid) { + return this.accounts.get(claimUuid); + } + + public StrategicResourceAccountSnapshot credit(UUID claimUuid, StrategicResourceBucket bucket, int amount, long tick) { + StrategicResourceAccountSnapshot account = this.accounts.getOrDefault(claimUuid, StrategicResourceAccountSnapshot.empty(claimUuid)); + StrategicResourceAccountSnapshot updated = account.withCredit(bucket, amount, tick); + putAccount(updated); + return updated; + } + + @Nullable + public StrategicResourceAccountSnapshot debit(UUID claimUuid, StrategicResourceBucket bucket, int amount, long tick) { + StrategicResourceAccountSnapshot account = this.accounts.get(claimUuid); + if (account == null) { + return null; + } + StrategicResourceAccountSnapshot updated = account.withDebit(bucket, amount, tick); + putAccount(updated); + return updated; + } + + public void putAccount(StrategicResourceAccountSnapshot account) { + if (account == null) { + return; + } + StrategicResourceAccountSnapshot previous = this.accounts.put(account.claimUuid(), account); + if (!account.equals(previous)) { + this.setDirty(); + } + } + + public Collection getAllAccounts() { + return this.accounts.values(); + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingService.java new file mode 100644 index 00000000..2b392936 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingService.java @@ -0,0 +1,146 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.governance.BannerModTreasuryLedgerSnapshot; +import com.talhanation.bannermod.governance.BannerModTreasuryManager; +import com.talhanation.bannermod.settlement.SettlementManager; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; + +import java.util.UUID; + +public final class StrategicResourceAccountingService { + private StrategicResourceAccountingService() { + } + + public static TransactionResult credit(ServerLevel level, + UUID claimUuid, + StrategicResourceBucket bucket, + int amount, + long tick) { + return credit( + BannerModTreasuryManager.get(level), + SettlementManager.get(level), + StrategicResourceAccountingManager.get(level), + claimUuid, + bucket, + amount, + tick + ); + } + + public static TransactionResult debit(ServerLevel level, + UUID claimUuid, + StrategicResourceBucket bucket, + int amount, + long tick) { + return debit( + BannerModTreasuryManager.get(level), + StrategicResourceAccountingManager.get(level), + claimUuid, + bucket, + amount, + tick + ); + } + + static TransactionResult credit(BannerModTreasuryManager treasuryManager, + SettlementManager settlementManager, + StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + StrategicResourceBucket bucket, + int amount, + long tick) { + int normalizedAmount = Math.max(0, amount); + if (claimUuid == null || bucket == null) { + return TransactionResult.sourceUnavailable(bucket, normalizedAmount, "invalid_request"); + } + if (bucket.treasuryBacked()) { + BannerModTreasuryLedgerSnapshot ledger = treasuryManager.getLedger(claimUuid); + SettlementSnapshot snapshot = settlementManager.getSnapshot(claimUuid); + ChunkPos anchorChunk = ledger != null ? ledger.anchorChunk() : snapshot != null ? snapshot.anchorChunk() : null; + if (anchorChunk == null) { + return TransactionResult.sourceUnavailable(bucket, normalizedAmount, "treasury_identity_unavailable"); + } + String settlementFactionId = ledger != null ? ledger.settlementFactionId() : snapshot.settlementFactionId(); + BannerModTreasuryLedgerSnapshot updated = treasuryManager.depositTaxes(claimUuid, anchorChunk, settlementFactionId, normalizedAmount, tick); + return TransactionResult.success(bucket, normalizedAmount, normalizedAmount, updated.treasuryBalance(), "treasury"); + } + + StrategicResourceAccountSnapshot updated = accountingManager.credit(claimUuid, bucket, normalizedAmount, tick); + return TransactionResult.success(bucket, normalizedAmount, normalizedAmount, updated.balance(bucket), "settlement_accounting"); + } + + static TransactionResult debit(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + StrategicResourceBucket bucket, + int amount, + long tick) { + int normalizedAmount = Math.max(0, amount); + if (claimUuid == null || bucket == null) { + return TransactionResult.sourceUnavailable(bucket, normalizedAmount, "invalid_request"); + } + if (bucket.treasuryBacked()) { + BannerModTreasuryLedgerSnapshot ledger = treasuryManager.getLedger(claimUuid); + if (ledger == null) { + return TransactionResult.sourceUnavailable(bucket, normalizedAmount, "treasury_unavailable"); + } + int balance = ledger.treasuryBalance(); + if (balance < normalizedAmount) { + return TransactionResult.shortage(bucket, normalizedAmount, normalizedAmount - balance, balance, "treasury"); + } + BannerModTreasuryLedgerSnapshot updated = ledger.withArmyUpkeepDebit(normalizedAmount, tick); + treasuryManager.putLedger(updated); + return TransactionResult.success(bucket, normalizedAmount, normalizedAmount, updated.treasuryBalance(), "treasury"); + } + + StrategicResourceAccountSnapshot account = accountingManager.getAccount(claimUuid); + if (account == null) { + return TransactionResult.sourceUnavailable(bucket, normalizedAmount, "settlement_accounting_unavailable"); + } + int balance = account.balance(bucket); + if (balance < normalizedAmount) { + return TransactionResult.shortage(bucket, normalizedAmount, normalizedAmount - balance, balance, "settlement_accounting"); + } + StrategicResourceAccountSnapshot updated = accountingManager.debit(claimUuid, bucket, normalizedAmount, tick); + return TransactionResult.success(bucket, normalizedAmount, normalizedAmount, updated.balance(bucket), "settlement_accounting"); + } + + public enum Status { + SUCCESS, + SHORTAGE, + SOURCE_UNAVAILABLE + } + + public record TransactionResult( + Status status, + StrategicResourceBucket bucket, + int requestedAmount, + int appliedAmount, + int missingAmount, + int balanceAfter, + String source, + String reason + ) { + private static TransactionResult success(StrategicResourceBucket bucket, + int requestedAmount, + int appliedAmount, + int balanceAfter, + String source) { + return new TransactionResult(Status.SUCCESS, bucket, requestedAmount, appliedAmount, 0, balanceAfter, source, ""); + } + + private static TransactionResult shortage(StrategicResourceBucket bucket, + int requestedAmount, + int missingAmount, + int balanceAfter, + String source) { + return new TransactionResult(Status.SHORTAGE, bucket, requestedAmount, 0, missingAmount, balanceAfter, source, "shortage"); + } + + private static TransactionResult sourceUnavailable(StrategicResourceBucket bucket, int requestedAmount, String reason) { + return new TransactionResult(Status.SOURCE_UNAVAILABLE, bucket, requestedAmount, 0, requestedAmount, 0, "", reason); + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceBucket.java b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceBucket.java new file mode 100644 index 00000000..fa0913cf --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/StrategicResourceBucket.java @@ -0,0 +1,23 @@ +package com.talhanation.bannermod.settlement.economy; + +public enum StrategicResourceBucket { + FOOD("food"), + IRON("iron"), + WOOD("wood"), + STONE("stone"), + COINS("coins"); + + private final String id; + + StrategicResourceBucket(String id) { + this.id = id; + } + + public String id() { + return this.id; + } + + public boolean treasuryBacked() { + return this == COINS; + } +} diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java index ebe984ef..5e935a11 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java @@ -8,6 +8,7 @@ import com.talhanation.bannermod.settlement.SettlementProjectCandidateSnapshot; import com.talhanation.bannermod.settlement.SettlementSnapshot; import com.talhanation.bannermod.settlement.SettlementStockpileSummary; +import com.talhanation.bannermod.settlement.SettlementSupplySignal; import com.talhanation.bannermod.settlement.SettlementSupplySignalState; import com.talhanation.bannermod.settlement.SettlementTradeRouteHandoffSnapshot; import com.talhanation.bannermod.settlement.building.BuildingType; @@ -229,6 +230,83 @@ void unownedOrOtherClaimMineSiteDoesNotContribute() { assertEquals(0, line(summary, "stone").productionHint()); } + @Test + void unstaffedMineSiteYieldsLessThanStaffedValidMine() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot(claimUuid, List.of(), 0, 0, 0); + + ClaimStrategicEconomySummary staffed = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(), + null, + List.of(mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.IRON, 1.0F, 2, false, false)) + ); + ClaimStrategicEconomySummary unstaffed = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(), + null, + List.of(mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.IRON, 1.0F, 0, false, false)) + ); + + assertEquals(3, line(staffed, "iron").productionHint()); + assertEquals(1, line(unstaffed, "iron").productionHint()); + assertTrue(line(unstaffed, "iron").degraded()); + } + + @Test + void missingMineMaintenanceInputsDegradeMineYield() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + + for (String missingGood : List.of("food", "wood", "tools")) { + SettlementSnapshot snapshot = snapshot( + claimUuid, + List.of(), + 0, + 0, + 0, + List.of(new SettlementSupplySignal(missingGood, 1, 0, 1, 0)) + ); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(), + null, + List.of(mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.IRON, 1.0F, 2, false, false)) + ); + + ClaimStrategicEconomySummary.ResourceLine iron = line(summary, "iron"); + assertEquals(1, iron.productionHint(), missingGood); + assertTrue(iron.degraded(), missingGood); + } + } + + @Test + void reservedMineDisruptionReducesYieldWithoutRemovingMineRecord() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + SettlementSnapshot snapshot = snapshot( + claimUuid, + List.of(), + 0, + 0, + 0, + List.of(new SettlementSupplySignal("mine_disruption", 0, 0, 0, 1)) + ); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot, + List.of(), + null, + List.of(mineSite(claimUuid, ownerUuid, VenaterraDepositCategory.IRON, 1.0F, 2, false, false)) + ); + + ClaimStrategicEconomySummary.ResourceLine iron = line(summary, "iron"); + assertEquals(2, iron.productionHint()); + assertTrue(iron.degraded()); + } + private static ClaimStrategicEconomySummary.ResourceLine line(ClaimStrategicEconomySummary summary, String resourceId) { return summary.resources().stream() .filter(line -> line.resourceId().equals(resourceId)) @@ -277,6 +355,16 @@ private static StrategicMineSite mineSite(UUID claimUuid, float richness, boolean degraded, boolean unknown) { + return mineSite(claimUuid, ownerUuid, category, richness, 1, degraded, unknown); + } + + private static StrategicMineSite mineSite(UUID claimUuid, + UUID ownerUuid, + VenaterraDepositCategory category, + float richness, + int assignedWorkerCount, + boolean degraded, + boolean unknown) { return new StrategicMineSite( UUID.randomUUID(), claimUuid, @@ -287,6 +375,7 @@ private static StrategicMineSite mineSite(UUID claimUuid, StrategicMineSite.SourceType.VALIDATED_MINE_BUILDING, category, richness, + assignedWorkerCount, degraded, unknown ); @@ -297,6 +386,15 @@ private static SettlementSnapshot snapshot(UUID claimUuid, int assignedResidents, int assignedWorkers, int missingAssignments) { + return snapshot(claimUuid, buildings, assignedResidents, assignedWorkers, missingAssignments, List.of()); + } + + private static SettlementSnapshot snapshot(UUID claimUuid, + List buildings, + int assignedResidents, + int assignedWorkers, + int missingAssignments, + List supplySignals) { return new SettlementSnapshot( claimUuid, 1, @@ -314,9 +412,22 @@ private static SettlementSnapshot snapshot(UUID claimUuid, SettlementDesiredGoodsSnapshot.empty(), SettlementProjectCandidateSnapshot.empty(), SettlementTradeRouteHandoffSnapshot.empty(), - SettlementSupplySignalState.empty(), + supplySignalState(supplySignals), List.of(), buildings ); } + + private static SettlementSupplySignalState supplySignalState(List supplySignals) { + if (supplySignals.isEmpty()) { + return SettlementSupplySignalState.empty(); + } + return new SettlementSupplySignalState( + supplySignals.size(), + (int) supplySignals.stream().filter(signal -> signal.shortageUnits() > 0).count(), + supplySignals.stream().mapToInt(SettlementSupplySignal::shortageUnits).sum(), + supplySignals.stream().mapToInt(SettlementSupplySignal::reservationHintUnits).sum(), + supplySignals + ); + } } diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/FortLevelDefinitionTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/FortLevelDefinitionTest.java new file mode 100644 index 00000000..da1e7686 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/FortLevelDefinitionTest.java @@ -0,0 +1,78 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.ChunkPos; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FortLevelDefinitionTest { + @Test + void starterFortDefaultsToLevelOneAndBetaCaps() { + FortLevelDefinition definition = FortLevelDefinition.forLevel(1); + + assertEquals(1, definition.level()); + assertEquals(4, definition.workerCap()); + assertEquals(4, definition.soldierCap()); + assertEquals(1, definition.mineCap()); + assertEquals(0, definition.outpostCap()); + assertNotNull(definition.nextLevelRequirement()); + } + + @Test + void levelCapsMatchBetaTable() { + assertCaps(1, 4, 4, 1, 0); + assertCaps(2, 8, 10, 1, 1); + assertCaps(3, 16, 25, 2, 2); + assertCaps(4, 32, 50, 3, 4); + } + + @Test + void claimFortLevelSurvivesNbtRoundTrip() { + RecruitsClaim claim = new RecruitsClaim("Fort", UUID.randomUUID()); + claim.addChunk(new ChunkPos(3, 4)); + claim.setCenter(new ChunkPos(3, 4)); + claim.setFortLevel(3); + + CompoundTag tag = claim.toNBT(); + RecruitsClaim loaded = RecruitsClaim.fromNBT(tag); + + assertEquals(3, loaded.getFortLevel()); + } + + @Test + void summaryDebugLinesShowCapsAndNextRequirements() { + UUID claimUuid = UUID.randomUUID(); + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + SettlementSnapshot.create(claimUuid, new ChunkPos(0, 0), null), + List.of(), + null, + List.of(), + 1 + ); + + List lines = summary.debugLines(); + + assertTrue(lines.stream().anyMatch(line -> line.contains("Fort level 1"))); + assertTrue(lines.stream().anyMatch(line -> line.contains("caps workers=4 soldiers=4 mines=1 outposts=0"))); + assertTrue(lines.stream().anyMatch(line -> line.contains("Next fort level requirements: food="))); + assertTrue(lines.stream().anyMatch(line -> line.contains("iron=") && line.contains("wood=") + && line.contains("stone=") && line.contains("coins="))); + } + + private static void assertCaps(int level, int workers, int soldiers, int mines, int outposts) { + FortLevelDefinition definition = FortLevelDefinition.forLevel(level); + + assertEquals(workers, definition.workerCap()); + assertEquals(soldiers, definition.soldierCap()); + assertEquals(mines, definition.mineCap()); + assertEquals(outposts, definition.outpostCap()); + } +} diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractServiceTest.java new file mode 100644 index 00000000..0bc996ed --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractServiceTest.java @@ -0,0 +1,160 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.settlement.SettlementDesiredGoodsSnapshot; +import com.talhanation.bannermod.settlement.SettlementMarketRecord; +import com.talhanation.bannermod.settlement.SettlementMarketState; +import com.talhanation.bannermod.settlement.SettlementProjectCandidateSnapshot; +import com.talhanation.bannermod.settlement.SettlementSellerDispatchRecord; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import com.talhanation.bannermod.settlement.SettlementStockpileSummary; +import com.talhanation.bannermod.settlement.SettlementSupplySignalState; +import com.talhanation.bannermod.settlement.SettlementTradeRouteHandoffSnapshot; +import net.minecraft.nbt.CompoundTag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NpcDemandContractServiceTest { + private static final UUID CLAIM = UUID.fromString("00000000-0000-0000-0000-000000000291"); + + @Test + void isolatedClaimSpawnsContractWithoutOnlinePlayerInput() { + NpcDemandContractService service = new NpcDemandContractService(); + + service.tickClaim(snapshot(SettlementMarketState.empty(), SettlementTradeRouteHandoffSnapshot.empty()), 100L); + + List contracts = service.contracts(CLAIM); + assertEquals(1, contracts.size()); + assertEquals(NpcDemandContract.Status.OPEN, contracts.get(0).status()); + } + + @Test + void marketAccessImprovesFrequencyAndQualityComparedWithIsolatedFort() { + NpcDemandContractService isolated = new NpcDemandContractService(); + NpcDemandContractService market = new NpcDemandContractService(); + SettlementSnapshot isolatedSnapshot = snapshot(SettlementMarketState.empty(), SettlementTradeRouteHandoffSnapshot.empty()); + SettlementSnapshot marketSnapshot = snapshot(openMarketState(), SettlementTradeRouteHandoffSnapshot.empty()); + + isolated.tickClaim(isolatedSnapshot, 0L); + market.tickClaim(marketSnapshot, 0L); + isolated.tickClaim(isolatedSnapshot, 12_000L); + market.tickClaim(marketSnapshot, 12_000L); + + assertEquals(1, isolated.contracts(CLAIM).size(), "isolated forts wait a full day before another contract"); + assertEquals(2, market.contracts(CLAIM).size(), "market claims can receive contracts twice as often"); + assertTrue(market.contracts(CLAIM).get(0).rewardCoins() > isolated.contracts(CLAIM).get(0).rewardCoins()); + assertTrue(market.contracts(CLAIM).get(0).amount() > isolated.contracts(CLAIM).get(0).amount()); + } + + @Test + void tradingPostAccessFurtherImprovesContractCadence() { + NpcDemandContractService market = new NpcDemandContractService(); + NpcDemandContractService tradingPost = new NpcDemandContractService(); + SettlementSnapshot marketSnapshot = snapshot(openMarketState(), SettlementTradeRouteHandoffSnapshot.empty()); + SettlementSnapshot tradingPostClaim = snapshot(SettlementMarketState.empty(), tradingPostHandoffSnapshot()); + + market.tickClaim(marketSnapshot, 0L); + tradingPost.tickClaim(tradingPostClaim, 0L); + market.tickClaim(marketSnapshot, 9_000L); + tradingPost.tickClaim(tradingPostClaim, 9_000L); + + assertEquals(1, market.contracts(CLAIM).size()); + assertEquals(2, tradingPost.contracts(CLAIM).size()); + assertNotEquals(market.contracts(CLAIM).get(0).rewardCoins(), tradingPost.contracts(CLAIM).get(0).rewardCoins()); + } + + @Test + void statusLinesExposeServerBackedContractState() { + NpcDemandContractService service = new NpcDemandContractService(); + service.tickClaim(snapshot(SettlementMarketState.empty(), SettlementTradeRouteHandoffSnapshot.empty()), 0L); + + List lines = service.statusLines(CLAIM); + + assertEquals(2, lines.size()); + assertTrue(lines.get(0).contains("NPC demand contracts for claim")); + assertTrue(lines.get(1).contains("status=OPEN")); + assertTrue(lines.get(1).contains("buyer=")); + assertTrue(lines.get(1).contains("reward=")); + } + + @Test + void contractsAndScheduleSurviveNbtRoundTrip() { + SettlementSnapshot isolatedSnapshot = snapshot(SettlementMarketState.empty(), SettlementTradeRouteHandoffSnapshot.empty()); + NpcDemandContractService service = new NpcDemandContractService(); + service.tickClaim(isolatedSnapshot, 0L); + + CompoundTag tag = new CompoundTag(); + service.saveToTag(tag); + NpcDemandContractService loaded = new NpcDemandContractService(); + loaded.loadFromTag(tag); + + assertEquals(1, loaded.contracts(CLAIM).size()); + loaded.tickClaim(isolatedSnapshot, 12_000L); + assertEquals(1, loaded.contracts(CLAIM).size(), "next eligible tick should persist across reload"); + loaded.tickClaim(isolatedSnapshot, 24_000L); + assertEquals(2, loaded.contracts(CLAIM).size()); + } + + private static SettlementSnapshot snapshot(SettlementMarketState marketState, + SettlementTradeRouteHandoffSnapshot tradeRouteHandoffSnapshot) { + return new SettlementSnapshot( + CLAIM, + 1, + 2, + "blueguild", + 0L, + 0, + 0, + 0, + 0, + 0, + 0, + SettlementStockpileSummary.empty(), + marketState, + SettlementDesiredGoodsSnapshot.empty(), + SettlementProjectCandidateSnapshot.empty(), + tradeRouteHandoffSnapshot, + SettlementSupplySignalState.empty(), + List.of(), + List.of() + ); + } + + private static SettlementMarketState openMarketState() { + UUID marketUuid = UUID.fromString("00000000-0000-0000-0000-000000000292"); + return new SettlementMarketState( + 1, + 1, + 16, + 8, + 0, + 0, + List.of(new SettlementMarketRecord(marketUuid, "Market", true, 16, 8)), + List.of() + ); + } + + private static SettlementTradeRouteHandoffSnapshot tradingPostHandoffSnapshot() { + return new SettlementTradeRouteHandoffSnapshot( + 1, + 1, + 1, + 1, + 0, + 0, + List.of(), + List.of(new SettlementSellerDispatchRecord( + UUID.fromString("00000000-0000-0000-0000-000000000293"), + UUID.fromString("00000000-0000-0000-0000-000000000294"), + "Trading Post", + com.talhanation.bannermod.settlement.SettlementSellerDispatchState.READY + )), + List.of() + ); + } +} diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java index 0b56d730..791303b5 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicMineSiteServiceTest.java @@ -198,6 +198,59 @@ void invalidValidatedMineDoesNotReappearThroughSnapshotFallback() { assertTrue(sites.isEmpty()); } + @Test + void validatedMineUsesOverlappingSnapshotWorkersWithoutDuplicateSite() { + RecruitsClaim claim = claim(UUID.randomUUID()); + UUID validatedMineId = UUID.randomUUID(); + UUID liveMineId = UUID.randomUUID(); + + List sites = StrategicMineSiteService.derive( + null, + claim, + snapshot(claim.getUUID(), List.of(miningArea(liveMineId, 0))), + List.of(validated(claim.getUUID(), validatedMineId, BuildingType.MINE, BuildingValidationState.VALID)), + deposits(List.of(new VenaterraDepositCandidate( + VenaterraDepositCategory.IRON, + ResourceLocation.withDefaultNamespace("iron_ore"), + ResourceLocation.withDefaultNamespace("raw_iron"), + new BlockPos(0, 64, 0), + 0.75F, + 0.8D, + new VenaterraDepositCandidate.SourceMetadata("venaterra", "test", Level.OVERWORLD.location()) + ))) + ); + + assertEquals(1, sites.size()); + StrategicMineSite site = sites.getFirst(); + assertEquals(validatedMineId, site.siteId()); + assertEquals(0, site.assignedWorkerCount()); + } + + @Test + void invalidValidatedMineSuppressesOverlappingLiveSnapshotFallback() { + RecruitsClaim claim = claim(UUID.randomUUID()); + UUID validatedMineId = UUID.randomUUID(); + UUID liveMineId = UUID.randomUUID(); + + List sites = StrategicMineSiteService.derive( + null, + claim, + snapshot(claim.getUUID(), List.of(miningArea(liveMineId, 1))), + List.of(validated(claim.getUUID(), validatedMineId, BuildingType.MINE, BuildingValidationState.INVALID)), + deposits(List.of(new VenaterraDepositCandidate( + VenaterraDepositCategory.IRON, + ResourceLocation.withDefaultNamespace("iron_ore"), + ResourceLocation.withDefaultNamespace("raw_iron"), + new BlockPos(0, 64, 0), + 0.75F, + 0.8D, + new VenaterraDepositCandidate.SourceMetadata("venaterra", "test", Level.OVERWORLD.location()) + ))) + ); + + assertTrue(sites.isEmpty()); + } + @Test void nonMineBuildingIsNotIncluded() { RecruitsClaim claim = claim(UUID.randomUUID()); @@ -241,10 +294,18 @@ private static ValidatedBuildingRecord validated(UUID claimUuid, } private static SettlementBuildingRecord miningArea(UUID buildingUuid) { - return building(buildingUuid, "bannermod:mining_area"); + return miningArea(buildingUuid, 1); + } + + private static SettlementBuildingRecord miningArea(UUID buildingUuid, int assignedWorkers) { + return building(buildingUuid, "bannermod:mining_area", assignedWorkers); } private static SettlementBuildingRecord building(UUID buildingUuid, String typeId) { + return building(buildingUuid, typeId, 1); + } + + private static SettlementBuildingRecord building(UUID buildingUuid, String typeId, int assignedWorkers) { return new SettlementBuildingRecord( buildingUuid, typeId, @@ -253,7 +314,7 @@ private static SettlementBuildingRecord building(UUID buildingUuid, String typeI "blueguild", 0, 1, - 1, + assignedWorkers, List.of() ); } diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingServiceTest.java new file mode 100644 index 00000000..f8b86ea8 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/StrategicResourceAccountingServiceTest.java @@ -0,0 +1,175 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.governance.BannerModTreasuryLedgerSnapshot; +import com.talhanation.bannermod.governance.BannerModTreasuryManager; +import com.talhanation.bannermod.settlement.SettlementManager; +import com.talhanation.bannermod.settlement.SettlementSnapshot; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.world.level.ChunkPos; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class StrategicResourceAccountingServiceTest { + + @Test + void debitsCoinsThroughTreasuryLedger() { + UUID claimUuid = UUID.randomUUID(); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + treasury.depositTaxes(claimUuid, new ChunkPos(2, 3), "blueguild", 15, 10L); + + StrategicResourceAccountingService.TransactionResult result = StrategicResourceAccountingService.debit( + treasury, + new StrategicResourceAccountingManager(), + claimUuid, + StrategicResourceBucket.COINS, + 9, + 20L + ); + + assertEquals(StrategicResourceAccountingService.Status.SUCCESS, result.status()); + assertEquals(StrategicResourceBucket.COINS, result.bucket()); + assertEquals(9, result.appliedAmount()); + assertEquals(0, result.missingAmount()); + assertEquals(6, result.balanceAfter()); + assertEquals("treasury", result.source()); + assertEquals(6, treasury.getLedger(claimUuid).treasuryBalance()); + } + + @Test + void debitsNonCoinResourceFromSettlementAccounting() { + UUID claimUuid = UUID.randomUUID(); + StrategicResourceAccountingManager accounting = new StrategicResourceAccountingManager(); + accounting.credit(claimUuid, StrategicResourceBucket.WOOD, 12, 10L); + + StrategicResourceAccountingService.TransactionResult result = StrategicResourceAccountingService.debit( + new BannerModTreasuryManager(), + accounting, + claimUuid, + StrategicResourceBucket.WOOD, + 5, + 20L + ); + + assertEquals(StrategicResourceAccountingService.Status.SUCCESS, result.status()); + assertEquals(StrategicResourceBucket.WOOD, result.bucket()); + assertEquals(5, result.appliedAmount()); + assertEquals(7, result.balanceAfter()); + assertEquals(7, accounting.getAccount(claimUuid).wood()); + } + + @Test + void failedCoinDebitLeavesTreasuryUnchangedAndReportsMissingAmount() { + UUID claimUuid = UUID.randomUUID(); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + treasury.depositTaxes(claimUuid, new ChunkPos(2, 3), "blueguild", 4, 10L); + BannerModTreasuryLedgerSnapshot before = treasury.getLedger(claimUuid); + + StrategicResourceAccountingService.TransactionResult result = StrategicResourceAccountingService.debit( + treasury, + new StrategicResourceAccountingManager(), + claimUuid, + StrategicResourceBucket.COINS, + 9, + 20L + ); + + assertEquals(StrategicResourceAccountingService.Status.SHORTAGE, result.status()); + assertEquals(StrategicResourceBucket.COINS, result.bucket()); + assertEquals(5, result.missingAmount()); + assertEquals(0, result.appliedAmount()); + assertEquals(4, result.balanceAfter()); + assertEquals(before, treasury.getLedger(claimUuid)); + } + + @Test + void failedNonCoinDebitLeavesAccountingUnchangedAndReportsMissingAmount() { + UUID claimUuid = UUID.randomUUID(); + StrategicResourceAccountingManager accounting = new StrategicResourceAccountingManager(); + accounting.credit(claimUuid, StrategicResourceBucket.IRON, 3, 10L); + StrategicResourceAccountSnapshot before = accounting.getAccount(claimUuid); + + StrategicResourceAccountingService.TransactionResult result = StrategicResourceAccountingService.debit( + new BannerModTreasuryManager(), + accounting, + claimUuid, + StrategicResourceBucket.IRON, + 8, + 20L + ); + + assertEquals(StrategicResourceAccountingService.Status.SHORTAGE, result.status()); + assertEquals(StrategicResourceBucket.IRON, result.bucket()); + assertEquals(5, result.missingAmount()); + assertEquals(0, result.appliedAmount()); + assertEquals(3, result.balanceAfter()); + assertEquals(before, accounting.getAccount(claimUuid)); + } + + @Test + void missingNonCoinAccountReportsSourceUnavailableWithoutCreatingBalance() { + UUID claimUuid = UUID.randomUUID(); + StrategicResourceAccountingManager accounting = new StrategicResourceAccountingManager(); + + StrategicResourceAccountingService.TransactionResult result = StrategicResourceAccountingService.debit( + new BannerModTreasuryManager(), + accounting, + claimUuid, + StrategicResourceBucket.STONE, + 2, + 20L + ); + + assertEquals(StrategicResourceAccountingService.Status.SOURCE_UNAVAILABLE, result.status()); + assertEquals(StrategicResourceBucket.STONE, result.bucket()); + assertEquals(2, result.missingAmount()); + assertEquals("settlement_accounting_unavailable", result.reason()); + assertNull(accounting.getAccount(claimUuid)); + } + + @Test + void creditsCoinsThroughTreasuryIdentityFromSettlementSnapshot() { + UUID claimUuid = UUID.randomUUID(); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + SettlementManager settlements = new SettlementManager(); + settlements.putSnapshot(SettlementSnapshot.create(claimUuid, new ChunkPos(4, 5), "blueguild")); + + StrategicResourceAccountingService.TransactionResult result = StrategicResourceAccountingService.credit( + treasury, + settlements, + new StrategicResourceAccountingManager(), + claimUuid, + StrategicResourceBucket.COINS, + 11, + 30L + ); + + assertEquals(StrategicResourceAccountingService.Status.SUCCESS, result.status()); + assertEquals(11, result.balanceAfter()); + assertNotNull(treasury.getLedger(claimUuid)); + assertEquals(new ChunkPos(4, 5), treasury.getLedger(claimUuid).anchorChunk()); + } + + @Test + void nonCoinAccountingPersistsBalancesAndReceiptFields() { + UUID claimUuid = UUID.randomUUID(); + StrategicResourceAccountingManager accounting = new StrategicResourceAccountingManager(); + accounting.credit(claimUuid, StrategicResourceBucket.FOOD, 10, 30L); + accounting.debit(claimUuid, StrategicResourceBucket.FOOD, 4, 40L); + + StrategicResourceAccountingManager restored = StrategicResourceAccountingManager.load(accounting.save(new CompoundTag(), null), null); + StrategicResourceAccountSnapshot restoredAccount = restored.getAccount(claimUuid); + + assertNotNull(restoredAccount); + assertEquals(6, restoredAccount.food()); + assertEquals("food", restoredAccount.lastBucketId()); + assertEquals(10, restoredAccount.lastCreditAmount()); + assertEquals(30L, restoredAccount.lastCreditTick()); + assertEquals(4, restoredAccount.lastDebitAmount()); + assertEquals(40L, restoredAccount.lastDebitTick()); + } +}