diff --git a/MULTIPLAYER_GUIDE_EN.md b/MULTIPLAYER_GUIDE_EN.md index 05fa5763..11fd0b7f 100644 --- a/MULTIPLAYER_GUIDE_EN.md +++ b/MULTIPLAYER_GUIDE_EN.md @@ -44,7 +44,7 @@ During normal play in the Overworld, the small top-right claim HUD shows whether 6. For the other manual buildings keep using the `Settlement Surveyor Tool`: switch modes to `Storage`, `Farm`, `House`, `Mine`, `Lumber Camp`, `Smithy`, `Architect Builder`, or `Barracks`. Each mode now keeps its own hologram visible after you mark zones, highlights the recommended storage/interior/sleeping/work area, auto-advances to the next required role for multi-zone buildings, and shows role-specific build hints directly in the board/HUD so you know whether a volume wants beds, chests, crops, furnaces, drafting space, or walkable interior air. Supported post-anchor modes also expose `Actions -> Suggest Draft`: it scans for likely beds, chests/barrels, farmland plus water, ore or stone faces, logs plus saplings, and furnace/anvil work clusters, then drafts only the still-missing zones into your current survey session. Those suggestions are a helper, not a guaranteed-correct validator: keep them, edit them, clear them, or overwrite them with normal manual marking before pressing `Validate`. Suggested zones never register a building on their own; validation still goes through the same surveyor registration/runtime path as a manual building of that mode. The surveyor still never places blocks; it only previews and validates what the player built. If you loaded or built an imported structure first, open `Actions` on the survey board and turn off the canned guide preview before marking zones. In that imported/post-build flow the overlay keeps only the anchor, your pending corner, and your captured zones visible, so odd imported footprints do not fight the teaching hologram. Validation still goes through the same surveyor registration/runtime path as a manual building of that mode, and it does not need any extra metadata file beside the built structure itself. The `Building Placement Wand` still exists for prefab placement, validation, and registration workflows, but its picker is now paged and less cluttered: fewer entries per page, shorter labels, and most details moved into hover tooltips. It now includes a `Gatehouse` prefab: select it while the wand is in `PLACE` mode and right-click the target block to spawn a roofed twin-tower entrance. This is a prefab-placement workflow, not a starter-fort surveyor mode. The surveyor modes are now enough to build and validate the core starter settlement chain by hand. 7. Nearby unassigned citizens only convert after they physically reach a registered building anchor with an open vacancy. Watch the surveyor/wand feedback after founding or validating buildings: it now tells you which vacancy opened and which building type is the next safe expansion step. 8. Configure workers: right-click a worker to open its inventory and assign a profession or task, or open the worker command screen with `X` and issue group orders. -9. If you recruit soldiers, keep food and payment in your inventory. Issue orders with `R` (command screen): movement, formation, stance, aggression. Press `R` near a single recruit to assign per-unit tasks from its inventory. +9. If you recruit soldiers, keep food and payment in your inventory. Governed settlements also debit coarse army upkeep from settlement food stores and treasury coins; a missed cycle causes upkeep warnings and pressure instead of instantly deleting soldiers. Heavy, shielded, mounted, leader, or ranged troops can cost more than basic recruits. Issue orders with `R` (command screen): movement, formation, stance, aggression. Press `R` near a single recruit to assign per-unit tasks from its inventory. If something fails, first check whether the action is inside your claim and whether the worker or recruit belongs to your side. @@ -144,7 +144,7 @@ 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. +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. Governed armies also draw coarse upkeep from settlement food accounting and treasury coins; shortages surface as recruit upkeep incidents in the governor mirror, and heavier or ranged troop classes increase the requested cost when the server can infer them safely. 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 diff --git a/MULTIPLAYER_GUIDE_RU.md b/MULTIPLAYER_GUIDE_RU.md index dab80f62..b311ccbf 100644 --- a/MULTIPLAYER_GUIDE_RU.md +++ b/MULTIPLAYER_GUIDE_RU.md @@ -44,7 +44,7 @@ BannerMod добавляет поселения, рабочих, армии, г 6. Для остальных ручных построек продолжай использовать `Settlement Surveyor Tool`: переключай режимы на `Storage`, `Farm`, `House`, `Mine`, `Lumber Camp`, `Smithy`, `Architect Builder` или `Barracks`. У каждого режима теперь своя голограмма, она не исчезает после захвата зон, показывает рекомендуемую область склада/интерьера/спален/работы, сама переключает следующую обязательную роль у многозонных построек и прямо в планшете/HUD объясняет, чего эта зона ждет: кровати, сундуки, посевы, печи, место для чертежей или просто проходимый воздушный интерьер. Поддерживаемые режимы после постановки якоря также получают `Actions -> Черновик по подсказкам`: помощник сканирует рядом кровати, сундуки и бочки, грядки с водой, открытый камень или руду, бревна с саженцами и кластеры печей или наковален, а затем добавляет только недостающие зоны в текущую сессию. Это именно помощник, а не гарантированно точный валидатор: черновик можно оставить, поправить, очистить или просто перерисовать обычной ручной разметкой до нажатия `Проверить`. Сами подсказки никогда не регистрируют здание без обычной валидации. Землемер по-прежнему никогда не ставит блоки сам: он только показывает план и валидирует то, что реально построил игрок. Если ты сначала загрузил или уже построил импортированную структуру, открой `Actions` у планшета землемера и отключи шаблонную голограмму перед разметкой зон. В этом импортированном/post-build потоке остаются видимыми только якорь, ожидаемый угол и отмеченные игроком зоны, поэтому странный силуэт схемы не спорит с учебной голограммой. Проверка все равно идет по тому же пути регистрации и runtime, что и у ручного здания того же режима, и не требует отдельного metadata-файла. `Building Placement Wand` все еще существует для префабов, проверки и регистрации шаблонов, но его селектор теперь разбит на страницы и меньше перегружает экран: меньше вариантов за раз, короче подписи и больше подробностей в ховере. В нем теперь есть префаб `Gatehouse`: выбери его, пока wand стоит в режиме `PLACE`, и ПКМ по целевому блоку, чтобы поставить крытый вход с двумя башнями. Это путь размещения префаба, а не режим surveyor для стартового форта. Цепочку стартовых зданий теперь можно полностью строить и валидировать вручную через режимы землемера. 7. Свободные жители превращаются только когда физически доходят до якоря зарегистрированного здания с открытой вакансией. Следи за сообщениями землемера и жезла после основания или валидации: они теперь пишут, какая вакансия открылась и какой тип здания лучше ставить следующим. 8. Настрой воркеров: открой их инвентарь (ПКМ по воркеру), назначь профессию/задачу, либо открой их командный экран по `X` и раздай приказы группе. -9. Если нанимаешь рекрутов, держи для них еду и оплату в инвентаре. Команды ставь через `R` (командный экран): движение, формация, стойка, агрессия. Можно нажать `R` рядом с одним рекрутом, чтобы назначить ему персональную задачу из его инвентаря. +9. Если нанимаешь рекрутов, держи для них еду и оплату в инвентаре. Поселения с губернатором также списывают грубое содержание армии из запасов еды поселения и монет казны; один пропущенный цикл дает предупреждения и давление, но не удаляет солдат мгновенно. Тяжелые, щитовые, конные, лидерские или дальнобойные бойцы могут стоить дороже обычных рекрутов, когда сервер может безопасно это определить. Команды ставь через `R` (командный экран): движение, формация, стойка, агрессия. Можно нажать `R` рядом с одним рекрутом, чтобы назначить ему персональную задачу из его инвентаря. Если что-то не работает, сначала проверь: это внутри твоего клейма, и рабочий или рекрут действительно на твоей стороне? diff --git a/docs/BANNERMOD_ALMANAC.html b/docs/BANNERMOD_ALMANAC.html index ec05e821..a11e1c92 100644 --- a/docs/BANNERMOD_ALMANAC.html +++ b/docs/BANNERMOD_ALMANAC.html @@ -92,7 +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.

+

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. Governed armies now draw coarse food and coin upkeep from the settlement; shortages appear as recruit upkeep incidents instead of instantly deleting soldiers, and heavy or ranged troops can cost more when their class or equipment is clear. 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.

diff --git a/docs/BANNERMOD_BACKLOG.sqlite b/docs/BANNERMOD_BACKLOG.sqlite index 46a132a2..c2ecefd8 100644 Binary files a/docs/BANNERMOD_BACKLOG.sqlite and b/docs/BANNERMOD_BACKLOG.sqlite differ diff --git a/src/main/java/com/talhanation/bannermod/bootstrap/BannerModMain.java b/src/main/java/com/talhanation/bannermod/bootstrap/BannerModMain.java index 45b761b5..c709f666 100644 --- a/src/main/java/com/talhanation/bannermod/bootstrap/BannerModMain.java +++ b/src/main/java/com/talhanation/bannermod/bootstrap/BannerModMain.java @@ -154,6 +154,7 @@ private void setup(final FMLCommonSetupEvent event) { NeoForge.EVENT_BUS.register(new WarRevoltAutoResolver()); NeoForge.EVENT_BUS.register(new WarStateBroadcaster()); NeoForge.EVENT_BUS.register(new com.talhanation.bannermod.war.events.WarOccupationTaxTicker()); + NeoForge.EVENT_BUS.register(new com.talhanation.bannermod.war.events.TreatyPaymentTicker()); NeoForge.EVENT_BUS.register(new com.talhanation.bannermod.war.events.WarRetentionSweeper()); NeoForge.EVENT_BUS.register(new SettlementMutationRefreshEvents()); NeoForge.EVENT_BUS.register(new SettlementWorkOrderClaimReleaseEvents()); diff --git a/src/main/java/com/talhanation/bannermod/client/military/render/RecruitCrowdRenderEvents.java b/src/main/java/com/talhanation/bannermod/client/military/render/RecruitCrowdRenderEvents.java index 6f5237f4..f95acd58 100644 --- a/src/main/java/com/talhanation/bannermod/client/military/render/RecruitCrowdRenderEvents.java +++ b/src/main/java/com/talhanation/bannermod/client/military/render/RecruitCrowdRenderEvents.java @@ -3,7 +3,6 @@ import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.VertexConsumer; import com.talhanation.bannermod.bootstrap.BannerModMain; -import com.talhanation.bannermod.config.RecruitsClientConfig; import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; import com.talhanation.bannermod.util.RuntimeProfilingCounters; import net.minecraft.client.Camera; @@ -13,10 +12,12 @@ import net.minecraft.client.renderer.RenderType; import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.core.BlockPos; -import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; import net.minecraft.world.phys.AABB; import net.minecraft.world.phys.Vec3; import net.neoforged.api.distmarker.Dist; +import net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent; import net.neoforged.neoforge.client.event.RenderLevelStageEvent; import net.neoforged.neoforge.client.event.RenderLivingEvent; import net.neoforged.bus.api.SubscribeEvent; @@ -30,21 +31,41 @@ @EventBusSubscriber(modid = BannerModMain.MOD_ID, value = Dist.CLIENT) public final class RecruitCrowdRenderEvents { private static final double IMPOSTOR_QUERY_RADIUS = 96.0D; + private static final double IMPOSTOR_QUERY_RADIUS_SQR = IMPOSTOR_QUERY_RADIUS * IMPOSTOR_QUERY_RADIUS; + private static final double IMPOSTOR_QUERY_CACHE_CAMERA_DRIFT_SQR = 16.0D; + private static final int IMPOSTOR_QUERY_CACHE_TICKS = 2; private static final float BODY_WIDTH = 0.58F; private static final float HALF_WIDTH = BODY_WIDTH * 0.5F; + private static long lastImpostorQueryTick = Long.MIN_VALUE; + private static ResourceKey lastImpostorQueryDimension; + private static Vec3 lastImpostorQueryCameraPos; + private static List cachedImpostorQuery = List.of(); + private static Set cachedImpostorQueryIds = Set.of(); + private RecruitCrowdRenderEvents() { } @SubscribeEvent public static void onRenderLivingPre(RenderLivingEvent.Pre event) { if (event.getEntity() instanceof AbstractRecruitEntity recruit - && RecruitRenderLod.shouldUseCrowdImpostor(recruit)) { + && RecruitRenderLod.shouldUseCrowdImpostor(recruit) + && isCachedImpostorCandidate(recruit)) { RuntimeProfilingCounters.increment("recruit.render.normal_skipped_for_impostor"); event.setCanceled(true); } } + @SubscribeEvent + public static void onClientLogin(ClientPlayerNetworkEvent.LoggingIn event) { + clearCachedQuery(); + } + + @SubscribeEvent + public static void onClientLogout(ClientPlayerNetworkEvent.LoggingOut event) { + clearCachedQuery(); + } + @SubscribeEvent public static void onRenderLevel(RenderLevelStageEvent event) { if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_ENTITIES) { @@ -57,11 +78,7 @@ public static void onRenderLevel(RenderLevelStageEvent event) { Camera camera = event.getCamera(); Vec3 cameraPos = camera.getPosition(); - List recruits = minecraft.level.getEntitiesOfClass( - AbstractRecruitEntity.class, - AABB.ofSize(cameraPos, IMPOSTOR_QUERY_RADIUS * 2.0D, IMPOSTOR_QUERY_RADIUS * 2.0D, IMPOSTOR_QUERY_RADIUS * 2.0D), - recruit -> recruit.distanceToSqr(cameraPos) <= IMPOSTOR_QUERY_RADIUS * IMPOSTOR_QUERY_RADIUS - ); + List recruits = crowdQuery(minecraft, cameraPos); if (recruits.isEmpty()) { return; } @@ -73,10 +90,18 @@ public static void onRenderLevel(RenderLevelStageEvent event) { int rendered = 0; int candidates = 0; int frustumCulled = 0; + int rangeCulled = 0; float partialTick = event.getPartialTick().getGameTimeDeltaPartialTick(false); long startNanos = System.nanoTime(); for (AbstractRecruitEntity recruit : recruits) { + if (recruit.isRemoved()) { + continue; + } + if (recruit.distanceToSqr(cameraPos) > IMPOSTOR_QUERY_RADIUS_SQR) { + rangeCulled++; + continue; + } if (!RecruitRenderLod.shouldUseCrowdImpostor(recruit)) { continue; } @@ -85,8 +110,7 @@ public static void onRenderLevel(RenderLevelStageEvent event) { frustumCulled++; continue; } - ResourceLocation texture = crowdTexture(recruit); - RenderType renderType = RenderType.entityCutoutNoCull(texture); + RenderType renderType = RecruitHumanRenderer.crowdRenderType(recruit); VertexConsumer consumer = bufferSource.getBuffer(renderType); usedRenderTypes.add(renderType); renderImpostor(recruit, partialTick, camera, cameraPos, poseStack, consumer, minecraft); @@ -106,10 +130,83 @@ public static void onRenderLevel(RenderLevelStageEvent event) { if (frustumCulled > 0) { RuntimeProfilingCounters.add("recruit.render.crowd_impostor_frustum_culled", frustumCulled); } + if (rangeCulled > 0) { + RuntimeProfilingCounters.add("recruit.render.crowd_impostor_range_culled", rangeCulled); + } + } + + private static List crowdQuery(Minecraft minecraft, Vec3 cameraPos) { + if (minecraft.level == null) { + clearCachedQuery(); + return List.of(); + } + + Level level = minecraft.level; + long gameTime = level.getGameTime(); + ResourceKey dimension = level.dimension(); + if (isCachedQueryValid(dimension, gameTime, cameraPos)) { + RuntimeProfilingCounters.increment("recruit.render.crowd_query_cache_hits"); + return cachedImpostorQuery; + } + + RuntimeProfilingCounters.increment("recruit.render.crowd_query_cache_misses"); + long startNanos = System.nanoTime(); + List recruits = level.getEntitiesOfClass( + AbstractRecruitEntity.class, + AABB.ofSize(cameraPos, IMPOSTOR_QUERY_RADIUS * 2.0D, IMPOSTOR_QUERY_RADIUS * 2.0D, IMPOSTOR_QUERY_RADIUS * 2.0D), + recruit -> recruit.distanceToSqr(cameraPos) <= IMPOSTOR_QUERY_RADIUS_SQR + ); + RuntimeProfilingCounters.add("recruit.render.crowd_query_nanos", System.nanoTime() - startNanos); + lastImpostorQueryTick = gameTime; + lastImpostorQueryDimension = dimension; + lastImpostorQueryCameraPos = cameraPos; + cachedImpostorQuery = recruits; + cachedImpostorQueryIds = queryIds(recruits); + return cachedImpostorQuery; + } + + private static boolean isCachedImpostorCandidate(AbstractRecruitEntity recruit) { + if (recruit.isRemoved()) { + return false; + } + Minecraft minecraft = Minecraft.getInstance(); + Vec3 cameraPos = cameraPosition(minecraft); + if (minecraft.level == null || cameraPos == null || recruit.distanceToSqr(cameraPos) > IMPOSTOR_QUERY_RADIUS_SQR) { + return false; + } + crowdQuery(minecraft, cameraPos); + return cachedImpostorQueryIds.contains(recruit.getId()); + } + + private static Set queryIds(List recruits) { + Set ids = new HashSet<>(); + for (AbstractRecruitEntity recruit : recruits) { + ids.add(recruit.getId()); + } + return ids; + } + + private static void clearCachedQuery() { + lastImpostorQueryTick = Long.MIN_VALUE; + lastImpostorQueryDimension = null; + lastImpostorQueryCameraPos = null; + cachedImpostorQuery = List.of(); + cachedImpostorQueryIds = Set.of(); + } + + private static Vec3 cameraPosition(Minecraft minecraft) { + if (minecraft.gameRenderer == null) { + return null; + } + return minecraft.gameRenderer.getMainCamera().getPosition(); } - private static ResourceLocation crowdTexture(AbstractRecruitEntity recruit) { - return RecruitHumanRenderer.crowdTexture(recruit); + private static boolean isCachedQueryValid(ResourceKey dimension, long gameTime, Vec3 cameraPos) { + return dimension.equals(lastImpostorQueryDimension) + && gameTime >= lastImpostorQueryTick + && gameTime - lastImpostorQueryTick < IMPOSTOR_QUERY_CACHE_TICKS + && lastImpostorQueryCameraPos != null + && lastImpostorQueryCameraPos.distanceToSqr(cameraPos) <= IMPOSTOR_QUERY_CACHE_CAMERA_DRIFT_SQR; } private static void renderImpostor(AbstractRecruitEntity recruit, diff --git a/src/main/java/com/talhanation/bannermod/client/military/render/RecruitHumanRenderer.java b/src/main/java/com/talhanation/bannermod/client/military/render/RecruitHumanRenderer.java index 606b987e..540fd1f8 100644 --- a/src/main/java/com/talhanation/bannermod/client/military/render/RecruitHumanRenderer.java +++ b/src/main/java/com/talhanation/bannermod/client/military/render/RecruitHumanRenderer.java @@ -11,6 +11,7 @@ import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; import net.minecraft.client.model.HumanoidModel; import net.minecraft.client.model.geom.ModelLayers; +import net.minecraft.client.renderer.RenderType; import net.minecraft.client.renderer.entity.EntityRendererProvider; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.InteractionHand; @@ -22,6 +23,11 @@ public class RecruitHumanRenderer extends AbstractRecruitRenderer debug() { .executes(AdminDebugCommands::claimMineSites))) .then(Commands.literal("contracts") .then(Commands.argument("claimUuid", StringArgumentType.word()) - .executes(AdminDebugCommands::claimContracts)))) + .executes(AdminDebugCommands::claimContracts))) + .then(Commands.literal("objectives") + .then(Commands.literal("mine-dispute") + .then(Commands.argument("claimUuid", StringArgumentType.word()) + .then(Commands.argument("mineSiteId", StringArgumentType.word()) + .then(Commands.argument("durationTicks", LongArgumentType.longArg(0L)) + .executes(AdminDebugCommands::createMineDisputeObjective))))) + .then(Commands.literal("blockade") + .then(Commands.argument("targetKind", StringArgumentType.word()) + .then(Commands.argument("claimUuid", StringArgumentType.word()) + .then(Commands.argument("objectId", StringArgumentType.word()) + .then(Commands.argument("durationTicks", LongArgumentType.longArg(0L)) + .executes(AdminDebugCommands::createBlockadeObjective)))))) + .then(Commands.literal("resolve") + .then(Commands.argument("objectiveId", StringArgumentType.word()) + .executes(AdminDebugCommands::resolveEconomicObjective))) + .then(Commands.literal("prune") + .executes(AdminDebugCommands::pruneEconomicObjectives)))) .then(Commands.literal("save-versions") .executes(AdminDebugCommands::saveVersions)); } @@ -207,12 +230,15 @@ private static int claimEconomySummary(CommandContext contex List mineSites = claim == null ? List.of() : StrategicMineSiteService.derive(level, claim, snapshot, validatedBuildings); + long gameTime = level.getGameTime(); ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( snapshot, validatedBuildings, BannerModTreasuryManager.get(level).getLedger(claimUuid), mineSites, - claim == null ? FortLevelDefinition.MIN_LEVEL : claim.getFortLevel() + claim == null ? FortLevelDefinition.MIN_LEVEL : claim.getFortLevel(), + WarRuntimeContext.economicObjectives(level).activeForClaim(claimUuid, gameTime), + gameTime ); for (String line : summary.debugLines()) { context.getSource().sendSuccess(() -> Component.literal(line), false); @@ -275,6 +301,96 @@ private static int claimContracts(CommandContext context) th return Math.max(0, lines.size() - 1); } + private static int createMineDisputeObjective(CommandContext context) throws CommandSyntaxException { + ServerLevel level = serverLevel(context.getSource()); + UUID claimUuid = parseUuidArgument(context, "claimUuid"); + UUID mineSiteId = parseUuidArgument(context, "mineSiteId"); + if (claimUuid == null || mineSiteId == null) { + return 0; + } + long now = level.getGameTime(); + EconomicObjectiveRecord record = WarRuntimeContext.economicObjectives(level).createMineDispute( + null, + null, + claimUuid, + mineSiteId, + now, + expiresAt(now, LongArgumentType.getLong(context, "durationTicks")) + ); + context.getSource().sendSuccess(() -> Component.literal("Created mine dispute objective " + record.id()), false); + return 1; + } + + private static int createBlockadeObjective(CommandContext context) throws CommandSyntaxException { + ServerLevel level = serverLevel(context.getSource()); + EconomicObjectiveTargetKind targetKind = targetKind(StringArgumentType.getString(context, "targetKind")); + if (targetKind == null) { + context.getSource().sendFailure(Component.literal("targetKind must be route or storage")); + return 0; + } + UUID claimUuid = parseUuidArgument(context, "claimUuid"); + UUID objectId = parseUuidArgument(context, "objectId"); + if (claimUuid == null || objectId == null) { + return 0; + } + long now = level.getGameTime(); + EconomicObjectiveRecord record = WarRuntimeContext.economicObjectives(level).createBlockade( + null, + null, + claimUuid, + targetKind, + objectId, + now, + expiresAt(now, LongArgumentType.getLong(context, "durationTicks")) + ); + context.getSource().sendSuccess(() -> Component.literal("Created blockade objective " + record.id()), false); + return 1; + } + + private static int resolveEconomicObjective(CommandContext context) throws CommandSyntaxException { + ServerLevel level = serverLevel(context.getSource()); + UUID objectiveId = parseUuidArgument(context, "objectiveId"); + if (objectiveId == null) { + return 0; + } + boolean resolved = WarRuntimeContext.economicObjectives(level).resolve(objectiveId, level.getGameTime()); + if (!resolved) { + context.getSource().sendFailure(Component.literal("No active economic objective " + objectiveId)); + return 0; + } + context.getSource().sendSuccess(() -> Component.literal("Resolved economic objective " + objectiveId), false); + return 1; + } + + private static int pruneEconomicObjectives(CommandContext context) throws CommandSyntaxException { + ServerLevel level = serverLevel(context.getSource()); + EconomicObjectiveRuntime runtime = WarRuntimeContext.economicObjectives(level); + int removed = runtime.pruneExpired(level.getGameTime()); + context.getSource().sendSuccess(() -> Component.literal("Pruned " + removed + " expired economic objectives"), false); + return removed; + } + + private static EconomicObjectiveTargetKind targetKind(String value) { + return switch ((value == null ? "" : value).toLowerCase(Locale.ROOT)) { + case "route" -> EconomicObjectiveTargetKind.ROUTE; + case "storage" -> EconomicObjectiveTargetKind.STORAGE; + default -> null; + }; + } + + private static UUID parseUuidArgument(CommandContext context, String argumentName) { + try { + return UUID.fromString(StringArgumentType.getString(context, argumentName)); + } catch (IllegalArgumentException exception) { + context.getSource().sendFailure(Component.literal(argumentName + " must be a UUID")); + return null; + } + } + + private static long expiresAt(long now, long durationTicks) { + return durationTicks <= 0L ? 0L : now + durationTicks; + } + private static Integer currentVersion(Class savedDataClass) { try { Field field = savedDataClass.getDeclaredField("CURRENT_VERSION"); diff --git a/src/main/java/com/talhanation/bannermod/commands/war/EconomyStatusCommands.java b/src/main/java/com/talhanation/bannermod/commands/war/EconomyStatusCommands.java index 0abc497a..6dffd693 100644 --- a/src/main/java/com/talhanation/bannermod/commands/war/EconomyStatusCommands.java +++ b/src/main/java/com/talhanation/bannermod/commands/war/EconomyStatusCommands.java @@ -5,12 +5,26 @@ import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.exceptions.CommandSyntaxException; import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import com.talhanation.bannermod.events.ClaimEvents; +import com.talhanation.bannermod.governance.BannerModTreasuryManager; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.persistence.military.RecruitsClaimManager; import com.talhanation.bannermod.settlement.SettlementOrchestrator; +import com.talhanation.bannermod.settlement.economy.FortUpgradeService; import com.talhanation.bannermod.settlement.economy.NpcDemandContractService; +import com.talhanation.bannermod.settlement.economy.StrategicResourceAccountingManager; +import com.talhanation.bannermod.war.WarRuntimeContext; +import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; +import com.talhanation.bannermod.war.runtime.TreatyDefaultFact; +import com.talhanation.bannermod.war.runtime.TreatyRuntime; +import com.talhanation.bannermod.war.runtime.TributeTreatyRecord; +import com.talhanation.bannermod.war.runtime.VassalRelationshipRecord; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; import java.util.List; import java.util.UUID; @@ -27,16 +41,22 @@ public static LiteralArgumentBuilder build() { return Commands.literal("economy") .then(Commands.literal("contracts") .then(Commands.argument("claimUuid", StringArgumentType.word()) - .executes(EconomyStatusCommands::contracts))); + .executes(EconomyStatusCommands::contracts) + .then(Commands.literal("complete") + .requires(source -> source.hasPermission(2)) + .then(Commands.argument("contractUuid", StringArgumentType.word()) + .executes(EconomyStatusCommands::completeContract))))) + .then(Commands.literal("upgradeFort") + .then(Commands.argument("claimUuid", StringArgumentType.word()) + .executes(EconomyStatusCommands::upgradeFort))) + .then(Commands.literal("treaties") + .executes(EconomyStatusCommands::treaties)); } 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")); + ServerLevel level = overworldServerLevel(context.getSource()); + UUID claimUuid = parseUuid(context, "claimUuid"); + if (claimUuid == null) { return 0; } NpcDemandContractService service = SettlementOrchestrator.npcDemandContractService(level); @@ -51,10 +71,180 @@ private static int contracts(CommandContext context) throws return Math.max(0, lines.size() - 1); } + private static int upgradeFort(CommandContext context) throws CommandSyntaxException { + ServerLevel level = serverLevel(context.getSource()); + ServerPlayer player = context.getSource().getPlayerOrException(); + if (level.dimension() != Level.OVERWORLD) { + context.getSource().sendFailure(Component.translatable("chat.bannermod.fort.upgrade.denied.overworld_only")); + return 0; + } + UUID claimUuid = parseUuid(context, "claimUuid"); + if (claimUuid == null) { + return 0; + } + RecruitsClaimManager claimManager = ClaimEvents.claimManager(); + if (claimManager == null) { + context.getSource().sendFailure(Component.translatable("chat.bannermod.fort.upgrade.denied.missing_claim")); + return 0; + } + RecruitsClaim claim = claimManager.getAllClaims().stream() + .filter(candidate -> candidate.getUUID().equals(claimUuid)) + .findFirst() + .orElse(null); + PoliticalEntityRecord politicalOwner = claim != null && claim.getOwnerPoliticalEntityId() != null + ? WarRuntimeContext.registry(level).byId(claim.getOwnerPoliticalEntityId()).orElse(null) + : null; + BannerModTreasuryManager treasuryManager = BannerModTreasuryManager.get(level); + StrategicResourceAccountingManager accountingManager = StrategicResourceAccountingManager.get(level); + FortUpgradeService.UpgradeResult result = FortUpgradeService.planUpgrade( + treasuryManager, + accountingManager, + claim, + player.getUUID(), + player.isCreative() && player.hasPermissions(2), + politicalOwner + ); + if (!result.upgraded()) { + if (result.missingBucket() != null) { + context.getSource().sendFailure(Component.translatable( + result.messageKey(), + result.missingAmount(), + Component.translatable("resource.bannermod.strategic." + result.missingBucket().id()) + )); + } else { + context.getSource().sendFailure(Component.translatable(result.messageKey())); + } + return 0; + } + + RecruitsClaim upgradedClaim = RecruitsClaim.fromNBT(claim.toNBT()); + int previousFortLevel = upgradedClaim.getFortLevel(); + boolean persisted = claimManager.addOrUpdateClaimIf(level, upgradedClaim, () -> FortUpgradeService.applyUpgrade( + treasuryManager, + accountingManager, + upgradedClaim, + result, + level.getGameTime() + ), () -> FortUpgradeService.rollbackUpgrade( + treasuryManager, + accountingManager, + upgradedClaim, + previousFortLevel, + result, + level.getGameTime() + )); + if (!persisted) { + context.getSource().sendFailure(Component.translatable("chat.bannermod.fort.upgrade.denied.persistence_veto")); + return 0; + } + context.getSource().sendSuccess(() -> Component.translatable( + result.messageKey(), + upgradedClaim.getName(), + result.newLevel(), + Component.translatable("resource.bannermod.fort_level." + result.newLevel()) + ), true); + return result.newLevel(); + } + + private static int completeContract(CommandContext context) throws CommandSyntaxException { + ServerLevel level = overworldServerLevel(context.getSource()); + UUID claimUuid = parseUuid(context, "claimUuid"); + UUID contractUuid = parseUuid(context, "contractUuid"); + if (claimUuid == null || contractUuid == null) { + return 0; + } + NpcDemandContractService service = SettlementOrchestrator.npcDemandContractService(level); + if (service == null) { + context.getSource().sendFailure(Component.literal("NPC demand contract service unavailable")); + return 0; + } + NpcDemandContractService.CompletionResult result = service.completeContract(level, claimUuid, contractUuid, level.getGameTime()); + if (!result.success()) { + context.getSource().sendFailure(Component.literal("Contract completion failed: " + result.reason())); + return 0; + } + context.getSource().sendSuccess( + () -> Component.literal("Completed contract " + contractUuid + + ": delivered " + result.deliveredAmount() + " " + result.deliveredBucket().id() + + ", reward " + result.rewardCoins() + " coins"), + true + ); + return 1; + } + + private static UUID parseUuid(CommandContext context, String argumentName) { + try { + return UUID.fromString(StringArgumentType.getString(context, argumentName)); + } catch (IllegalArgumentException exception) { + if ("claimUuid".equals(argumentName)) { + context.getSource().sendFailure(Component.translatable("chat.bannermod.economy.denied.invalid_claim_uuid")); + } else { + context.getSource().sendFailure(Component.literal(argumentName + " must be a UUID")); + } + return null; + } + } + private static ServerLevel serverLevel(CommandSourceStack source) throws CommandSyntaxException { if (source.getLevel().isClientSide()) { throw SERVER_ONLY.create(); } return source.getLevel(); } + + private static int treaties(CommandContext context) throws CommandSyntaxException { + CommandSourceStack source = context.getSource(); + ServerLevel level = overworldServerLevel(source); + TreatyRuntime runtime = WarRuntimeContext.treaties(level); + int lines = 0; + source.sendSuccess(() -> Component.literal("Tribute treaties:"), false); + for (TributeTreatyRecord treaty : runtime.tributeTreaties()) { + lines++; + source.sendSuccess(() -> Component.literal( + "- " + treaty.id() + + " payer=" + treaty.payerEntityId() + + " receiver=" + treaty.receiverEntityId() + + " resource=" + treaty.resourceBucket().id() + + " amount=" + treaty.amount() + + " intervalTicks=" + treaty.intervalTicks() + + " sourceClaim=" + treaty.sourceClaimUuid() + + " missed=" + treaty.missedPayments() + + " defaulted=" + treaty.defaultedAmount() + + " active=" + treaty.active()), false); + } + source.sendSuccess(() -> Component.literal("Vassal relationships:"), false); + for (VassalRelationshipRecord relationship : runtime.vassalRelationships()) { + lines++; + source.sendSuccess(() -> Component.literal( + "- " + relationship.id() + + " overlord=" + relationship.overlordEntityId() + + " vassal=" + relationship.vassalEntityId() + + " obligations=" + relationship.obligations() + + " tributeAmount=" + relationship.tributeAmount() + + " active=" + relationship.active()), false); + } + source.sendSuccess(() -> Component.literal("Treaty-default facts:"), false); + for (TreatyDefaultFact fact : runtime.defaultFacts()) { + lines++; + source.sendSuccess(() -> Component.literal( + "- " + fact.id() + + " treaty=" + fact.treatyId() + + " payer=" + fact.payerEntityId() + + " receiver=" + fact.receiverEntityId() + + " type=" + fact.defaultType() + + " requested=" + fact.requestedAmount() + + " paid=" + fact.paidAmount() + + " defaulted=" + fact.defaultedAmount() + + " tick=" + fact.gameTime()), false); + } + if (lines == 0) { + source.sendSuccess(() -> Component.literal("No active treaty records or defaults."), false); + } + return lines; + } + + private static ServerLevel overworldServerLevel(CommandSourceStack source) throws CommandSyntaxException { + serverLevel(source); + return source.getServer().overworld(); + } } diff --git a/src/main/java/com/talhanation/bannermod/governance/BannerModGovernorHeartbeat.java b/src/main/java/com/talhanation/bannermod/governance/BannerModGovernorHeartbeat.java index b91b170f..2ac71f46 100644 --- a/src/main/java/com/talhanation/bannermod/governance/BannerModGovernorHeartbeat.java +++ b/src/main/java/com/talhanation/bannermod/governance/BannerModGovernorHeartbeat.java @@ -8,13 +8,22 @@ import com.talhanation.bannermod.persistence.military.RecruitsClaimManager; import com.talhanation.bannermod.entity.civilian.AbstractWorkerEntity; import com.talhanation.bannermod.entity.civilian.WorkerIndex; +import com.talhanation.bannermod.entity.military.AbstractLeaderEntity; +import com.talhanation.bannermod.entity.military.HorsemanEntity; +import com.talhanation.bannermod.entity.military.IRangedRecruit; +import com.talhanation.bannermod.entity.military.RecruitShieldmanEntity; +import com.talhanation.bannermod.settlement.economy.StrategicResourceAccountingManager; +import com.talhanation.bannermod.settlement.economy.StrategicResourceAccountSnapshot; +import com.talhanation.bannermod.settlement.economy.StrategicResourceBucket; import com.talhanation.bannermod.settlement.runtime.SettlementTreasuryDerivationService; import com.talhanation.bannermod.util.RuntimeProfilingCounters; import com.talhanation.bannermod.war.runtime.WarSiegeQueries; +import net.minecraft.core.BlockPos; import net.minecraft.world.Container; import net.minecraft.world.entity.Entity; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.npc.Villager; +import net.minecraft.world.item.ShieldItem; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.phys.AABB; @@ -29,6 +38,8 @@ public final class BannerModGovernorHeartbeat { private static final int TAX_PER_CITIZEN = 2; + private static final int BASE_RECRUIT_FOOD_UPKEEP = 1; + private static final int BASE_RECRUIT_COIN_UPKEEP = 1; private BannerModGovernorHeartbeat() { } @@ -114,14 +125,22 @@ public static void runGovernedClaimHeartbeat(ServerLevel level, RecruitsClaimMan if (level == null) { return; } - runGovernedClaimHeartbeat(level, claimManager, governorManager, BannerModTreasuryManager.get(level)); + runGovernedClaimHeartbeat(level, claimManager, governorManager, BannerModTreasuryManager.get(level), StrategicResourceAccountingManager.get(level)); } public static void runGovernedClaimHeartbeat(ServerLevel level, RecruitsClaimManager claimManager, BannerModGovernorManager governorManager, @Nullable BannerModTreasuryManager treasuryManager) { - runGovernedClaimHeartbeatBatch(level, claimManager, governorManager, treasuryManager, 0, Integer.MAX_VALUE); + runGovernedClaimHeartbeat(level, claimManager, governorManager, treasuryManager, level == null ? null : StrategicResourceAccountingManager.get(level)); + } + + public static void runGovernedClaimHeartbeat(ServerLevel level, + RecruitsClaimManager claimManager, + BannerModGovernorManager governorManager, + @Nullable BannerModTreasuryManager treasuryManager, + @Nullable StrategicResourceAccountingManager resourceAccountingManager) { + runGovernedClaimHeartbeatBatch(level, claimManager, governorManager, treasuryManager, resourceAccountingManager, 0, Integer.MAX_VALUE); } public static BatchResult runGovernedClaimHeartbeatBatch(ServerLevel level, @@ -130,6 +149,16 @@ public static BatchResult runGovernedClaimHeartbeatBatch(ServerLevel level, @Nullable BannerModTreasuryManager treasuryManager, int startIndex, int maxSnapshots) { + return runGovernedClaimHeartbeatBatch(level, claimManager, governorManager, treasuryManager, level == null ? null : StrategicResourceAccountingManager.get(level), startIndex, maxSnapshots); + } + + public static BatchResult runGovernedClaimHeartbeatBatch(ServerLevel level, + RecruitsClaimManager claimManager, + BannerModGovernorManager governorManager, + @Nullable BannerModTreasuryManager treasuryManager, + @Nullable StrategicResourceAccountingManager resourceAccountingManager, + int startIndex, + int maxSnapshots) { if (level == null || claimManager == null || governorManager == null) { return BatchResult.completedResult(); } @@ -174,11 +203,11 @@ public static BatchResult runGovernedClaimHeartbeatBatch(ServerLevel level, RecruitsClaim claim = resolveClaim(claimManager, snapshot); List workers = claim == null ? List.of() : workersInClaim(level, claim); List recruits = claim == null ? List.of() : recruitsInClaim(level, claim); - BannerModSupplyStatus.WorkerSupplyStatus workerSupplyStatus = summarizeWorkerSupplyStatus(workers); - BannerModSupplyStatus.RecruitSupplyStatus recruitSupplyStatus = summarizeRecruitSupplyStatus(level, recruits); BannerModSettlementBinding.Binding binding = claim == null ? BannerModSettlementBinding.resolveSettlementStatus((RecruitsClaim) null, snapshot.anchorChunk(), snapshot.settlementFactionId()) : BannerModSettlementBinding.resolveSettlementStatus(claim, resolveAnchorChunk(claim, snapshot), snapshot.settlementFactionId()); + BannerModSupplyStatus.WorkerSupplyStatus workerSupplyStatus = summarizeWorkerSupplyStatus(workers); + BannerModSupplyStatus.RecruitSupplyStatus recruitSupplyStatus = summarizeRecruitSupplyStatus(level, recruits); HeartbeatReport report = evaluate(new HeartbeatInput( binding.status(), @@ -192,13 +221,39 @@ public static BatchResult runGovernedClaimHeartbeatBatch(ServerLevel level, snapshot.lastHeartbeatTick(), snapshot )); + ArmyUpkeepDemand armyUpkeepDemand = armyUpkeepDemand(level, claimManager, snapshot.claimUuid(), RecruitIndex.instance().all(level, true)); + BannerModSupplyStatus.RecruitSupplyStatus economicRecruitSupplyStatus = applyArmyUpkeepPressure( + treasuryManager, + resourceAccountingManager, + snapshot, + recruitSupplyStatus, + armyUpkeepDemand, + report.taxesCollected(), + report.heartbeatTick() + ); + if (economicRecruitSupplyStatus != recruitSupplyStatus) { + recruitSupplyStatus = economicRecruitSupplyStatus; + report = evaluate(new HeartbeatInput( + binding.status(), + claim != null && WarSiegeQueries.isClaimUnderSiege(level, claim), + claim == null ? 0 : countEntitiesInClaim(level, claim, Villager.class), + workers.size(), + recruits.size(), + workerSupplyStatus, + recruitSupplyStatus, + level.getGameTime(), + snapshot.lastHeartbeatTick(), + snapshot + )); + } BannerModTreasuryLedgerSnapshot.FiscalRollup fiscalRollup = SettlementTreasuryDerivationService.deriveHeartbeatAccounting( treasuryManager, snapshot, binding, report, - recruitSupplyStatus + recruitSupplyStatus, + armyUpkeepDemand.coins() ); governorManager.putSnapshot(snapshot.withHeartbeatReport( @@ -283,6 +338,140 @@ private static BannerModSupplyStatus.RecruitSupplyStatus summarizeRecruitSupplyS return new BannerModSupplyStatus.RecruitSupplyStatus(state, blocked, needsFood, needsPayment, reasonToken, accounting); } + static BannerModSupplyStatus.RecruitSupplyStatus applyArmyUpkeepPressure(@Nullable BannerModTreasuryManager treasuryManager, + @Nullable StrategicResourceAccountingManager resourceAccountingManager, + BannerModGovernorSnapshot snapshot, + BannerModSupplyStatus.RecruitSupplyStatus currentStatus, + ArmyUpkeepDemand demand, + int taxesCollected, + long tick) { + if (snapshot == null || currentStatus == null || demand == null || (demand.food() <= 0 && demand.coins() <= 0)) { + return currentStatus; + } + StrategicResourceAccountSnapshot account = resourceAccountingManager == null ? null : resourceAccountingManager.getAccount(snapshot.claimUuid()); + int availableFood = account == null ? 0 : account.balance(StrategicResourceBucket.FOOD); + if (account != null && demand.food() > 0 && availableFood >= demand.food()) { + resourceAccountingManager.debit(snapshot.claimUuid(), StrategicResourceBucket.FOOD, demand.food(), tick); + } + int missingFood = account == null ? 0 : Math.max(0, demand.food() - availableFood); + BannerModTreasuryLedgerSnapshot ledger = treasuryManager == null ? null : treasuryManager.getLedger(snapshot.claimUuid()); + int availableCoins = Math.max(0, taxesCollected) + (ledger == null ? 0 : ledger.treasuryBalance()); + int missingCoins = Math.max(0, demand.coins() - availableCoins); + if (missingFood <= 0 && missingCoins <= 0) { + return currentStatus; + } + + BannerModSupplyStatus.ArmyUpkeepStatus currentAccounting = currentStatus.accounting(); + int unpaidLevel = Math.max(missingCoins, currentAccounting == null ? 0 : Math.max(0, currentAccounting.unpaidLevel())); + int starvingLevel = Math.max(missingFood, currentAccounting == null ? 0 : Math.max(0, currentAccounting.starvingLevel())); + boolean unpaid = missingCoins > 0 || (currentAccounting != null && currentAccounting.unpaid()); + boolean starving = missingFood > 0 || (currentAccounting != null && currentAccounting.starving()); + BannerModSupplyStatus.ArmyUpkeepState state = unpaid && starving + ? BannerModSupplyStatus.ArmyUpkeepState.UNPAID_AND_STARVING + : unpaid + ? BannerModSupplyStatus.ArmyUpkeepState.UNPAID + : BannerModSupplyStatus.ArmyUpkeepState.STARVING; + String reasonToken = unpaid && starving ? "army_upkeep_unpaid_and_starving" : unpaid ? "army_upkeep_unpaid" : "army_upkeep_starving"; + BannerModSupplyStatus.ArmyUpkeepStatus accounting = new BannerModSupplyStatus.ArmyUpkeepStatus( + state, + unpaid, + starving, + unpaidLevel, + starvingLevel, + reasonToken + ); + boolean needsPayment = currentStatus.needsPayment() || missingCoins > 0; + boolean needsFood = currentStatus.needsFood() || missingFood > 0; + BannerModSupplyStatus.RecruitSupplyState supplyState = needsFood && needsPayment + ? BannerModSupplyStatus.RecruitSupplyState.NEEDS_FOOD_AND_PAYMENT + : needsPayment + ? BannerModSupplyStatus.RecruitSupplyState.NEEDS_PAYMENT + : BannerModSupplyStatus.RecruitSupplyState.NEEDS_FOOD; + return new BannerModSupplyStatus.RecruitSupplyStatus( + supplyState, + true, + needsFood, + needsPayment, + currentStatus.reasonToken() == null ? shortageReasonToken(missingFood, missingCoins) : currentStatus.reasonToken(), + accounting + ); + } + + private static String shortageReasonToken(int missingFood, int missingCoins) { + if (missingFood > 0 && missingCoins > 0) { + return "recruit_upkeep_missing_food_and_payment"; + } + return missingCoins > 0 ? "recruit_upkeep_missing_payment" : "recruit_upkeep_missing_food"; + } + + private static ArmyUpkeepDemand armyUpkeepDemand(ServerLevel level, + RecruitsClaimManager claimManager, + UUID governedClaimUuid, + List recruits) { + int food = 0; + int coins = 0; + for (AbstractRecruitEntity recruit : recruits) { + if (recruit == null || !recruit.isAlive() || !recruit.isOwned() || !recruitBelongsToUpkeepClaim(level, claimManager, governedClaimUuid, recruit)) { + continue; + } + ArmyUpkeepDemand cost = armyUpkeepCost(recruit); + food += cost.food(); + coins += cost.coins(); + } + return new ArmyUpkeepDemand(food, coins); + } + + private static boolean recruitBelongsToUpkeepClaim(ServerLevel level, + RecruitsClaimManager claimManager, + UUID governedClaimUuid, + AbstractRecruitEntity recruit) { + if (claimManager == null || governedClaimUuid == null || recruit == null) { + return false; + } + if (upkeepPosBelongsToClaim(claimManager, governedClaimUuid, recruit.getUpkeepPos())) { + return true; + } + UUID upkeepUuid = recruit.getUpkeepUUID(); + if (level == null || upkeepUuid == null) { + return false; + } + Entity upkeepEntity = level.getEntity(upkeepUuid); + if (upkeepEntity instanceof AbstractRecruitEntity upkeepRecruit + && upkeepPosBelongsToClaim(claimManager, governedClaimUuid, upkeepRecruit.getUpkeepPos())) { + return true; + } + return false; + } + + static boolean upkeepPosBelongsToClaim(RecruitsClaimManager claimManager, UUID governedClaimUuid, @Nullable BlockPos upkeepPos) { + if (claimManager == null || governedClaimUuid == null || upkeepPos == null) { + return false; + } + RecruitsClaim claim = claimManager.getClaim(new ChunkPos(upkeepPos)); + return claim != null && governedClaimUuid.equals(claim.getUUID()); + } + + private static ArmyUpkeepDemand armyUpkeepCost(AbstractRecruitEntity recruit) { + int food = BASE_RECRUIT_FOOD_UPKEEP; + int coins = BASE_RECRUIT_COIN_UPKEEP; + if (recruit instanceof IRangedRecruit) { + coins++; + } + if (recruit instanceof RecruitShieldmanEntity || recruit instanceof HorsemanEntity || recruit instanceof AbstractLeaderEntity) { + food++; + coins++; + } + if (hasShieldEquipment(recruit)) { + coins++; + } + return new ArmyUpkeepDemand(food, coins); + } + + private static boolean hasShieldEquipment(AbstractRecruitEntity recruit) { + return recruit.getMainHandItem().getItem() instanceof ShieldItem + || recruit.getOffhandItem().getItem() instanceof ShieldItem; + } + @Nullable private static Container resolveRecruitUpkeepContainer(ServerLevel level, AbstractRecruitEntity recruit) { if (recruit.getUpkeepPos() != null) { @@ -355,6 +544,13 @@ private static BatchResult completedResult() { } } + record ArmyUpkeepDemand(int food, int coins) { + ArmyUpkeepDemand { + food = Math.max(0, food); + coins = Math.max(0, coins); + } + } + private static RecruitsClaim resolveClaim(RecruitsClaimManager claimManager, BannerModGovernorSnapshot snapshot) { RecruitsClaim claim = claimManager.getClaim(snapshot.anchorChunk()); if (claim != null && claim.getUUID().equals(snapshot.claimUuid())) { diff --git a/src/main/java/com/talhanation/bannermod/network/messages/military/MessageUpdateClaim.java b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageUpdateClaim.java index e8eb072f..c15c2152 100644 --- a/src/main/java/com/talhanation/bannermod/network/messages/military/MessageUpdateClaim.java +++ b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageUpdateClaim.java @@ -3,6 +3,7 @@ import com.talhanation.bannermod.events.ClaimEvents; import com.talhanation.bannermod.config.RecruitsServerConfig; import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.settlement.economy.FortLevelDefinition; import com.talhanation.bannermod.war.WarRuntimeContext; import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; import com.talhanation.bannermod.war.registry.PoliticalRegistryRuntime; @@ -55,6 +56,7 @@ public void executeServerSide(BannerModNetworkContext context){ resolvePoliticalOwner(sender, existingClaim))) return; if (!isAdmin && overlapsOtherClaim(updatedClaim, existingClaim)) return; + preserveServerOwnedFields(updatedClaim, existingClaim); if (existingClaim != null && !isAdmin) { updatedClaim.setOwnerPoliticalEntityId(existingClaim.getOwnerPoliticalEntityId()); updatedClaim.setPlayer(existingClaim.getPlayerInfo()); @@ -109,6 +111,12 @@ private static boolean overlapsOtherClaim(RecruitsClaim updatedClaim, RecruitsCl } return false; } + + static void preserveServerOwnedFields(RecruitsClaim updatedClaim, RecruitsClaim existingClaim) { + if (updatedClaim == null) return; + updatedClaim.setFortLevel(existingClaim != null ? existingClaim.getFortLevel() : FortLevelDefinition.MIN_LEVEL); + } + public MessageUpdateClaim fromBytes(FriendlyByteBuf buf) { this.claimNBT = buf.readNbt(); return this; diff --git a/src/main/java/com/talhanation/bannermod/persistence/military/RecruitsClaimManager.java b/src/main/java/com/talhanation/bannermod/persistence/military/RecruitsClaimManager.java index a7f918b6..9ff1cfbe 100644 --- a/src/main/java/com/talhanation/bannermod/persistence/military/RecruitsClaimManager.java +++ b/src/main/java/com/talhanation/bannermod/persistence/military/RecruitsClaimManager.java @@ -20,6 +20,8 @@ import javax.annotation.Nullable; import java.util.*; +import java.util.function.BooleanSupplier; + public class RecruitsClaimManager { private final Map claims = new HashMap<>(); @@ -52,13 +54,24 @@ void persistClaims(RecruitsClaimSaveData data) { } public void addOrUpdateClaim(ServerLevel level, RecruitsClaim claim) { - if (claim == null) return; + addOrUpdateClaimIf(level, claim, null, null); + } + + public boolean addOrUpdateClaimIf(ServerLevel level, + RecruitsClaim claim, + @Nullable BooleanSupplier beforePersist, + @Nullable Runnable rollback) { + if (claim == null) return false; + if (beforePersist != null && !beforePersist.getAsBoolean()) return false; // ClaimEvent.Updated feuern – cancelable boolean isNew = claims.values().stream().noneMatch(c -> c.getUUID().equals(claim.getUUID())); ClaimEvent.Updated updateEvent = new ClaimEvent.Updated(claim, level, isNew); NeoForge.EVENT_BUS.post(updateEvent); - if (updateEvent.isCanceled()) return; + if (updateEvent.isCanceled()) { + if (rollback != null) rollback.run(); + return false; + } claims.entrySet().removeIf(entry -> entry.getValue().getUUID().equals(claim.getUUID())); @@ -70,6 +83,7 @@ public void addOrUpdateClaim(ServerLevel level, RecruitsClaim claim) { persistClaims(RecruitsClaimSaveData.get(level)); this.broadcastClaimsToAll(level); + return true; } public void removeClaim(RecruitsClaim claim) { 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 b7f71d73..ab172213 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummary.java @@ -1,5 +1,7 @@ package com.talhanation.bannermod.settlement.economy; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveState; + import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -12,17 +14,31 @@ public record ClaimStrategicEconomySummary( List resources, boolean shortage, boolean degraded, - boolean unknown + boolean unknown, + EconomicObjectiveState objectiveState ) { public ClaimStrategicEconomySummary { fortLevel = fortLevel == null ? FortLevelDefinition.forLevel(FortLevelDefinition.MIN_LEVEL) : fortLevel; resources = List.copyOf(resources == null ? List.of() : resources); + objectiveState = objectiveState == null ? EconomicObjectiveState.NORMAL : objectiveState; + } + + public ClaimStrategicEconomySummary(UUID claimUuid, + int anchorChunkX, + int anchorChunkZ, + FortLevelDefinition fortLevel, + List resources, + boolean shortage, + boolean degraded, + boolean unknown) { + this(claimUuid, anchorChunkX, anchorChunkZ, fortLevel, resources, shortage, degraded, unknown, EconomicObjectiveState.NORMAL); } 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); + + " shortage=" + this.shortage + " degraded=" + this.degraded + " unknown=" + this.unknown + + " objective=" + this.objectiveState); lines.add("Fort level " + this.fortLevel.level() + " " + this.fortLevel.name() + " caps workers=" + this.fortLevel.workerCap() + " soldiers=" + this.fortLevel.soldierCap() @@ -40,7 +56,8 @@ public List debugLines() { + " consumption=" + resource.consumptionHint() + " shortage=" + resource.shortage() + " degraded=" + resource.degraded() - + " unknown=" + resource.unknown()); + + " unknown=" + resource.unknown() + + " objective=" + resource.objectiveState()); } return lines; } @@ -53,7 +70,8 @@ public record ResourceLine( int consumptionHint, boolean shortage, boolean degraded, - boolean unknown + boolean unknown, + EconomicObjectiveState objectiveState ) { public ResourceLine { resourceId = resourceId == null ? "" : resourceId; @@ -61,6 +79,18 @@ public record ResourceLine( capacityHint = Math.max(0, capacityHint); productionHint = Math.max(0, productionHint); consumptionHint = Math.max(0, consumptionHint); + objectiveState = objectiveState == null ? EconomicObjectiveState.NORMAL : objectiveState; + } + + public ResourceLine(String resourceId, + int stockpileHint, + int capacityHint, + int productionHint, + int consumptionHint, + boolean shortage, + boolean degraded, + boolean unknown) { + this(resourceId, stockpileHint, capacityHint, productionHint, consumptionHint, shortage, degraded, unknown, EconomicObjectiveState.NORMAL); } } } 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 ff5cc562..d299a314 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryService.java @@ -9,6 +9,9 @@ import com.talhanation.bannermod.settlement.building.BuildingType; import com.talhanation.bannermod.settlement.building.BuildingValidationState; import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveRecord; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveState; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveTargetKind; import javax.annotation.Nullable; import java.util.EnumMap; @@ -50,7 +53,17 @@ public static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, @Nullable List mineSites, int fortLevel) { - return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, true, fortLevel); + return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, true, fortLevel, List.of(), 0L); + } + + public static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, + List validatedBuildings, + @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, + @Nullable List mineSites, + int fortLevel, + @Nullable List objectives, + long gameTime) { + return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, true, fortLevel, objectives, gameTime); } private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, @@ -58,7 +71,7 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, @Nullable List mineSites, boolean strategicMineSitesProvided) { - return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, strategicMineSitesProvided, FortLevelDefinition.MIN_LEVEL); + return derive(snapshot, validatedBuildings, treasuryLedger, mineSites, strategicMineSitesProvided, FortLevelDefinition.MIN_LEVEL, List.of(), 0L); } private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, @@ -66,7 +79,9 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, @Nullable BannerModTreasuryLedgerSnapshot treasuryLedger, @Nullable List mineSites, boolean strategicMineSitesProvided, - int fortLevel) { + int fortLevel, + @Nullable List objectives, + long gameTime) { Map resources = new EnumMap<>(ResourceKey.class); for (ResourceKey resource : RESOURCES) { resources.put(resource, new MutableResource()); @@ -74,6 +89,7 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, List safeValidatedBuildings = validatedBuildings == null ? List.of() : validatedBuildings; List safeMineSites = mineSites == null ? List.of() : mineSites; + List safeObjectives = objectives == null ? List.of() : objectives; Set validBuildingIds = new HashSet<>(); Set invalidBuildingIds = new HashSet<>(); for (ValidatedBuildingRecord record : safeValidatedBuildings) { @@ -92,10 +108,12 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, applyTreasury(resources, treasuryLedger); applyBuildings(resources, snapshot, safeValidatedBuildings, validBuildingIds, invalidBuildingIds, strategicMineSitesProvided); applyMines(resources, snapshot, safeMineSites, mineMaintenance(resources, snapshot)); + applyObjectives(resources, snapshot, safeMineSites, safeObjectives, gameTime); boolean anyShortage = false; boolean anyDegraded = false; boolean anyUnknown = false; + EconomicObjectiveState objectiveState = aggregateObjectiveState(snapshot, safeObjectives, gameTime); List lines = RESOURCES.stream() .map(resource -> { MutableResource mutable = resources.get(resource); @@ -108,7 +126,8 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, mutable.consumptionHint, shortage, mutable.degraded, - mutable.unknown + mutable.unknown, + mutable.objectiveState ); }) .toList(); @@ -117,6 +136,13 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, anyDegraded |= line.degraded(); anyUnknown |= line.unknown(); } + for (StrategicMineSite site : safeMineSites) { + if (site != null && site.claimUuid().equals(snapshot.claimUuid()) && site.ownerPoliticalEntityId() != null + && resourceForMineCategory(site.resourceCategory()) == null) { + anyDegraded |= site.degraded(); + anyUnknown |= site.unknown(); + } + } return new ClaimStrategicEconomySummary( snapshot.claimUuid(), @@ -126,7 +152,8 @@ private static ClaimStrategicEconomySummary derive(SettlementSnapshot snapshot, lines, anyShortage, anyDegraded, - anyUnknown + anyUnknown, + objectiveState ); } @@ -264,11 +291,6 @@ private static void applyMines(Map resources, } 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; } @@ -301,6 +323,60 @@ private static MineMaintenance mineMaintenance(Map return new MineMaintenance(missingFood || missingWood || missingTools, disruptionUnits); } + private static void applyObjectives(Map resources, + SettlementSnapshot snapshot, + List mineSites, + List objectives, + long gameTime) { + for (EconomicObjectiveRecord objective : objectives) { + if (objective == null || !snapshot.claimUuid().equals(objective.claimUuid()) || !objective.isActiveAt(gameTime)) { + continue; + } + EconomicObjectiveState state = objective.economyStateAt(gameTime); + if (objective.targetKind() == EconomicObjectiveTargetKind.MINE) { + applyMineObjective(resources, mineSites, objective, state); + } else if (objective.targetKind() == EconomicObjectiveTargetKind.ROUTE + || objective.targetKind() == EconomicObjectiveTargetKind.STORAGE) { + for (MutableResource resource : resources.values()) { + resource.objectiveState = resource.objectiveState.merge(state); + } + } + } + } + + private static EconomicObjectiveState aggregateObjectiveState(SettlementSnapshot snapshot, + List objectives, + long gameTime) { + EconomicObjectiveState state = EconomicObjectiveState.NORMAL; + for (EconomicObjectiveRecord objective : objectives) { + if (objective != null && snapshot.claimUuid().equals(objective.claimUuid()) && objective.isActiveAt(gameTime)) { + state = state.merge(objective.economyStateAt(gameTime)); + } + } + return state; + } + + private static void applyMineObjective(Map resources, + List mineSites, + EconomicObjectiveRecord objective, + EconomicObjectiveState state) { + UUID siteId = objective.strategicObjectId(); + if (siteId == null) { + return; + } + for (StrategicMineSite site : mineSites) { + if (site == null || !siteId.equals(site.siteId())) { + continue; + } + ResourceKey resource = resourceForMineCategory(site.resourceCategory()); + if (resource != null) { + MutableResource mutable = resources.get(resource); + mutable.objectiveState = mutable.objectiveState.merge(state); + } + return; + } + } + 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")) { @@ -410,6 +486,7 @@ private static final class MutableResource { private boolean shortage; private boolean degraded; private boolean unknown; + private EconomicObjectiveState objectiveState = EconomicObjectiveState.NORMAL; } private record BuildingContribution(@Nullable ResourceKey resource, diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/FortUpgradeService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/FortUpgradeService.java new file mode 100644 index 00000000..947de966 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/FortUpgradeService.java @@ -0,0 +1,257 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.governance.BannerModTreasuryLedgerSnapshot; +import com.talhanation.bannermod.governance.BannerModTreasuryManager; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.war.registry.PoliticalEntityAuthority; +import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public final class FortUpgradeService { + private FortUpgradeService() { + } + + public static UpgradeResult planUpgrade(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + RecruitsClaim claim, + UUID actorUuid, + boolean admin, + @Nullable PoliticalEntityRecord politicalOwner) { + if (claim == null) { + return UpgradeResult.denied(DenialReason.INVALID_STATE, "chat.bannermod.fort.upgrade.denied.missing_claim"); + } + if (!canUpgrade(actorUuid, admin, claim, politicalOwner)) { + return UpgradeResult.denied(DenialReason.MISSING_AUTHORITY, "chat.bannermod.fort.upgrade.denied.no_authority"); + } + + FortLevelDefinition current = FortLevelDefinition.forLevel(claim.getFortLevel()); + FortLevelDefinition.UpgradeRequirement requirement = current.nextLevelRequirement(); + if (requirement == null || current.level() >= FortLevelDefinition.MAX_LEVEL) { + return UpgradeResult.denied(DenialReason.INVALID_STATE, "chat.bannermod.fort.upgrade.denied.max_level"); + } + + MissingResource missing = firstMissingResource(treasuryManager, accountingManager, claim.getUUID(), requirement); + if (missing != null) { + return UpgradeResult.denied( + DenialReason.MISSING_RESOURCES, + "chat.bannermod.fort.upgrade.denied.missing_resources", + missing.bucket, + missing.missingAmount + ); + } + + int nextLevel = current.level() + 1; + return UpgradeResult.success(nextLevel, FortLevelDefinition.forLevel(nextLevel)); + } + + public static boolean applyUpgrade(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + RecruitsClaim claim, + UpgradeResult result, + long tick) { + if (claim == null || result == null || !result.upgraded()) { + return false; + } + FortLevelDefinition current = FortLevelDefinition.forLevel(claim.getFortLevel()); + FortLevelDefinition.UpgradeRequirement requirement = current.nextLevelRequirement(); + if (requirement == null || result.newLevel() != current.level() + 1) { + return false; + } + if (firstMissingResource(treasuryManager, accountingManager, claim.getUUID(), requirement) != null) { + return false; + } + if (!debitRequirement(treasuryManager, accountingManager, claim.getUUID(), requirement, tick)) { + return false; + } + claim.setFortLevel(result.newLevel()); + return true; + } + + public static void rollbackUpgrade(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + RecruitsClaim claim, + int previousLevel, + UpgradeResult result, + long tick) { + if (claim == null || result == null || !result.upgraded()) { + return; + } + FortLevelDefinition previous = FortLevelDefinition.forLevel(previousLevel); + if (result.newLevel() != previous.level() + 1 || previous.nextLevelRequirement() == null) { + return; + } + creditRequirement(treasuryManager, accountingManager, claim.getUUID(), previous.nextLevelRequirement(), tick); + claim.setFortLevel(previous.level()); + } + + private static boolean canUpgrade(UUID actorUuid, + boolean admin, + RecruitsClaim claim, + @Nullable PoliticalEntityRecord politicalOwner) { + if (actorUuid == null || claim == null) { + return false; + } + if (admin) { + return true; + } + if (claim.getOwnerPoliticalEntityId() != null) { + return politicalOwner != null && PoliticalEntityAuthority.canAct(actorUuid, false, politicalOwner); + } + if (claim.getPlayerInfo() != null && actorUuid.equals(claim.getPlayerInfo().getUUID())) { + return true; + } + return politicalOwner != null && PoliticalEntityAuthority.canAct(actorUuid, false, politicalOwner); + } + + @Nullable + private static MissingResource firstMissingResource(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + FortLevelDefinition.UpgradeRequirement requirement) { + MissingResource missing = missingNonCoin(accountingManager, claimUuid, StrategicResourceBucket.FOOD, requirement.food()); + if (missing != null) return missing; + missing = missingNonCoin(accountingManager, claimUuid, StrategicResourceBucket.IRON, requirement.iron()); + if (missing != null) return missing; + missing = missingNonCoin(accountingManager, claimUuid, StrategicResourceBucket.WOOD, requirement.wood()); + if (missing != null) return missing; + missing = missingNonCoin(accountingManager, claimUuid, StrategicResourceBucket.STONE, requirement.stone()); + if (missing != null) return missing; + + BannerModTreasuryLedgerSnapshot ledger = treasuryManager.getLedger(claimUuid); + int coins = ledger != null ? ledger.treasuryBalance() : 0; + if (coins < requirement.coins()) { + return new MissingResource(StrategicResourceBucket.COINS, requirement.coins() - coins); + } + return null; + } + + @Nullable + private static MissingResource missingNonCoin(StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + StrategicResourceBucket bucket, + int required) { + StrategicResourceAccountSnapshot account = accountingManager.getAccount(claimUuid); + int balance = account != null ? account.balance(bucket) : 0; + if (balance < required) { + return new MissingResource(bucket, required - balance); + } + return null; + } + + private static boolean debitRequirement(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + FortLevelDefinition.UpgradeRequirement requirement, + long tick) { + List applied = new ArrayList<>(); + if (!debitBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.FOOD, requirement.food(), tick, applied)) return false; + if (!debitBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.IRON, requirement.iron(), tick, applied)) return false; + if (!debitBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.WOOD, requirement.wood(), tick, applied)) return false; + if (!debitBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.STONE, requirement.stone(), tick, applied)) return false; + return debitBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.COINS, requirement.coins(), tick, applied); + } + + private static boolean debitBucket(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + StrategicResourceBucket bucket, + int amount, + long tick, + List applied) { + StrategicResourceAccountingService.TransactionResult result = StrategicResourceAccountingService.debit( + treasuryManager, + accountingManager, + claimUuid, + bucket, + amount, + tick + ); + if (result.status() == StrategicResourceAccountingService.Status.SUCCESS && result.appliedAmount() == amount) { + applied.add(result); + return true; + } + rollback(treasuryManager, accountingManager, claimUuid, applied, tick); + return false; + } + + private static void rollback(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + List applied, + long tick) { + for (int index = applied.size() - 1; index >= 0; index--) { + StrategicResourceAccountingService.TransactionResult result = applied.get(index); + creditBucket(treasuryManager, accountingManager, claimUuid, result.bucket(), result.appliedAmount(), tick); + } + } + + private static void creditRequirement(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + FortLevelDefinition.UpgradeRequirement requirement, + long tick) { + creditBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.FOOD, requirement.food(), tick); + creditBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.IRON, requirement.iron(), tick); + creditBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.WOOD, requirement.wood(), tick); + creditBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.STONE, requirement.stone(), tick); + creditBucket(treasuryManager, accountingManager, claimUuid, StrategicResourceBucket.COINS, requirement.coins(), tick); + } + + private static void creditBucket(BannerModTreasuryManager treasuryManager, + StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + StrategicResourceBucket bucket, + int amount, + long tick) { + if (amount <= 0) { + return; + } + if (bucket.treasuryBacked()) { + BannerModTreasuryLedgerSnapshot ledger = treasuryManager.getLedger(claimUuid); + if (ledger != null) { + treasuryManager.depositTaxes(claimUuid, ledger.anchorChunk(), ledger.settlementFactionId(), amount, tick); + } + return; + } + accountingManager.credit(claimUuid, bucket, amount, tick); + } + + public enum DenialReason { + NONE, + MISSING_AUTHORITY, + MISSING_RESOURCES, + INVALID_STATE + } + + public record UpgradeResult( + boolean upgraded, + DenialReason denialReason, + String messageKey, + @Nullable StrategicResourceBucket missingBucket, + int missingAmount, + int newLevel, + @Nullable FortLevelDefinition newDefinition + ) { + private static UpgradeResult success(int newLevel, FortLevelDefinition newDefinition) { + return new UpgradeResult(true, DenialReason.NONE, "chat.bannermod.fort.upgrade.success", null, 0, newLevel, newDefinition); + } + + private static UpgradeResult denied(DenialReason reason, String messageKey) { + return denied(reason, messageKey, null, 0); + } + + private static UpgradeResult denied(DenialReason reason, + String messageKey, + @Nullable StrategicResourceBucket missingBucket, + int missingAmount) { + return new UpgradeResult(false, reason, messageKey, missingBucket, Math.max(0, missingAmount), 0, null); + } + } + + private record MissingResource(StrategicResourceBucket bucket, int missingAmount) { + } +} diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContract.java b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContract.java index 22115344..55679022 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContract.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContract.java @@ -51,6 +51,24 @@ public NpcDemandContract expired(long gameTime) { ); } + public NpcDemandContract fulfilled() { + if (this.status == Status.FULFILLED) { + return this; + } + return new NpcDemandContract( + this.contractUuid, + this.claimUuid, + this.buyer, + this.resourceBucket, + this.requestedItems, + this.amount, + this.createdAtGameTime, + this.deadlineGameTime, + this.rewardCoins, + Status.FULFILLED + ); + } + public String debugLine() { return "contract=" + this.contractUuid + " claim=" + this.claimUuid diff --git a/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractService.java b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractService.java index 23c891b6..74ee83a3 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractService.java @@ -1,15 +1,20 @@ 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.nbt.ListTag; import net.minecraft.nbt.Tag; +import net.minecraft.server.level.ServerLevel; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.UUID; @@ -78,6 +83,80 @@ public List statusLines(UUID claimUuid) { return lines; } + public CompletionResult completeContract(ServerLevel level, UUID claimUuid, UUID contractUuid, long gameTime) { + return completeContract( + BannerModTreasuryManager.get(level), + SettlementManager.get(level), + StrategicResourceAccountingManager.get(level), + claimUuid, + contractUuid, + gameTime + ); + } + + CompletionResult completeContract(BannerModTreasuryManager treasuryManager, + SettlementManager settlementManager, + StrategicResourceAccountingManager accountingManager, + UUID claimUuid, + UUID contractUuid, + long gameTime) { + if (treasuryManager == null || settlementManager == null || accountingManager == null || claimUuid == null || contractUuid == null) { + return CompletionResult.failed("invalid_request"); + } + List contracts = this.contractsByClaim.get(claimUuid); + if (contracts == null || contracts.isEmpty()) { + return CompletionResult.failed("contract_not_found"); + } + for (int i = 0; i < contracts.size(); i++) { + NpcDemandContract contract = contracts.get(i); + if (!contract.contractUuid().equals(contractUuid)) { + continue; + } + if (contract.status() != NpcDemandContract.Status.OPEN) { + return CompletionResult.failed("contract_" + contract.status().name().toLowerCase(Locale.ROOT)); + } + if (gameTime >= contract.deadlineGameTime()) { + contracts.set(i, contract.expired(gameTime)); + markDirty(); + return CompletionResult.failed("contract_expired"); + } + StrategicResourceBucket requestedBucket = bucketById(contract.resourceBucket()); + if (requestedBucket == null) { + return CompletionResult.failed("unknown_resource_bucket"); + } + if (!hasRewardDestination(treasuryManager, settlementManager, claimUuid)) { + return CompletionResult.failed("reward_destination_unavailable"); + } + StrategicResourceAccountingService.TransactionResult debit = StrategicResourceAccountingService.debit( + treasuryManager, + accountingManager, + claimUuid, + requestedBucket, + contract.amount(), + gameTime + ); + if (debit.status() != StrategicResourceAccountingService.Status.SUCCESS) { + return CompletionResult.failed(debit.reason().isBlank() ? debit.status().name().toLowerCase(Locale.ROOT) : debit.reason()); + } + StrategicResourceAccountingService.TransactionResult reward = StrategicResourceAccountingService.credit( + treasuryManager, + settlementManager, + accountingManager, + claimUuid, + StrategicResourceBucket.COINS, + contract.rewardCoins(), + gameTime + ); + if (reward.status() != StrategicResourceAccountingService.Status.SUCCESS) { + return CompletionResult.failed(reward.reason().isBlank() ? reward.status().name().toLowerCase(Locale.ROOT) : reward.reason()); + } + contracts.set(i, contract.fulfilled()); + markDirty(); + return CompletionResult.success(contract, requestedBucket, debit.appliedAmount(), reward.appliedAmount(), reward.balanceAfter()); + } + return CompletionResult.failed("contract_not_found"); + } + private void expireContracts(UUID claimUuid, long gameTime) { List contracts = this.contractsByClaim.get(claimUuid); if (contracts == null || contracts.isEmpty()) { @@ -157,6 +236,24 @@ private void markDirty() { this.dirtyCallback.run(); } + private boolean hasRewardDestination(BannerModTreasuryManager treasuryManager, SettlementManager settlementManager, UUID claimUuid) { + BannerModTreasuryLedgerSnapshot ledger = treasuryManager.getLedger(claimUuid); + SettlementSnapshot snapshot = settlementManager.getSnapshot(claimUuid); + return (ledger != null && ledger.anchorChunk() != null) || (snapshot != null && snapshot.anchorChunk() != null); + } + + private static StrategicResourceBucket bucketById(String bucketId) { + if (bucketId == null || bucketId.isBlank()) { + return null; + } + for (StrategicResourceBucket bucket : StrategicResourceBucket.values()) { + if (bucket.id().equals(bucketId)) { + return bucket; + } + } + return null; + } + private NpcDemandContract createContract(SettlementSnapshot snapshot, long gameTime) { int index = Math.floorMod(snapshot.claimUuid().hashCode() + (int) (gameTime / 20L), TEMPLATES.size()); ContractTemplate template = TEMPLATES.get(index); @@ -218,4 +315,22 @@ private record ContractTemplate(String buyer, int baseAmount, int baseReward) { } + + public record CompletionResult( + boolean success, + String reason, + UUID contractUuid, + StrategicResourceBucket deliveredBucket, + int deliveredAmount, + int rewardCoins, + int rewardBalanceAfter + ) { + private static CompletionResult success(NpcDemandContract contract, StrategicResourceBucket deliveredBucket, int deliveredAmount, int rewardCoins, int rewardBalanceAfter) { + return new CompletionResult(true, "", contract.contractUuid(), deliveredBucket, deliveredAmount, rewardCoins, rewardBalanceAfter); + } + + private static CompletionResult failed(String reason) { + return new CompletionResult(false, reason == null || reason.isBlank() ? "failed" : reason, null, null, 0, 0, 0); + } + } } diff --git a/src/main/java/com/talhanation/bannermod/settlement/runtime/SettlementTreasuryDerivationService.java b/src/main/java/com/talhanation/bannermod/settlement/runtime/SettlementTreasuryDerivationService.java index 150b2e80..0bf0414e 100644 --- a/src/main/java/com/talhanation/bannermod/settlement/runtime/SettlementTreasuryDerivationService.java +++ b/src/main/java/com/talhanation/bannermod/settlement/runtime/SettlementTreasuryDerivationService.java @@ -6,6 +6,7 @@ import com.talhanation.bannermod.governance.BannerModTreasuryLedgerSnapshot; import com.talhanation.bannermod.governance.BannerModTreasuryManager; import com.talhanation.bannermod.persistence.military.RecruitsClaimManager; +import com.talhanation.bannermod.settlement.economy.StrategicResourceAccountingManager; import com.talhanation.bannermod.shared.logistics.BannerModSupplyStatus; import com.talhanation.bannermod.shared.settlement.BannerModSettlementBinding; import net.minecraft.server.level.ServerLevel; @@ -27,30 +28,41 @@ public static BannerModGovernorHeartbeat.BatchResult runGovernorHeartbeatBatch(S claimManager, governorManager, treasuryManager, + level == null ? null : StrategicResourceAccountingManager.get(level), startIndex, maxSnapshots ); } + @Nullable + public static BannerModTreasuryLedgerSnapshot.FiscalRollup deriveHeartbeatAccounting(@Nullable BannerModTreasuryManager treasuryManager, + BannerModGovernorSnapshot snapshot, + BannerModSettlementBinding.Binding binding, + BannerModGovernorHeartbeat.HeartbeatReport report, + @Nullable BannerModSupplyStatus.RecruitSupplyStatus recruitSupplyStatus) { + return deriveHeartbeatAccounting(treasuryManager, snapshot, binding, report, recruitSupplyStatus, 0); + } + @Nullable public static BannerModTreasuryLedgerSnapshot.FiscalRollup deriveHeartbeatAccounting(@Nullable BannerModTreasuryManager treasuryManager, BannerModGovernorSnapshot snapshot, BannerModSettlementBinding.Binding binding, BannerModGovernorHeartbeat.HeartbeatReport report, - @Nullable BannerModSupplyStatus.RecruitSupplyStatus recruitSupplyStatus) { + @Nullable BannerModSupplyStatus.RecruitSupplyStatus recruitSupplyStatus, + int requestedArmyUpkeepDebit) { if (treasuryManager == null || snapshot == null || binding == null || report == null) { return null; } - int requestedArmyUpkeepDebit = resolveRequestedArmyUpkeepDebit(recruitSupplyStatus); + int requestedDebit = Math.max(Math.max(0, requestedArmyUpkeepDebit), resolveRequestedArmyUpkeepDebit(recruitSupplyStatus)); BannerModTreasuryLedgerSnapshot updated = treasuryManager.applyHeartbeatAccounting( snapshot.claimUuid(), snapshot.anchorChunk(), binding.claimFactionId(), report.taxesCollected(), - requestedArmyUpkeepDebit, + requestedDebit, report.heartbeatTick() ); - return updated.projectFiscalRollup(report.taxesCollected(), requestedArmyUpkeepDebit, report.heartbeatTick()); + return updated.projectFiscalRollup(report.taxesCollected(), requestedDebit, report.heartbeatTick()); } private static int resolveRequestedArmyUpkeepDebit(@Nullable BannerModSupplyStatus.RecruitSupplyStatus recruitSupplyStatus) { diff --git a/src/main/java/com/talhanation/bannermod/war/WarRuntimeContext.java b/src/main/java/com/talhanation/bannermod/war/WarRuntimeContext.java index 0751f5a0..3f4bc9d6 100644 --- a/src/main/java/com/talhanation/bannermod/war/WarRuntimeContext.java +++ b/src/main/java/com/talhanation/bannermod/war/WarRuntimeContext.java @@ -1,5 +1,9 @@ package com.talhanation.bannermod.war; +import com.talhanation.bannermod.events.ClaimEvents; +import com.talhanation.bannermod.governance.BannerModTreasuryManager; +import com.talhanation.bannermod.settlement.economy.StrategicResourceAccountingManager; +import com.talhanation.bannermod.war.config.WarServerConfig; import com.talhanation.bannermod.war.audit.WarAuditLogSavedData; import com.talhanation.bannermod.war.cooldown.WarCooldownRuntime; import com.talhanation.bannermod.war.cooldown.WarCooldownSavedData; @@ -7,6 +11,8 @@ import com.talhanation.bannermod.war.registry.WarPoliticalRegistrySavedData; import com.talhanation.bannermod.war.runtime.DemilitarizationRuntime; import com.talhanation.bannermod.war.runtime.DemilitarizationSavedData; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveRuntime; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveSavedData; import com.talhanation.bannermod.war.runtime.OccupationRuntime; import com.talhanation.bannermod.war.runtime.OccupationSavedData; import com.talhanation.bannermod.war.runtime.OccupationTaxRuntime; @@ -14,6 +20,9 @@ import com.talhanation.bannermod.war.runtime.RevoltSavedData; import com.talhanation.bannermod.war.runtime.SiegeStandardRuntime; import com.talhanation.bannermod.war.runtime.SiegeStandardSavedData; +import com.talhanation.bannermod.war.runtime.TreatyPaymentRuntime; +import com.talhanation.bannermod.war.runtime.TreatyRuntime; +import com.talhanation.bannermod.war.runtime.TreatySavedData; import com.talhanation.bannermod.war.runtime.WarAllyInviteRuntime; import com.talhanation.bannermod.war.runtime.WarAllyInviteSavedData; import com.talhanation.bannermod.war.runtime.WarDeclarationRuntime; @@ -77,6 +86,10 @@ public static RevoltRuntime revolts(ServerLevel level) { return RevoltSavedData.get(level).runtime(); } + public static EconomicObjectiveRuntime economicObjectives(ServerLevel level) { + return EconomicObjectiveSavedData.get(level).runtime(); + } + public static WarCooldownRuntime cooldowns(ServerLevel level) { return WarCooldownSavedData.get(level).runtime(); } @@ -89,11 +102,25 @@ public static WarAllyInviteRuntime allyInvites(ServerLevel level) { return WarAllyInviteSavedData.get(level).runtime(); } + public static TreatyRuntime treaties(ServerLevel level) { + return TreatySavedData.get(level).runtime(); + } + + public static TreatyPaymentRuntime treatyPayments(ServerLevel level) { + return new TreatyPaymentRuntime( + treaties(level), + BannerModTreasuryManager.get(level), + StrategicResourceAccountingManager.get(level), + ClaimEvents.claimManager(), + audit(level) + ); + } + public static OccupationTaxRuntime taxRuntime(ServerLevel level) { return new OccupationTaxRuntime( occupations(level), - com.talhanation.bannermod.governance.BannerModTreasuryManager.get(level), - com.talhanation.bannermod.events.ClaimEvents.claimManager(), + BannerModTreasuryManager.get(level), + ClaimEvents.claimManager(), audit(level) ); } @@ -107,10 +134,12 @@ public static WarOutcomeApplier applierFor(ServerLevel level) { demilitarizations(level), registry(level), cooldowns(level), - com.talhanation.bannermod.war.config.WarServerConfig.lostTerritoryImmunityTicks(), + WarServerConfig.lostTerritoryImmunityTicks(), level, - com.talhanation.bannermod.governance.BannerModTreasuryManager.get(level), - com.talhanation.bannermod.events.ClaimEvents.claimManager() + BannerModTreasuryManager.get(level), + ClaimEvents.claimManager(), + treaties(level), + WarServerConfig.tributeTreatyIntervalTicks() ); } } diff --git a/src/main/java/com/talhanation/bannermod/war/config/WarServerConfig.java b/src/main/java/com/talhanation/bannermod/war/config/WarServerConfig.java index 5e32b5d0..5db756ca 100644 --- a/src/main/java/com/talhanation/bannermod/war/config/WarServerConfig.java +++ b/src/main/java/com/talhanation/bannermod/war/config/WarServerConfig.java @@ -26,6 +26,7 @@ public class WarServerConfig { public static final ModConfigSpec.BooleanValue SiegeProtectionAttackersExplosivesOnly; public static final ModConfigSpec.IntValue OccupationTaxAmountPerChunk; public static final ModConfigSpec.IntValue OccupationTaxIntervalDays; + public static final ModConfigSpec.IntValue TributeTreatyIntervalDays; static { RegulatedPvpEnabled = BUILDER.comment(""" @@ -98,6 +99,12 @@ Minimum delay (ticks) between declaration and earliest activation. \tdefault: 1""") .defineInRange("OccupationTaxIntervalDays", 1, 0, 365); + TributeTreatyIntervalDays = BUILDER.comment(""" + Real-day cadence between ongoing tribute treaty payments created by the + \ttribute war outcome. Set to 0 to keep only the one-shot outcome payment. + \tdefault: 1""") + .defineInRange("TributeTreatyIntervalDays", 1, 0, 365); + SERVER = BUILDER.build(); } @@ -117,6 +124,10 @@ public static long occupationTaxIntervalTicks() { return Math.max(0L, OccupationTaxIntervalDays.get().longValue()) * WarCooldownPolicy.TICKS_PER_DAY; } + public static long tributeTreatyIntervalTicks() { + return Math.max(0L, TributeTreatyIntervalDays.get().longValue()) * WarCooldownPolicy.TICKS_PER_DAY; + } + public static BattleWindowSchedule resolveSchedule() { List windows = new ArrayList<>(); for (String entry : BattleWindows.get()) { diff --git a/src/main/java/com/talhanation/bannermod/war/events/TreatyPaymentTicker.java b/src/main/java/com/talhanation/bannermod/war/events/TreatyPaymentTicker.java new file mode 100644 index 00000000..05f224cb --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/events/TreatyPaymentTicker.java @@ -0,0 +1,25 @@ +package com.talhanation.bannermod.war.events; + +import com.talhanation.bannermod.war.WarRuntimeContext; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; + +public class TreatyPaymentTicker { + private static final int TICK_INTERVAL = 20; + + private int counter = 0; + + @SubscribeEvent + public void onServerTick(ServerTickEvent.Post event) { + MinecraftServer server = event.getServer(); + if (server == null) return; + ServerLevel level = server.overworld(); + if (level == null) return; + if (++counter < TICK_INTERVAL) return; + counter = 0; + + WarRuntimeContext.treatyPayments(level).processDue(level.getGameTime()); + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveRecord.java b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveRecord.java new file mode 100644 index 00000000..729dc459 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveRecord.java @@ -0,0 +1,96 @@ +package com.talhanation.bannermod.war.runtime; + +import net.minecraft.nbt.CompoundTag; + +import javax.annotation.Nullable; +import java.util.UUID; + +public record EconomicObjectiveRecord( + UUID id, + EconomicObjectiveType type, + EconomicObjectiveTargetKind targetKind, + @Nullable UUID warId, + @Nullable UUID localHostilityId, + UUID claimUuid, + @Nullable UUID strategicObjectId, + long createdGameTime, + long expiresGameTime, + long resolvedGameTime +) { + public EconomicObjectiveRecord { + id = id == null ? UUID.randomUUID() : id; + type = type == null ? EconomicObjectiveType.MINE_DISPUTE : type; + targetKind = targetKind == null ? EconomicObjectiveTargetKind.MINE : targetKind; + claimUuid = claimUuid == null ? new UUID(0L, 0L) : claimUuid; + createdGameTime = Math.max(0L, createdGameTime); + expiresGameTime = Math.max(0L, expiresGameTime); + resolvedGameTime = Math.max(0L, resolvedGameTime); + } + + public boolean isActiveAt(long gameTime) { + return gameTime >= createdGameTime && resolvedGameTime <= 0L && (expiresGameTime <= 0L || gameTime < expiresGameTime); + } + + public EconomicObjectiveRecord resolve(long gameTime) { + return new EconomicObjectiveRecord( + id, + type, + targetKind, + warId, + localHostilityId, + claimUuid, + strategicObjectId, + createdGameTime, + expiresGameTime, + Math.max(1L, gameTime) + ); + } + + public EconomicObjectiveState economyStateAt(long gameTime) { + return isActiveAt(gameTime) ? type.economyState() : EconomicObjectiveState.NORMAL; + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("Id", id); + tag.putString("Type", type.name()); + tag.putString("TargetKind", targetKind.name()); + if (warId != null) { + tag.putUUID("WarId", warId); + } + if (localHostilityId != null) { + tag.putUUID("LocalHostilityId", localHostilityId); + } + tag.putUUID("ClaimUuid", claimUuid); + if (strategicObjectId != null) { + tag.putUUID("StrategicObjectId", strategicObjectId); + } + tag.putLong("CreatedGameTime", createdGameTime); + tag.putLong("ExpiresGameTime", expiresGameTime); + tag.putLong("ResolvedGameTime", resolvedGameTime); + return tag; + } + + public static EconomicObjectiveRecord fromTag(CompoundTag tag) { + return new EconomicObjectiveRecord( + tag.hasUUID("Id") ? tag.getUUID("Id") : UUID.randomUUID(), + enumValue(EconomicObjectiveType.class, tag.getString("Type"), EconomicObjectiveType.MINE_DISPUTE), + enumValue(EconomicObjectiveTargetKind.class, tag.getString("TargetKind"), EconomicObjectiveTargetKind.MINE), + tag.hasUUID("WarId") ? tag.getUUID("WarId") : null, + tag.hasUUID("LocalHostilityId") ? tag.getUUID("LocalHostilityId") : null, + tag.hasUUID("ClaimUuid") ? tag.getUUID("ClaimUuid") : new UUID(0L, 0L), + tag.hasUUID("StrategicObjectId") ? tag.getUUID("StrategicObjectId") : null, + tag.getLong("CreatedGameTime"), + tag.getLong("ExpiresGameTime"), + tag.getLong("ResolvedGameTime") + ); + } + + private static > E enumValue(Class type, String name, E fallback) { + try { + return Enum.valueOf(type, name); + } catch (IllegalArgumentException ex) { + return fallback; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveRuntime.java b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveRuntime.java new file mode 100644 index 00000000..d30a5d8c --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveRuntime.java @@ -0,0 +1,205 @@ +package com.talhanation.bannermod.war.runtime; + +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +public final class EconomicObjectiveRuntime { + private final Map objectivesById = new LinkedHashMap<>(); + private Runnable dirtyListener = () -> { }; + + public void setDirtyListener(Runnable dirtyListener) { + this.dirtyListener = dirtyListener == null ? () -> { } : dirtyListener; + } + + public EconomicObjectiveRecord add(EconomicObjectiveRecord record) { + objectivesById.put(record.id(), record); + dirtyListener.run(); + return record; + } + + public EconomicObjectiveRecord createMineDispute(@Nullable UUID warId, + @Nullable UUID localHostilityId, + UUID claimUuid, + UUID mineSiteId, + long createdGameTime, + long expiresGameTime) { + return add(new EconomicObjectiveRecord( + UUID.randomUUID(), + EconomicObjectiveType.MINE_DISPUTE, + EconomicObjectiveTargetKind.MINE, + warId, + localHostilityId, + claimUuid, + mineSiteId, + createdGameTime, + expiresGameTime, + 0L + )); + } + + public EconomicObjectiveRecord createBlockade(@Nullable UUID warId, + @Nullable UUID localHostilityId, + UUID claimUuid, + EconomicObjectiveTargetKind targetKind, + @Nullable UUID strategicObjectId, + long createdGameTime, + long expiresGameTime) { + EconomicObjectiveTargetKind kind = targetKind == EconomicObjectiveTargetKind.STORAGE + ? EconomicObjectiveTargetKind.STORAGE + : EconomicObjectiveTargetKind.ROUTE; + return add(new EconomicObjectiveRecord( + UUID.randomUUID(), + EconomicObjectiveType.BLOCKADE, + kind, + warId, + localHostilityId, + claimUuid, + strategicObjectId, + createdGameTime, + expiresGameTime, + 0L + )); + } + + public EconomicObjectiveRecord createRaid(@Nullable UUID warId, + @Nullable UUID localHostilityId, + UUID claimUuid, + EconomicObjectiveTargetKind targetKind, + @Nullable UUID strategicObjectId, + long createdGameTime, + long expiresGameTime) { + EconomicObjectiveTargetKind kind = targetKind == EconomicObjectiveTargetKind.MINE + || targetKind == EconomicObjectiveTargetKind.ROUTE + || targetKind == EconomicObjectiveTargetKind.STORAGE + ? targetKind + : EconomicObjectiveTargetKind.STORAGE; + return add(new EconomicObjectiveRecord( + UUID.randomUUID(), + EconomicObjectiveType.RAID, + kind, + warId, + localHostilityId, + claimUuid, + strategicObjectId, + createdGameTime, + expiresGameTime, + 0L + )); + } + + public EconomicObjectiveRecord createOutpostCapture(@Nullable UUID warId, + @Nullable UUID localHostilityId, + UUID claimUuid, + UUID outpostId, + long createdGameTime, + long expiresGameTime) { + return add(new EconomicObjectiveRecord( + UUID.randomUUID(), + EconomicObjectiveType.OUTPOST_CAPTURE, + EconomicObjectiveTargetKind.OUTPOST, + warId, + localHostilityId, + claimUuid, + outpostId, + createdGameTime, + expiresGameTime, + 0L + )); + } + + public boolean resolve(UUID objectiveId, long gameTime) { + EconomicObjectiveRecord existing = objectivesById.get(objectiveId); + if (existing == null || !existing.isActiveAt(gameTime)) { + return false; + } + objectivesById.put(objectiveId, existing.resolve(gameTime)); + dirtyListener.run(); + return true; + } + + public int pruneExpired(long gameTime) { + int before = objectivesById.size(); + objectivesById.values().removeIf(objective -> objective.expiresGameTime() > 0L && gameTime >= objective.expiresGameTime()); + int removed = before - objectivesById.size(); + if (removed > 0) { + dirtyListener.run(); + } + return removed; + } + + public Optional byId(UUID id) { + return Optional.ofNullable(objectivesById.get(id)); + } + + public Collection all() { + return List.copyOf(objectivesById.values()); + } + + public List activeForClaim(UUID claimUuid, long gameTime) { + if (claimUuid == null) { + return List.of(); + } + return objectivesById.values().stream() + .filter(objective -> claimUuid.equals(objective.claimUuid()) && objective.isActiveAt(gameTime)) + .toList(); + } + + public EconomicObjectiveState stateForMine(UUID claimUuid, UUID mineSiteId, long gameTime) { + return stateForTarget(claimUuid, EconomicObjectiveTargetKind.MINE, mineSiteId, gameTime); + } + + public EconomicObjectiveState stateForRoute(UUID claimUuid, @Nullable UUID routeId, long gameTime) { + return stateForTarget(claimUuid, EconomicObjectiveTargetKind.ROUTE, routeId, gameTime); + } + + public EconomicObjectiveState stateForStorage(UUID claimUuid, @Nullable UUID storageId, long gameTime) { + return stateForTarget(claimUuid, EconomicObjectiveTargetKind.STORAGE, storageId, gameTime); + } + + public EconomicObjectiveState stateForTarget(UUID claimUuid, + EconomicObjectiveTargetKind targetKind, + @Nullable UUID strategicObjectId, + long gameTime) { + EconomicObjectiveState state = EconomicObjectiveState.NORMAL; + for (EconomicObjectiveRecord objective : activeForClaim(claimUuid, gameTime)) { + if (objective.targetKind() != targetKind) { + continue; + } + if (strategicObjectId != null && objective.strategicObjectId() != null + && !strategicObjectId.equals(objective.strategicObjectId())) { + continue; + } + state = state.merge(objective.economyStateAt(gameTime)); + } + return state; + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + ListTag list = new ListTag(); + for (EconomicObjectiveRecord record : objectivesById.values()) { + list.add(record.toTag()); + } + tag.put("EconomicObjectives", list); + return tag; + } + + public static EconomicObjectiveRuntime fromTag(CompoundTag tag) { + EconomicObjectiveRuntime runtime = new EconomicObjectiveRuntime(); + ListTag list = tag.getList("EconomicObjectives", Tag.TAG_COMPOUND); + for (int i = 0; i < list.size(); i++) { + EconomicObjectiveRecord record = EconomicObjectiveRecord.fromTag(list.getCompound(i)); + runtime.objectivesById.put(record.id(), record); + } + return runtime; + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveSavedData.java b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveSavedData.java new file mode 100644 index 00000000..c27fece9 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveSavedData.java @@ -0,0 +1,52 @@ +package com.talhanation.bannermod.war.runtime; + +import com.talhanation.bannermod.persistence.SavedDataVersioning; +import com.talhanation.bannermod.war.events.WarSyncDirtyTracker; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +public final class EconomicObjectiveSavedData extends SavedData { + private static final String FILE_ID = "bannermodEconomicObjectives"; + private static final SavedData.Factory FACTORY = new SavedData.Factory<>(EconomicObjectiveSavedData::new, EconomicObjectiveSavedData::load); + private static final int CURRENT_VERSION = 1; + + private final EconomicObjectiveRuntime runtime; + + public EconomicObjectiveSavedData() { + this(new EconomicObjectiveRuntime()); + } + + private EconomicObjectiveSavedData(EconomicObjectiveRuntime runtime) { + this.runtime = runtime; + this.runtime.setDirtyListener(this::markDirty); + } + + public static EconomicObjectiveSavedData get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID); + } + + public static EconomicObjectiveSavedData load(CompoundTag tag, HolderLookup.Provider registries) { + SavedDataVersioning.migrate(tag, CURRENT_VERSION, "EconomicObjectiveSavedData"); + return new EconomicObjectiveSavedData(EconomicObjectiveRuntime.fromTag(tag)); + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + SavedDataVersioning.putVersion(tag, CURRENT_VERSION); + CompoundTag inner = runtime.toTag(); + tag.put("EconomicObjectives", inner.getList("EconomicObjectives", Tag.TAG_COMPOUND)); + return tag; + } + + public EconomicObjectiveRuntime runtime() { + return runtime; + } + + private void markDirty() { + setDirty(); + WarSyncDirtyTracker.markDirty(); + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveState.java b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveState.java new file mode 100644 index 00000000..fe4d57ff --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveState.java @@ -0,0 +1,27 @@ +package com.talhanation.bannermod.war.runtime; + +public enum EconomicObjectiveState { + NORMAL, + CONTESTED, + BLOCKED, + RAIDED; + + public EconomicObjectiveState merge(EconomicObjectiveState other) { + if (other == null || other == NORMAL) { + return this; + } + if (this == NORMAL) { + return other; + } + return priority(other) > priority(this) ? other : this; + } + + private static int priority(EconomicObjectiveState state) { + return switch (state) { + case NORMAL -> 0; + case CONTESTED -> 1; + case BLOCKED -> 2; + case RAIDED -> 3; + }; + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveTargetKind.java b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveTargetKind.java new file mode 100644 index 00000000..666acf9b --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveTargetKind.java @@ -0,0 +1,8 @@ +package com.talhanation.bannermod.war.runtime; + +public enum EconomicObjectiveTargetKind { + MINE, + ROUTE, + STORAGE, + OUTPOST +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveType.java b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveType.java new file mode 100644 index 00000000..4d7dba6b --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveType.java @@ -0,0 +1,16 @@ +package com.talhanation.bannermod.war.runtime; + +public enum EconomicObjectiveType { + MINE_DISPUTE, + BLOCKADE, + RAID, + OUTPOST_CAPTURE; + + public EconomicObjectiveState economyState() { + return switch (this) { + case MINE_DISPUTE, OUTPOST_CAPTURE -> EconomicObjectiveState.CONTESTED; + case BLOCKADE -> EconomicObjectiveState.BLOCKED; + case RAID -> EconomicObjectiveState.RAIDED; + }; + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/TreatyDefaultFact.java b/src/main/java/com/talhanation/bannermod/war/runtime/TreatyDefaultFact.java new file mode 100644 index 00000000..96a2096a --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/TreatyDefaultFact.java @@ -0,0 +1,52 @@ +package com.talhanation.bannermod.war.runtime; + +import net.minecraft.nbt.CompoundTag; + +import java.util.UUID; + +public record TreatyDefaultFact( + UUID id, + UUID treatyId, + UUID payerEntityId, + UUID receiverEntityId, + String defaultType, + int requestedAmount, + int paidAmount, + int defaultedAmount, + long gameTime +) { + public TreatyDefaultFact { + defaultType = defaultType == null ? "" : defaultType; + requestedAmount = Math.max(0, requestedAmount); + paidAmount = Math.max(0, paidAmount); + defaultedAmount = Math.max(0, defaultedAmount); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("Id", id); + tag.putUUID("TreatyId", treatyId); + tag.putUUID("PayerEntityId", payerEntityId); + tag.putUUID("ReceiverEntityId", receiverEntityId); + tag.putString("DefaultType", defaultType); + tag.putInt("RequestedAmount", requestedAmount); + tag.putInt("PaidAmount", paidAmount); + tag.putInt("DefaultedAmount", defaultedAmount); + tag.putLong("GameTime", gameTime); + return tag; + } + + public static TreatyDefaultFact fromTag(CompoundTag tag) { + return new TreatyDefaultFact( + tag.getUUID("Id"), + tag.getUUID("TreatyId"), + tag.getUUID("PayerEntityId"), + tag.getUUID("ReceiverEntityId"), + tag.getString("DefaultType"), + tag.getInt("RequestedAmount"), + tag.getInt("PaidAmount"), + tag.getInt("DefaultedAmount"), + tag.getLong("GameTime") + ); + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/TreatyPaymentRuntime.java b/src/main/java/com/talhanation/bannermod/war/runtime/TreatyPaymentRuntime.java new file mode 100644 index 00000000..79b32be1 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/TreatyPaymentRuntime.java @@ -0,0 +1,131 @@ +package com.talhanation.bannermod.war.runtime; + +import com.talhanation.bannermod.governance.BannerModTreasuryLedgerSnapshot; +import com.talhanation.bannermod.governance.BannerModTreasuryManager; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.persistence.military.RecruitsClaimManager; +import com.talhanation.bannermod.settlement.economy.StrategicResourceAccountSnapshot; +import com.talhanation.bannermod.settlement.economy.StrategicResourceAccountingManager; +import com.talhanation.bannermod.settlement.economy.StrategicResourceBucket; +import com.talhanation.bannermod.war.audit.WarAuditLogSavedData; + +import javax.annotation.Nullable; +import java.util.UUID; + +public final class TreatyPaymentRuntime { + private final TreatyRuntime treaties; + @Nullable + private final BannerModTreasuryManager treasury; + @Nullable + private final StrategicResourceAccountingManager resources; + @Nullable + private final RecruitsClaimManager claimManager; + private final WarAuditLogSavedData audit; + + public TreatyPaymentRuntime(TreatyRuntime treaties, + @Nullable BannerModTreasuryManager treasury, + @Nullable StrategicResourceAccountingManager resources, + @Nullable RecruitsClaimManager claimManager, + WarAuditLogSavedData audit) { + this.treaties = treaties; + this.treasury = treasury; + this.resources = resources; + this.claimManager = claimManager; + this.audit = audit; + } + + public void processDue(long currentTick) { + for (TributeTreatyRecord treaty : treaties.tributeTreaties()) { + if (!treaty.active() || treaty.amount() <= 0 || treaty.intervalTicks() <= 0L) continue; + if (currentTick - treaty.lastPaidAtGameTime() < treaty.intervalTicks()) continue; + processOne(treaty, currentTick); + } + } + + private void processOne(TributeTreatyRecord treaty, long currentTick) { + int paid = treaty.resourceBucket().treasuryBacked() + ? transferCoins(treaty, currentTick) + : transferResource(treaty, currentTick); + int defaulted = treaty.amount() - paid; + treaties.recordPayment(treaty.id(), treaty.lastPaidAtGameTime() + treaty.intervalTicks()); + if (paid > 0) { + audit.append(treaty.sourceWarId(), "TREATY_TRIBUTE_PAID", + "treatyId=" + treaty.id() + + ";payer=" + treaty.payerEntityId() + + ";receiver=" + treaty.receiverEntityId() + + ";resource=" + treaty.resourceBucket().id() + + ";requested=" + treaty.amount() + + ";paid=" + paid, + currentTick); + } + if (defaulted > 0) { + TreatyDefaultFact fact = treaties.recordDefault(treaty, treaty.amount(), paid, defaulted, currentTick); + audit.append(treaty.sourceWarId(), "TREATY_DEFAULT", + "factId=" + fact.id() + + ";treatyId=" + treaty.id() + + ";payer=" + treaty.payerEntityId() + + ";receiver=" + treaty.receiverEntityId() + + ";resource=" + treaty.resourceBucket().id() + + ";requested=" + treaty.amount() + + ";paid=" + paid + + ";defaulted=" + defaulted, + currentTick); + } + } + + private int transferCoins(TributeTreatyRecord treaty, long currentTick) { + if (treasury == null || claimManager == null) return 0; + RecruitsClaim receiverClaim = firstClaimOwnedBy(treaty.receiverEntityId()); + if (receiverClaim == null) return 0; + int remaining = treaty.amount(); + int transferred = 0; + for (RecruitsClaim claim : claimManager.getAllClaims()) { + if (remaining <= 0) break; + if (!treaty.payerEntityId().equals(claim.getOwnerPoliticalEntityId())) continue; + BannerModTreasuryLedgerSnapshot ledger = treasury.getLedger(claim.getUUID()); + if (ledger == null || ledger.treasuryBalance() <= 0) continue; + int debit = Math.min(ledger.treasuryBalance(), remaining); + treasury.recordArmyUpkeepDebit(claim.getUUID(), claim.getCenter(), null, debit, currentTick); + remaining -= debit; + transferred += debit; + } + if (transferred > 0) { + treasury.depositTaxes(receiverClaim.getUUID(), receiverClaim.getCenter(), null, transferred, currentTick); + } + return transferred; + } + + private int transferResource(TributeTreatyRecord treaty, long currentTick) { + if (resources == null || claimManager == null) return 0; + RecruitsClaim receiverClaim = firstClaimOwnedBy(treaty.receiverEntityId()); + if (receiverClaim == null) return 0; + StrategicResourceBucket bucket = treaty.resourceBucket(); + int remaining = treaty.amount(); + int transferred = 0; + for (RecruitsClaim claim : claimManager.getAllClaims()) { + if (remaining <= 0) break; + if (!treaty.payerEntityId().equals(claim.getOwnerPoliticalEntityId())) continue; + StrategicResourceAccountSnapshot account = resources.getAccount(claim.getUUID()); + if (account == null || account.balance(bucket) <= 0) continue; + int debit = Math.min(account.balance(bucket), remaining); + resources.debit(claim.getUUID(), bucket, debit, currentTick); + remaining -= debit; + transferred += debit; + } + if (transferred > 0) { + resources.credit(receiverClaim.getUUID(), bucket, transferred, currentTick); + } + return transferred; + } + + @Nullable + private RecruitsClaim firstClaimOwnedBy(UUID politicalEntityId) { + if (claimManager == null || politicalEntityId == null) return null; + for (RecruitsClaim claim : claimManager.getAllClaims()) { + if (politicalEntityId.equals(claim.getOwnerPoliticalEntityId())) { + return claim; + } + } + return null; + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/TreatyRuntime.java b/src/main/java/com/talhanation/bannermod/war/runtime/TreatyRuntime.java new file mode 100644 index 00000000..f3eefec0 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/TreatyRuntime.java @@ -0,0 +1,127 @@ +package com.talhanation.bannermod.war.runtime; + +import com.talhanation.bannermod.settlement.economy.StrategicResourceBucket; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import javax.annotation.Nullable; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class TreatyRuntime { + private final Map tributeTreaties = new LinkedHashMap<>(); + private final Map vassalRelationships = new LinkedHashMap<>(); + private final Map defaultFacts = new LinkedHashMap<>(); + private Runnable dirtyListener = () -> { }; + + public void setDirtyListener(Runnable dirtyListener) { + this.dirtyListener = dirtyListener == null ? () -> { } : dirtyListener; + } + + public TributeTreatyRecord addTribute(UUID payerEntityId, UUID receiverEntityId, + StrategicResourceBucket resourceBucket, int amount, + long intervalTicks, UUID sourceWarId, + @Nullable UUID sourceClaimUuid, + long createdAtGameTime) { + TributeTreatyRecord record = new TributeTreatyRecord(UUID.randomUUID(), payerEntityId, + receiverEntityId, resourceBucket, amount, intervalTicks, sourceWarId, + sourceClaimUuid, createdAtGameTime, createdAtGameTime, 0, 0, true); + tributeTreaties.put(record.id(), record); + dirtyListener.run(); + return record; + } + + public VassalRelationshipRecord addVassalRelationship(UUID overlordEntityId, + UUID vassalEntityId, + String obligations, + int tributeAmount, + long tributeIntervalTicks, + UUID sourceWarId, + long createdAtGameTime) { + VassalRelationshipRecord record = new VassalRelationshipRecord(UUID.randomUUID(), + overlordEntityId, vassalEntityId, obligations, tributeAmount, + tributeIntervalTicks, sourceWarId, createdAtGameTime, true); + vassalRelationships.put(record.id(), record); + dirtyListener.run(); + return record; + } + + public void recordPayment(UUID treatyId, long paidAtGameTime) { + TributeTreatyRecord record = tributeTreaties.get(treatyId); + if (record == null) return; + TributeTreatyRecord updated = record.withPayment(paidAtGameTime); + if (!updated.equals(record)) { + tributeTreaties.put(treatyId, updated); + dirtyListener.run(); + } + } + + public TreatyDefaultFact recordDefault(TributeTreatyRecord treaty, int requested, + int paid, int defaulted, long gameTime) { + TributeTreatyRecord current = tributeTreaties.getOrDefault(treaty.id(), treaty); + TributeTreatyRecord updated = current.withDefault(defaulted); + tributeTreaties.put(updated.id(), updated); + TreatyDefaultFact fact = new TreatyDefaultFact(UUID.randomUUID(), treaty.id(), + treaty.payerEntityId(), treaty.receiverEntityId(), "TRIBUTE_PAYMENT", + requested, paid, defaulted, gameTime); + defaultFacts.put(fact.id(), fact); + dirtyListener.run(); + return fact; + } + + public Collection tributeTreaties() { + return List.copyOf(tributeTreaties.values()); + } + + public Collection vassalRelationships() { + return List.copyOf(vassalRelationships.values()); + } + + public Collection defaultFacts() { + return List.copyOf(defaultFacts.values()); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + ListTag tributeList = new ListTag(); + for (TributeTreatyRecord record : tributeTreaties.values()) { + tributeList.add(record.toTag()); + } + tag.put("TributeTreaties", tributeList); + ListTag vassalList = new ListTag(); + for (VassalRelationshipRecord record : vassalRelationships.values()) { + vassalList.add(record.toTag()); + } + tag.put("VassalRelationships", vassalList); + ListTag defaultList = new ListTag(); + for (TreatyDefaultFact fact : defaultFacts.values()) { + defaultList.add(fact.toTag()); + } + tag.put("DefaultFacts", defaultList); + return tag; + } + + public static TreatyRuntime fromTag(CompoundTag tag) { + TreatyRuntime runtime = new TreatyRuntime(); + ListTag tributeList = tag.getList("TributeTreaties", Tag.TAG_COMPOUND); + for (int i = 0; i < tributeList.size(); i++) { + TributeTreatyRecord record = TributeTreatyRecord.fromTag(tributeList.getCompound(i)); + runtime.tributeTreaties.put(record.id(), record); + } + ListTag vassalList = tag.getList("VassalRelationships", Tag.TAG_COMPOUND); + for (int i = 0; i < vassalList.size(); i++) { + VassalRelationshipRecord record = VassalRelationshipRecord.fromTag(vassalList.getCompound(i)); + runtime.vassalRelationships.put(record.id(), record); + } + ListTag defaultList = tag.getList("DefaultFacts", Tag.TAG_COMPOUND); + for (int i = 0; i < defaultList.size(); i++) { + TreatyDefaultFact fact = TreatyDefaultFact.fromTag(defaultList.getCompound(i)); + runtime.defaultFacts.put(fact.id(), fact); + } + return runtime; + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/TreatySavedData.java b/src/main/java/com/talhanation/bannermod/war/runtime/TreatySavedData.java new file mode 100644 index 00000000..ff0f7b50 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/TreatySavedData.java @@ -0,0 +1,48 @@ +package com.talhanation.bannermod.war.runtime; + +import com.talhanation.bannermod.persistence.SavedDataVersioning; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.saveddata.SavedData; + +public class TreatySavedData extends SavedData { + private static final String FILE_ID = "bannermodTreaties"; + private static final SavedData.Factory FACTORY = new SavedData.Factory<>(TreatySavedData::new, TreatySavedData::load); + + private static final int CURRENT_VERSION = 1; + private final TreatyRuntime runtime; + + public TreatySavedData() { + this(new TreatyRuntime()); + } + + private TreatySavedData(TreatyRuntime runtime) { + this.runtime = runtime; + this.runtime.setDirtyListener(this::setDirty); + } + + public static TreatySavedData get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(FACTORY, FILE_ID); + } + + public static TreatySavedData load(CompoundTag tag, HolderLookup.Provider registries) { + SavedDataVersioning.migrate(tag, CURRENT_VERSION, "TreatySavedData"); + return new TreatySavedData(TreatyRuntime.fromTag(tag)); + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + SavedDataVersioning.putVersion(tag, CURRENT_VERSION); + CompoundTag inner = runtime.toTag(); + tag.put("TributeTreaties", inner.getList("TributeTreaties", Tag.TAG_COMPOUND)); + tag.put("VassalRelationships", inner.getList("VassalRelationships", Tag.TAG_COMPOUND)); + tag.put("DefaultFacts", inner.getList("DefaultFacts", Tag.TAG_COMPOUND)); + return tag; + } + + public TreatyRuntime runtime() { + return runtime; + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/TributeTreatyRecord.java b/src/main/java/com/talhanation/bannermod/war/runtime/TributeTreatyRecord.java new file mode 100644 index 00000000..2f10a343 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/TributeTreatyRecord.java @@ -0,0 +1,95 @@ +package com.talhanation.bannermod.war.runtime; + +import com.talhanation.bannermod.settlement.economy.StrategicResourceBucket; +import net.minecraft.nbt.CompoundTag; + +import javax.annotation.Nullable; +import java.util.Locale; +import java.util.UUID; + +public record TributeTreatyRecord( + UUID id, + UUID payerEntityId, + UUID receiverEntityId, + StrategicResourceBucket resourceBucket, + int amount, + long intervalTicks, + UUID sourceWarId, + @Nullable UUID sourceClaimUuid, + long createdAtGameTime, + long lastPaidAtGameTime, + int missedPayments, + int defaultedAmount, + boolean active +) { + public TributeTreatyRecord { + resourceBucket = resourceBucket == null ? StrategicResourceBucket.COINS : resourceBucket; + amount = Math.max(0, amount); + intervalTicks = Math.max(0L, intervalTicks); + missedPayments = Math.max(0, missedPayments); + defaultedAmount = Math.max(0, defaultedAmount); + } + + public TributeTreatyRecord withPayment(long paidAtGameTime) { + return new TributeTreatyRecord(id, payerEntityId, receiverEntityId, resourceBucket, amount, + intervalTicks, sourceWarId, sourceClaimUuid, createdAtGameTime, paidAtGameTime, + missedPayments, defaultedAmount, active); + } + + public TributeTreatyRecord withDefault(int defaulted) { + return new TributeTreatyRecord(id, payerEntityId, receiverEntityId, resourceBucket, amount, + intervalTicks, sourceWarId, sourceClaimUuid, createdAtGameTime, lastPaidAtGameTime, + missedPayments + 1, defaultedAmount + Math.max(0, defaulted), active); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("Id", id); + tag.putUUID("PayerEntityId", payerEntityId); + tag.putUUID("ReceiverEntityId", receiverEntityId); + tag.putString("ResourceBucket", resourceBucket.id()); + tag.putInt("Amount", amount); + tag.putLong("IntervalTicks", intervalTicks); + if (sourceWarId != null) { + tag.putUUID("SourceWarId", sourceWarId); + } + if (sourceClaimUuid != null) { + tag.putUUID("SourceClaimUuid", sourceClaimUuid); + } + tag.putLong("CreatedAtGameTime", createdAtGameTime); + tag.putLong("LastPaidAtGameTime", lastPaidAtGameTime); + tag.putInt("MissedPayments", missedPayments); + tag.putInt("DefaultedAmount", defaultedAmount); + tag.putBoolean("Active", active); + return tag; + } + + public static TributeTreatyRecord fromTag(CompoundTag tag) { + return new TributeTreatyRecord( + tag.getUUID("Id"), + tag.getUUID("PayerEntityId"), + tag.getUUID("ReceiverEntityId"), + bucketFromId(tag.getString("ResourceBucket")), + tag.getInt("Amount"), + tag.getLong("IntervalTicks"), + tag.hasUUID("SourceWarId") ? tag.getUUID("SourceWarId") : null, + tag.hasUUID("SourceClaimUuid") ? tag.getUUID("SourceClaimUuid") : null, + tag.getLong("CreatedAtGameTime"), + tag.getLong("LastPaidAtGameTime"), + tag.getInt("MissedPayments"), + tag.getInt("DefaultedAmount"), + !tag.contains("Active") || tag.getBoolean("Active") + ); + } + + private static StrategicResourceBucket bucketFromId(String id) { + if (id != null) { + for (StrategicResourceBucket bucket : StrategicResourceBucket.values()) { + if (bucket.id().equals(id.toLowerCase(Locale.ROOT))) { + return bucket; + } + } + } + return StrategicResourceBucket.COINS; + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/VassalRelationshipRecord.java b/src/main/java/com/talhanation/bannermod/war/runtime/VassalRelationshipRecord.java new file mode 100644 index 00000000..ad453c41 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/war/runtime/VassalRelationshipRecord.java @@ -0,0 +1,53 @@ +package com.talhanation.bannermod.war.runtime; + +import net.minecraft.nbt.CompoundTag; + +import java.util.UUID; + +public record VassalRelationshipRecord( + UUID id, + UUID overlordEntityId, + UUID vassalEntityId, + String obligations, + int tributeAmount, + long tributeIntervalTicks, + UUID sourceWarId, + long createdAtGameTime, + boolean active +) { + public VassalRelationshipRecord { + obligations = obligations == null ? "" : obligations; + tributeAmount = Math.max(0, tributeAmount); + tributeIntervalTicks = Math.max(0L, tributeIntervalTicks); + } + + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.putUUID("Id", id); + tag.putUUID("OverlordEntityId", overlordEntityId); + tag.putUUID("VassalEntityId", vassalEntityId); + tag.putString("Obligations", obligations); + tag.putInt("TributeAmount", tributeAmount); + tag.putLong("TributeIntervalTicks", tributeIntervalTicks); + if (sourceWarId != null) { + tag.putUUID("SourceWarId", sourceWarId); + } + tag.putLong("CreatedAtGameTime", createdAtGameTime); + tag.putBoolean("Active", active); + return tag; + } + + public static VassalRelationshipRecord fromTag(CompoundTag tag) { + return new VassalRelationshipRecord( + tag.getUUID("Id"), + tag.getUUID("OverlordEntityId"), + tag.getUUID("VassalEntityId"), + tag.getString("Obligations"), + tag.getInt("TributeAmount"), + tag.getLong("TributeIntervalTicks"), + tag.hasUUID("SourceWarId") ? tag.getUUID("SourceWarId") : null, + tag.getLong("CreatedAtGameTime"), + !tag.contains("Active") || tag.getBoolean("Active") + ); + } +} diff --git a/src/main/java/com/talhanation/bannermod/war/runtime/WarOutcomeApplier.java b/src/main/java/com/talhanation/bannermod/war/runtime/WarOutcomeApplier.java index f5346142..e0f51ba4 100644 --- a/src/main/java/com/talhanation/bannermod/war/runtime/WarOutcomeApplier.java +++ b/src/main/java/com/talhanation/bannermod/war/runtime/WarOutcomeApplier.java @@ -4,6 +4,7 @@ import com.talhanation.bannermod.governance.BannerModTreasuryManager; import com.talhanation.bannermod.persistence.military.RecruitsClaim; import com.talhanation.bannermod.persistence.military.RecruitsClaimManager; +import com.talhanation.bannermod.settlement.economy.StrategicResourceBucket; import com.talhanation.bannermod.war.audit.WarAuditLogSavedData; import com.talhanation.bannermod.war.cooldown.WarCooldownKind; import com.talhanation.bannermod.war.cooldown.WarCooldownRuntime; @@ -33,6 +34,9 @@ public final class WarOutcomeApplier { private final BannerModTreasuryManager treasury; @Nullable private final RecruitsClaimManager claimManager; + @Nullable + private final TreatyRuntime treaties; + private final long tributeTreatyIntervalTicks; public WarOutcomeApplier(WarDeclarationRuntime declarations, SiegeStandardRuntime sieges, @@ -80,6 +84,23 @@ public WarOutcomeApplier(WarDeclarationRuntime declarations, @Nullable ServerLevel level, @Nullable BannerModTreasuryManager treasury, @Nullable RecruitsClaimManager claimManager) { + this(declarations, sieges, audit, occupations, demilitarizations, registry, + cooldowns, lostTerritoryImmunityTicks, level, treasury, claimManager, null, 0L); + } + + public WarOutcomeApplier(WarDeclarationRuntime declarations, + SiegeStandardRuntime sieges, + WarAuditLogSavedData audit, + OccupationRuntime occupations, + DemilitarizationRuntime demilitarizations, + PoliticalRegistryRuntime registry, + @Nullable WarCooldownRuntime cooldowns, + long lostTerritoryImmunityTicks, + @Nullable ServerLevel level, + @Nullable BannerModTreasuryManager treasury, + @Nullable RecruitsClaimManager claimManager, + @Nullable TreatyRuntime treaties, + long tributeTreatyIntervalTicks) { this.declarations = declarations; this.sieges = sieges; this.audit = audit; @@ -91,6 +112,8 @@ public WarOutcomeApplier(WarDeclarationRuntime declarations, this.level = level; this.treasury = treasury; this.claimManager = claimManager; + this.treaties = treaties; + this.tributeTreatyIntervalTicks = Math.max(0L, tributeTreatyIntervalTicks); } public Result applyWhitePeace(UUID warId, long gameTime) { @@ -126,6 +149,26 @@ public Result applyTribute(UUID warId, long tributeAmount, long gameTime) { war.attackerPoliticalEntityId(), requested, gameTime); + if (treaties != null && requested > 0 && tributeTreatyIntervalTicks > 0L) { + RecruitsClaim sourceClaim = findFirstClaimOwnedBy(war.defenderPoliticalEntityId()); + TributeTreatyRecord treaty = treaties.addTribute( + war.defenderPoliticalEntityId(), + war.attackerPoliticalEntityId(), + StrategicResourceBucket.COINS, + requested, + tributeTreatyIntervalTicks, + warId, + sourceClaim == null ? null : sourceClaim.getUUID(), + gameTime); + audit.append(warId, "TREATY_CREATED", + "type=TRIBUTE;treatyId=" + treaty.id() + + ";payer=" + treaty.payerEntityId() + + ";receiver=" + treaty.receiverEntityId() + + ";amount=" + treaty.amount() + + ";resource=" + treaty.resourceBucket().id() + + ";intervalTicks=" + treaty.intervalTicks(), + gameTime); + } declarations.updateState(warId, WarState.RESOLVED); clearSieges(warId); grantLostTerritoryImmunity(war.defenderPoliticalEntityId(), gameTime); @@ -292,6 +335,22 @@ public Result applyVassalize(UUID warId, long gameTime) { if (!changed) { return Result.invalid("defender_not_found"); } + if (treaties != null) { + VassalRelationshipRecord relationship = treaties.addVassalRelationship( + war.attackerPoliticalEntityId(), + war.defenderPoliticalEntityId(), + "military_support,overlord_diplomacy", + 0, + tributeTreatyIntervalTicks, + warId, + gameTime); + audit.append(warId, "TREATY_CREATED", + "type=VASSAL_RELATIONSHIP;relationshipId=" + relationship.id() + + ";overlord=" + relationship.overlordEntityId() + + ";vassal=" + relationship.vassalEntityId() + + ";obligations=" + relationship.obligations(), + gameTime); + } declarations.updateState(warId, WarState.RESOLVED); clearSieges(warId); grantLostTerritoryImmunity(war.defenderPoliticalEntityId(), gameTime); diff --git a/src/main/resources/assets/bannermod/lang/en_us.json b/src/main/resources/assets/bannermod/lang/en_us.json index 6c144cc2..175db305 100644 --- a/src/main/resources/assets/bannermod/lang/en_us.json +++ b/src/main/resources/assets/bannermod/lang/en_us.json @@ -579,6 +579,23 @@ "chat.bannermod.claim.transfer.denied.same": "The claim already belongs to that state.", "chat.bannermod.claim.transfer.denied.no_source_authority": "You lack authority over the current owning state.", "chat.bannermod.claim.transfer.denied.no_target_authority": "You lack authority in the target state.", + "chat.bannermod.economy.denied.invalid_claim_uuid": "claimUuid must be a UUID.", + "chat.bannermod.fort.upgrade.success": "%s upgraded to fort level %s (%s).", + "chat.bannermod.fort.upgrade.denied.missing_claim": "That claim no longer exists on the server.", + "chat.bannermod.fort.upgrade.denied.no_authority": "You lack authority to upgrade that fort.", + "chat.bannermod.fort.upgrade.denied.max_level": "That fort is already at the maximum level.", + "chat.bannermod.fort.upgrade.denied.overworld_only": "Fort upgrades can only be performed in the Overworld.", + "chat.bannermod.fort.upgrade.denied.persistence_veto": "Fort upgrade denied: the claim update was blocked by the server.", + "chat.bannermod.fort.upgrade.denied.missing_resources": "Fort upgrade denied: missing %s %s.", + "resource.bannermod.strategic.food": "food", + "resource.bannermod.strategic.iron": "iron", + "resource.bannermod.strategic.wood": "wood", + "resource.bannermod.strategic.stone": "stone", + "resource.bannermod.strategic.coins": "coins", + "resource.bannermod.fort_level.1": "Outpost/Zastava", + "resource.bannermod.fort_level.2": "Fort", + "resource.bannermod.fort_level.3": "Stronghold", + "resource.bannermod.fort_level.4": "Town/City", "gui.bannermod.map.teleport_admin": "Teleport (Admin)", "gui.bannermod.worldmap.formation.open_command_screen": "Open command screen", "gui.bannermod.worldmap.formation.advance_in_formation": "Advance in formation", diff --git a/src/main/resources/assets/bannermod/lang/ru_ru.json b/src/main/resources/assets/bannermod/lang/ru_ru.json index 5fc31e30..4f94303c 100644 --- a/src/main/resources/assets/bannermod/lang/ru_ru.json +++ b/src/main/resources/assets/bannermod/lang/ru_ru.json @@ -578,6 +578,23 @@ "chat.bannermod.claim.transfer.denied.same": "Этот клейм уже принадлежит этому государству.", "chat.bannermod.claim.transfer.denied.no_source_authority": "У тебя нет полномочий в текущем государстве-владельце.", "chat.bannermod.claim.transfer.denied.no_target_authority": "У тебя нет полномочий в целевом государстве.", + "chat.bannermod.economy.denied.invalid_claim_uuid": "claimUuid должен быть UUID.", + "chat.bannermod.fort.upgrade.success": "%s улучшен до уровня форта %s (%s).", + "chat.bannermod.fort.upgrade.denied.missing_claim": "Эта территория больше не существует на сервере.", + "chat.bannermod.fort.upgrade.denied.no_authority": "У тебя нет полномочий улучшать этот форт.", + "chat.bannermod.fort.upgrade.denied.max_level": "Этот форт уже на максимальном уровне.", + "chat.bannermod.fort.upgrade.denied.overworld_only": "Улучшать форты можно только в Верхнем мире.", + "chat.bannermod.fort.upgrade.denied.persistence_veto": "Улучшение форта отклонено: сервер заблокировал изменение территории.", + "chat.bannermod.fort.upgrade.denied.missing_resources": "Улучшение форта отклонено: не хватает %s %s.", + "resource.bannermod.strategic.food": "еды", + "resource.bannermod.strategic.iron": "железа", + "resource.bannermod.strategic.wood": "дерева", + "resource.bannermod.strategic.stone": "камня", + "resource.bannermod.strategic.coins": "монет", + "resource.bannermod.fort_level.1": "Застава", + "resource.bannermod.fort_level.2": "Форт", + "resource.bannermod.fort_level.3": "Крепость", + "resource.bannermod.fort_level.4": "Город", "gui.bannermod.map.teleport_admin": "Телепортация (Админ)", "gui.bannermod.worldmap.formation.open_command_screen": "Открыть командный экран", "gui.bannermod.worldmap.formation.advance_in_formation": "Наступать строем", diff --git a/src/test/java/com/talhanation/bannermod/client/military/render/RecruitRendererParitySourceTest.java b/src/test/java/com/talhanation/bannermod/client/military/render/RecruitRendererParitySourceTest.java index bd2a773a..d8fa17d2 100644 --- a/src/test/java/com/talhanation/bannermod/client/military/render/RecruitRendererParitySourceTest.java +++ b/src/test/java/com/talhanation/bannermod/client/military/render/RecruitRendererParitySourceTest.java @@ -80,11 +80,20 @@ void crowdImpostorKeepsRuntimeCounterHooks() throws IOException { assertTrue(lodSource.contains("CROWDED_RECRUIT_COUNT = 48")); assertTrue(lodSource.contains("CROWD_IMPOSTOR_DISTANCE = 48.0D")); assertTrue(eventSource.contains("RuntimeProfilingCounters.increment(\"recruit.render.normal_skipped_for_impostor\")")); + assertTrue(eventSource.contains("isCachedImpostorCandidate(recruit)")); + assertTrue(eventSource.contains("cachedImpostorQueryIds.contains(recruit.getId())")); + assertTrue(eventSource.contains("ClientPlayerNetworkEvent.LoggingOut")); + assertTrue(eventSource.contains("clearCachedQuery()")); assertTrue(eventSource.contains("RuntimeProfilingCounters.add(\"recruit.render.crowd_query_results\", recruits.size())")); + assertTrue(eventSource.contains("RuntimeProfilingCounters.increment(\"recruit.render.crowd_query_cache_hits\")")); + assertTrue(eventSource.contains("RuntimeProfilingCounters.increment(\"recruit.render.crowd_query_cache_misses\")")); + assertTrue(eventSource.contains("RuntimeProfilingCounters.add(\"recruit.render.crowd_query_nanos\", System.nanoTime() - startNanos)")); assertTrue(eventSource.contains("RuntimeProfilingCounters.add(\"recruit.render.crowd_impostor_candidates\", candidates)")); assertTrue(eventSource.contains("RuntimeProfilingCounters.add(\"recruit.render.crowd_impostors\", rendered)")); assertTrue(eventSource.contains("RuntimeProfilingCounters.add(\"recruit.render.crowd_impostor_nanos\", System.nanoTime() - startNanos)")); assertTrue(eventSource.contains("RuntimeProfilingCounters.add(\"recruit.render.crowd_impostor_frustum_culled\", frustumCulled)")); + assertTrue(eventSource.contains("RuntimeProfilingCounters.add(\"recruit.render.crowd_impostor_range_culled\", rangeCulled)")); + assertTrue(eventSource.contains("RecruitHumanRenderer.crowdRenderType(recruit)")); } @Test diff --git a/src/test/java/com/talhanation/bannermod/commands/admin/AdminDebugCommandsTest.java b/src/test/java/com/talhanation/bannermod/commands/admin/AdminDebugCommandsTest.java new file mode 100644 index 00000000..fc7864f2 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/commands/admin/AdminDebugCommandsTest.java @@ -0,0 +1,26 @@ +package com.talhanation.bannermod.commands.admin; + +import com.mojang.brigadier.tree.CommandNode; +import net.minecraft.commands.CommandSourceStack; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class AdminDebugCommandsTest { + @Test + void economyObjectiveCommandsAreRegistered() { + CommandNode root = AdminDebugCommands.debug().build(); + + CommandNode objectives = child(child(root, "economy"), "objectives"); + assertNotNull(child(objectives, "mine-dispute")); + assertNotNull(child(objectives, "blockade")); + assertNotNull(child(objectives, "resolve")); + assertNotNull(child(objectives, "prune")); + } + + private static CommandNode child(CommandNode parent, String name) { + CommandNode child = parent.getChild(name); + assertNotNull(child, name); + return child; + } +} diff --git a/src/test/java/com/talhanation/bannermod/commands/war/FortUpgradeCommandContractTest.java b/src/test/java/com/talhanation/bannermod/commands/war/FortUpgradeCommandContractTest.java new file mode 100644 index 00000000..43eb9cbf --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/commands/war/FortUpgradeCommandContractTest.java @@ -0,0 +1,31 @@ +package com.talhanation.bannermod.commands.war; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FortUpgradeCommandContractTest { + private static final Path ROOT = Path.of(""); + private static final String COMMAND = "src/main/java/com/talhanation/bannermod/commands/war/EconomyStatusCommands.java"; + + @Test + void upgradeFortRejectsNonOverworldBeforeClaimOrEconomyLookup() throws IOException { + String src = Files.readString(ROOT.resolve(COMMAND)); + + int dimensionGuard = src.indexOf("level.dimension() != Level.OVERWORLD"); + int claimLookup = src.indexOf("ClaimEvents.claimManager()"); + int treasuryLookup = src.indexOf("BannerModTreasuryManager.get(level)"); + int accountingLookup = src.indexOf("StrategicResourceAccountingManager.get(level)"); + + assertTrue(dimensionGuard >= 0, "upgradeFort must reject non-Overworld execution"); + assertTrue(src.contains("chat.bannermod.fort.upgrade.denied.overworld_only"), + "upgradeFort must return a localization-ready Overworld-only denial"); + assertTrue(dimensionGuard < claimLookup, "dimension guard must run before claim lookup"); + assertTrue(dimensionGuard < treasuryLookup, "dimension guard must run before treasury lookup"); + assertTrue(dimensionGuard < accountingLookup, "dimension guard must run before resource-accounting lookup"); + } +} diff --git a/src/test/java/com/talhanation/bannermod/governance/BannerModGovernorHeartbeatTest.java b/src/test/java/com/talhanation/bannermod/governance/BannerModGovernorHeartbeatTest.java index 833720dd..c68081d0 100644 --- a/src/test/java/com/talhanation/bannermod/governance/BannerModGovernorHeartbeatTest.java +++ b/src/test/java/com/talhanation/bannermod/governance/BannerModGovernorHeartbeatTest.java @@ -1,7 +1,13 @@ package com.talhanation.bannermod.governance; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.persistence.military.RecruitsClaimManager; import com.talhanation.bannermod.shared.logistics.BannerModSupplyStatus; import com.talhanation.bannermod.shared.settlement.BannerModSettlementBinding; +import com.talhanation.bannermod.settlement.economy.StrategicResourceAccountingManager; +import com.talhanation.bannermod.settlement.economy.StrategicResourceBucket; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; import org.junit.jupiter.api.Test; import java.util.List; @@ -9,6 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; class BannerModGovernorHeartbeatTest { @@ -167,4 +174,110 @@ void shortageAndLowDefenseMapToExplicitRecommendationAndIncidentVocabulary() { ); } + @Test + void missingStrategicFoodAccountDoesNotCreateStarvationPressure() { + BannerModGovernorSnapshot snapshot = BannerModGovernorSnapshot.create(UUID.randomUUID(), new ChunkPos(4, 7), "blueguild"); + BannerModSupplyStatus.RecruitSupplyStatus current = stableRecruitSupply(); + + BannerModSupplyStatus.RecruitSupplyStatus updated = BannerModGovernorHeartbeat.applyArmyUpkeepPressure( + null, + new StrategicResourceAccountingManager(), + snapshot, + current, + new BannerModGovernorHeartbeat.ArmyUpkeepDemand(3, 0), + 0, + 200L + ); + + assertSame(current, updated); + } + + @Test + void foodShortageDoesNotPartiallyDrainStrategicFoodBalance() { + UUID claimUuid = UUID.randomUUID(); + BannerModGovernorSnapshot snapshot = BannerModGovernorSnapshot.create(claimUuid, new ChunkPos(4, 7), "blueguild"); + StrategicResourceAccountingManager resourceAccountingManager = new StrategicResourceAccountingManager(); + resourceAccountingManager.credit(claimUuid, StrategicResourceBucket.FOOD, 2, 100L); + + BannerModSupplyStatus.RecruitSupplyStatus updated = BannerModGovernorHeartbeat.applyArmyUpkeepPressure( + null, + resourceAccountingManager, + snapshot, + stableRecruitSupply(), + new BannerModGovernorHeartbeat.ArmyUpkeepDemand(3, 0), + 0, + 200L + ); + + assertTrue(updated.blocked()); + assertTrue(updated.needsFood()); + assertTrue(updated.accounting().starving()); + assertEquals(2, resourceAccountingManager.getAccount(claimUuid).balance(StrategicResourceBucket.FOOD)); + } + + @Test + void successfulFoodUpkeepDebitsFullDemandWithoutShortagePressure() { + UUID claimUuid = UUID.randomUUID(); + BannerModGovernorSnapshot snapshot = BannerModGovernorSnapshot.create(claimUuid, new ChunkPos(4, 7), "blueguild"); + StrategicResourceAccountingManager resourceAccountingManager = new StrategicResourceAccountingManager(); + resourceAccountingManager.credit(claimUuid, StrategicResourceBucket.FOOD, 3, 100L); + BannerModSupplyStatus.RecruitSupplyStatus current = stableRecruitSupply(); + + BannerModSupplyStatus.RecruitSupplyStatus updated = BannerModGovernorHeartbeat.applyArmyUpkeepPressure( + null, + resourceAccountingManager, + snapshot, + current, + new BannerModGovernorHeartbeat.ArmyUpkeepDemand(3, 0), + 0, + 200L + ); + + assertSame(current, updated); + assertEquals(0, resourceAccountingManager.getAccount(claimUuid).balance(StrategicResourceBucket.FOOD)); + } + + @Test + void upkeepAttributionRequiresMatchingClaimForUpkeepPosition() { + RecruitsClaim governedClaim = claimAt("Keep", new ChunkPos(0, 0)); + RecruitsClaim foreignClaim = claimAt("Outpost", new ChunkPos(2, 0)); + RecruitsClaimManager claimManager = new RecruitsClaimManager(); + claimManager.testInsertClaim(governedClaim); + claimManager.testInsertClaim(foreignClaim); + + assertTrue(BannerModGovernorHeartbeat.upkeepPosBelongsToClaim( + claimManager, + governedClaim.getUUID(), + new BlockPos(8, 64, 8) + )); + assertFalse(BannerModGovernorHeartbeat.upkeepPosBelongsToClaim( + claimManager, + governedClaim.getUUID(), + new BlockPos(40, 64, 8) + )); + assertFalse(BannerModGovernorHeartbeat.upkeepPosBelongsToClaim( + claimManager, + governedClaim.getUUID(), + null + )); + } + + private static RecruitsClaim claimAt(String name, ChunkPos chunkPos) { + RecruitsClaim claim = new RecruitsClaim(name, UUID.randomUUID()); + claim.setCenter(chunkPos); + claim.addChunk(chunkPos); + return claim; + } + + private static BannerModSupplyStatus.RecruitSupplyStatus stableRecruitSupply() { + return new BannerModSupplyStatus.RecruitSupplyStatus( + BannerModSupplyStatus.RecruitSupplyState.READY, + false, + false, + false, + null, + BannerModSupplyStatus.armyUpkeepStatus(false, false, 100.0F) + ); + } + } diff --git a/src/test/java/com/talhanation/bannermod/network/messages/military/MessageUpdateClaimTest.java b/src/test/java/com/talhanation/bannermod/network/messages/military/MessageUpdateClaimTest.java new file mode 100644 index 00000000..02982fa4 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/network/messages/military/MessageUpdateClaimTest.java @@ -0,0 +1,33 @@ +package com.talhanation.bannermod.network.messages.military; + +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.settlement.economy.FortLevelDefinition; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MessageUpdateClaimTest { + @Test + void genericClaimUpdatePreservesExistingServerFortLevel() { + RecruitsClaim existing = new RecruitsClaim("Fort", UUID.randomUUID()); + existing.setFortLevel(3); + RecruitsClaim updatedFromClient = new RecruitsClaim("Fort", existing.getOwnerPoliticalEntityId()); + updatedFromClient.setFortLevel(4); + + MessageUpdateClaim.preserveServerOwnedFields(updatedFromClient, existing); + + assertEquals(3, updatedFromClient.getFortLevel()); + } + + @Test + void newGenericClaimUpdateUsesMinimumServerFortLevel() { + RecruitsClaim updatedFromClient = new RecruitsClaim("Fort", UUID.randomUUID()); + updatedFromClient.setFortLevel(4); + + MessageUpdateClaim.preserveServerOwnedFields(updatedFromClient, null); + + assertEquals(FortLevelDefinition.MIN_LEVEL, updatedFromClient.getFortLevel()); + } +} diff --git a/src/test/java/com/talhanation/bannermod/persistence/military/RecruitsClaimManagerConditionalUpdateContractTest.java b/src/test/java/com/talhanation/bannermod/persistence/military/RecruitsClaimManagerConditionalUpdateContractTest.java new file mode 100644 index 00000000..507d8b7c --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/persistence/military/RecruitsClaimManagerConditionalUpdateContractTest.java @@ -0,0 +1,31 @@ +package com.talhanation.bannermod.persistence.military; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RecruitsClaimManagerConditionalUpdateContractTest { + private static final Path ROOT = Path.of(""); + private static final String MANAGER = "src/main/java/com/talhanation/bannermod/persistence/military/RecruitsClaimManager.java"; + + @Test + void conditionalUpdateMutatesBeforeEventAndRollsBackCanceledEvent() throws IOException { + String src = Files.readString(ROOT.resolve(MANAGER)); + + int beforePersist = src.indexOf("beforePersist.getAsBoolean()"); + int eventPost = src.indexOf("NeoForge.EVENT_BUS.post(updateEvent)"); + int rollback = src.indexOf("rollback.run()"); + int persist = src.indexOf("persistClaims(RecruitsClaimSaveData.get(level))"); + + assertTrue(beforePersist >= 0, "conditional update must run the mutation/debit callback"); + assertTrue(eventPost >= 0, "conditional update must still post ClaimEvent.Updated"); + assertTrue(rollback >= 0, "conditional update must rollback if ClaimEvent.Updated is canceled"); + assertTrue(beforePersist < eventPost, "listeners must see the intended final claim state"); + assertTrue(eventPost < persist, "claim event must remain a pre-persist veto point"); + assertTrue(rollback < persist, "canceled events must rollback before any persistence"); + } +} 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 5e935a11..dc73ef45 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/ClaimStrategicEconomySummaryServiceTest.java @@ -14,6 +14,10 @@ import com.talhanation.bannermod.settlement.building.BuildingType; import com.talhanation.bannermod.settlement.building.BuildingValidationState; import com.talhanation.bannermod.settlement.building.ValidatedBuildingRecord; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveRecord; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveState; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveTargetKind; +import com.talhanation.bannermod.war.runtime.EconomicObjectiveType; import net.minecraft.core.BlockPos; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; @@ -168,7 +172,7 @@ void strategicMineSiteCategoryReplacesGenericMineIronHint() { } @Test - void unknownMineSiteDoesNotGrantYield() { + void unsupportedMineCategoryDoesNotMutateIronLine() { UUID claimUuid = UUID.randomUUID(); UUID ownerUuid = UUID.randomUUID(); SettlementSnapshot snapshot = snapshot(claimUuid, List.of(), 0, 0, 0); @@ -182,8 +186,10 @@ void unknownMineSiteDoesNotGrantYield() { ClaimStrategicEconomySummary.ResourceLine iron = line(summary, "iron"); assertEquals(0, iron.productionHint()); - assertTrue(iron.degraded()); - assertTrue(iron.unknown()); + assertFalse(iron.degraded()); + assertFalse(iron.unknown()); + assertTrue(summary.degraded()); + assertTrue(summary.unknown()); } @Test @@ -307,6 +313,123 @@ void reservedMineDisruptionReducesYieldWithoutRemovingMineRecord() { assertTrue(iron.degraded()); } + @Test + void mineObjectiveMarksMineResourceContestedInReadModel() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + UUID mineSiteId = UUID.randomUUID(); + StrategicMineSite mineSite = mineSite( + mineSiteId, + claimUuid, + ownerUuid, + VenaterraDepositCategory.IRON, + 1.0F, + 2, + false, + false + ); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot(claimUuid, List.of(), 0, 0, 0), + List.of(), + null, + List.of(mineSite), + 2, + List.of(objective(EconomicObjectiveType.MINE_DISPUTE, EconomicObjectiveTargetKind.MINE, claimUuid, mineSiteId, 10L, 30L)), + 20L + ); + + assertEquals(2, summary.fortLevel().level()); + assertEquals(EconomicObjectiveState.CONTESTED, line(summary, "iron").objectiveState()); + assertEquals(EconomicObjectiveState.CONTESTED, summary.objectiveState()); + } + + @Test + void outpostCaptureMarksOnlyClaimAggregateContested() { + UUID claimUuid = UUID.randomUUID(); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot(claimUuid, List.of(), 0, 0, 0), + List.of(), + null, + List.of(), + 3, + List.of(objective(EconomicObjectiveType.OUTPOST_CAPTURE, EconomicObjectiveTargetKind.OUTPOST, claimUuid, UUID.randomUUID(), 10L, 30L)), + 20L + ); + + assertEquals(3, summary.fortLevel().level()); + assertEquals(EconomicObjectiveState.CONTESTED, summary.objectiveState()); + assertTrue(summary.resources().stream().allMatch(line -> line.objectiveState() == EconomicObjectiveState.NORMAL)); + } + + @Test + void missingMineSiteObjectiveMarksOnlyClaimAggregateContested() { + UUID claimUuid = UUID.randomUUID(); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot(claimUuid, List.of(), 0, 0, 0), + List.of(), + null, + List.of(), + FortLevelDefinition.MIN_LEVEL, + List.of(objective(EconomicObjectiveType.MINE_DISPUTE, EconomicObjectiveTargetKind.MINE, claimUuid, UUID.randomUUID(), 10L, 30L)), + 20L + ); + + assertEquals(EconomicObjectiveState.CONTESTED, summary.objectiveState()); + assertTrue(summary.resources().stream().allMatch(line -> line.objectiveState() == EconomicObjectiveState.NORMAL)); + } + + @Test + void routeObjectiveMarksSupplyBlockedInReadModel() { + UUID claimUuid = UUID.randomUUID(); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot(claimUuid, List.of(), 0, 0, 0), + List.of(), + null, + List.of(), + FortLevelDefinition.MIN_LEVEL, + List.of(objective(EconomicObjectiveType.BLOCKADE, EconomicObjectiveTargetKind.ROUTE, claimUuid, UUID.randomUUID(), 10L, 30L)), + 20L + ); + + assertEquals(EconomicObjectiveState.BLOCKED, summary.objectiveState()); + assertTrue(summary.resources().stream().allMatch(line -> line.objectiveState() == EconomicObjectiveState.BLOCKED)); + } + + @Test + void expiredObjectiveReturnsReadModelToNormalWithoutRemovingMine() { + UUID claimUuid = UUID.randomUUID(); + UUID ownerUuid = UUID.randomUUID(); + UUID mineSiteId = UUID.randomUUID(); + StrategicMineSite mineSite = mineSite( + mineSiteId, + claimUuid, + ownerUuid, + VenaterraDepositCategory.IRON, + 1.0F, + 2, + false, + false + ); + + ClaimStrategicEconomySummary summary = ClaimStrategicEconomySummaryService.derive( + snapshot(claimUuid, List.of(), 0, 0, 0), + List.of(), + null, + List.of(mineSite), + FortLevelDefinition.MIN_LEVEL, + List.of(objective(EconomicObjectiveType.MINE_DISPUTE, EconomicObjectiveTargetKind.MINE, claimUuid, mineSiteId, 10L, 30L)), + 30L + ); + + assertEquals(3, line(summary, "iron").productionHint()); + assertEquals(EconomicObjectiveState.NORMAL, line(summary, "iron").objectiveState()); + assertEquals(EconomicObjectiveState.NORMAL, summary.objectiveState()); + } + private static ClaimStrategicEconomySummary.ResourceLine line(ClaimStrategicEconomySummary summary, String resourceId) { return summary.resources().stream() .filter(line -> line.resourceId().equals(resourceId)) @@ -350,15 +473,35 @@ private static ValidatedBuildingRecord validated(UUID claimUuid, } private static StrategicMineSite mineSite(UUID claimUuid, - UUID ownerUuid, - VenaterraDepositCategory category, - float richness, - boolean degraded, - boolean unknown) { + UUID ownerUuid, + VenaterraDepositCategory category, + 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 mineSite( + UUID.randomUUID(), + claimUuid, + ownerUuid, + category, + richness, + assignedWorkerCount, + degraded, + unknown + ); + } + + private static StrategicMineSite mineSite(UUID siteId, + UUID claimUuid, UUID ownerUuid, VenaterraDepositCategory category, float richness, @@ -366,7 +509,7 @@ private static StrategicMineSite mineSite(UUID claimUuid, boolean degraded, boolean unknown) { return new StrategicMineSite( - UUID.randomUUID(), + siteId, claimUuid, ownerUuid, Level.OVERWORLD, @@ -381,6 +524,26 @@ private static StrategicMineSite mineSite(UUID claimUuid, ); } + private static EconomicObjectiveRecord objective(EconomicObjectiveType type, + EconomicObjectiveTargetKind targetKind, + UUID claimUuid, + UUID strategicObjectId, + long createdGameTime, + long expiresGameTime) { + return new EconomicObjectiveRecord( + UUID.randomUUID(), + type, + targetKind, + UUID.randomUUID(), + null, + claimUuid, + strategicObjectId, + createdGameTime, + expiresGameTime, + 0L + ); + } + private static SettlementSnapshot snapshot(UUID claimUuid, List buildings, int assignedResidents, diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/FortUpgradeServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/FortUpgradeServiceTest.java new file mode 100644 index 00000000..67b1b3ca --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/FortUpgradeServiceTest.java @@ -0,0 +1,259 @@ +package com.talhanation.bannermod.settlement.economy; + +import com.talhanation.bannermod.governance.BannerModTreasuryManager; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.persistence.military.RecruitsPlayerInfo; +import com.talhanation.bannermod.war.registry.PoliticalEntityRecord; +import com.talhanation.bannermod.war.registry.PoliticalEntityStatus; +import net.minecraft.core.BlockPos; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FortUpgradeServiceTest { + @Test + void upgradeDebitsRequirementOnceAndIncreasesFortLevel() { + UUID owner = UUID.randomUUID(); + RecruitsClaim claim = ownedClaim(owner); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + StrategicResourceAccountingManager accounting = fundedAccounting(claim.getUUID(), 200, 80, 250, 180); + treasury.depositTaxes(claim.getUUID(), new ChunkPos(0, 0), "state", 400, 10L); + + FortUpgradeService.UpgradeResult result = FortUpgradeService.planUpgrade( + treasury, + accounting, + claim, + owner, + false, + null + ); + + assertTrue(result.upgraded()); + assertEquals(1, claim.getFortLevel()); + assertEquals(200, accounting.getAccount(claim.getUUID()).food()); + + assertTrue(FortUpgradeService.applyUpgrade(treasury, accounting, claim, result, 20L)); + + assertEquals(2, claim.getFortLevel()); + assertEquals(80, accounting.getAccount(claim.getUUID()).food()); + assertEquals(40, accounting.getAccount(claim.getUUID()).iron()); + assertEquals(90, accounting.getAccount(claim.getUUID()).wood()); + assertEquals(60, accounting.getAccount(claim.getUUID()).stone()); + assertEquals(150, treasury.getLedger(claim.getUUID()).treasuryBalance()); + } + + @Test + void missingResourceDeniesWithoutPartialDebit() { + UUID owner = UUID.randomUUID(); + RecruitsClaim claim = ownedClaim(owner); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + StrategicResourceAccountingManager accounting = fundedAccounting(claim.getUUID(), 200, 80, 250, 119); + treasury.depositTaxes(claim.getUUID(), new ChunkPos(0, 0), "state", 400, 10L); + + FortUpgradeService.UpgradeResult result = FortUpgradeService.planUpgrade( + treasury, + accounting, + claim, + owner, + false, + null + ); + + assertFalse(result.upgraded()); + assertEquals(FortUpgradeService.DenialReason.MISSING_RESOURCES, result.denialReason()); + assertEquals(1, claim.getFortLevel()); + assertEquals(200, accounting.getAccount(claim.getUUID()).food()); + assertEquals(80, accounting.getAccount(claim.getUUID()).iron()); + assertEquals(250, accounting.getAccount(claim.getUUID()).wood()); + assertEquals(119, accounting.getAccount(claim.getUUID()).stone()); + assertEquals(400, treasury.getLedger(claim.getUUID()).treasuryBalance()); + } + + @Test + void stalePlanDoesNotDebitOrUpgradeWhenResourceBalanceChangesBeforeApply() { + UUID owner = UUID.randomUUID(); + RecruitsClaim claim = ownedClaim(owner); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + StrategicResourceAccountingManager accounting = fundedAccounting(claim.getUUID(), 200, 80, 250, 180); + treasury.depositTaxes(claim.getUUID(), new ChunkPos(0, 0), "state", 400, 10L); + FortUpgradeService.UpgradeResult result = FortUpgradeService.planUpgrade( + treasury, + accounting, + claim, + owner, + false, + null + ); + + accounting.debit(claim.getUUID(), StrategicResourceBucket.STONE, 61, 15L); + + assertFalse(FortUpgradeService.applyUpgrade(treasury, accounting, claim, result, 20L)); + assertEquals(1, claim.getFortLevel()); + assertEquals(200, accounting.getAccount(claim.getUUID()).food()); + assertEquals(80, accounting.getAccount(claim.getUUID()).iron()); + assertEquals(250, accounting.getAccount(claim.getUUID()).wood()); + assertEquals(119, accounting.getAccount(claim.getUUID()).stone()); + assertEquals(400, treasury.getLedger(claim.getUUID()).treasuryBalance()); + } + + @Test + void missingAuthorityDeniesBeforeDebit() { + UUID owner = UUID.randomUUID(); + RecruitsClaim claim = ownedClaim(owner); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + StrategicResourceAccountingManager accounting = fundedAccounting(claim.getUUID(), 200, 80, 250, 180); + treasury.depositTaxes(claim.getUUID(), new ChunkPos(0, 0), "state", 400, 10L); + + FortUpgradeService.UpgradeResult result = FortUpgradeService.planUpgrade( + treasury, + accounting, + claim, + UUID.randomUUID(), + false, + null + ); + + assertFalse(result.upgraded()); + assertEquals(FortUpgradeService.DenialReason.MISSING_AUTHORITY, result.denialReason()); + assertEquals(1, claim.getFortLevel()); + assertEquals(200, accounting.getAccount(claim.getUUID()).food()); + assertEquals(400, treasury.getLedger(claim.getUUID()).treasuryBalance()); + } + + @Test + void politicalLeaderCanUpgradeClaimOwnedByState() { + UUID leader = UUID.randomUUID(); + UUID politicalId = UUID.randomUUID(); + RecruitsClaim claim = new RecruitsClaim("Fort", politicalId); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + StrategicResourceAccountingManager accounting = fundedAccounting(claim.getUUID(), 200, 80, 250, 180); + treasury.depositTaxes(claim.getUUID(), new ChunkPos(0, 0), "state", 400, 10L); + + FortUpgradeService.UpgradeResult result = FortUpgradeService.planUpgrade( + treasury, + accounting, + claim, + leader, + false, + politicalEntity(politicalId, leader) + ); + + assertTrue(result.upgraded()); + assertTrue(FortUpgradeService.applyUpgrade(treasury, accounting, claim, result, 20L)); + assertEquals(2, claim.getFortLevel()); + } + + @Test + void stateOwnedClaimRejectsStaleDirectPlayerOwnerWithoutPoliticalAuthority() { + UUID staleOwner = UUID.randomUUID(); + UUID politicalId = UUID.randomUUID(); + RecruitsClaim claim = new RecruitsClaim("Fort", politicalId); + claim.setPlayer(new RecruitsPlayerInfo(staleOwner, "stale")); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + StrategicResourceAccountingManager accounting = fundedAccounting(claim.getUUID(), 200, 80, 250, 180); + treasury.depositTaxes(claim.getUUID(), new ChunkPos(0, 0), "state", 400, 10L); + + FortUpgradeService.UpgradeResult result = FortUpgradeService.planUpgrade( + treasury, + accounting, + claim, + staleOwner, + false, + null + ); + + assertFalse(result.upgraded()); + assertEquals(FortUpgradeService.DenialReason.MISSING_AUTHORITY, result.denialReason()); + assertEquals(1, claim.getFortLevel()); + assertEquals(200, accounting.getAccount(claim.getUUID()).food()); + assertEquals(400, treasury.getLedger(claim.getUUID()).treasuryBalance()); + } + + @Test + void rollbackRestoresResourcesAndFortLevelAfterAppliedUpgrade() { + UUID owner = UUID.randomUUID(); + RecruitsClaim claim = ownedClaim(owner); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + StrategicResourceAccountingManager accounting = fundedAccounting(claim.getUUID(), 200, 80, 250, 180); + treasury.depositTaxes(claim.getUUID(), new ChunkPos(0, 0), "state", 400, 10L); + FortUpgradeService.UpgradeResult result = FortUpgradeService.planUpgrade( + treasury, + accounting, + claim, + owner, + false, + null + ); + + assertTrue(FortUpgradeService.applyUpgrade(treasury, accounting, claim, result, 20L)); + FortUpgradeService.rollbackUpgrade(treasury, accounting, claim, 1, result, 21L); + + assertEquals(1, claim.getFortLevel()); + assertEquals(200, accounting.getAccount(claim.getUUID()).food()); + assertEquals(80, accounting.getAccount(claim.getUUID()).iron()); + assertEquals(250, accounting.getAccount(claim.getUUID()).wood()); + assertEquals(180, accounting.getAccount(claim.getUUID()).stone()); + assertEquals(400, treasury.getLedger(claim.getUUID()).treasuryBalance()); + } + + @Test + void maxLevelDeniesAsInvalidState() { + UUID owner = UUID.randomUUID(); + RecruitsClaim claim = ownedClaim(owner); + claim.setFortLevel(FortLevelDefinition.MAX_LEVEL); + + FortUpgradeService.UpgradeResult result = FortUpgradeService.planUpgrade( + new BannerModTreasuryManager(), + new StrategicResourceAccountingManager(), + claim, + owner, + false, + null + ); + + assertFalse(result.upgraded()); + assertEquals(FortUpgradeService.DenialReason.INVALID_STATE, result.denialReason()); + assertEquals(FortLevelDefinition.MAX_LEVEL, claim.getFortLevel()); + } + + private static RecruitsClaim ownedClaim(UUID owner) { + RecruitsClaim claim = new RecruitsClaim("Fort", null); + claim.setPlayer(new RecruitsPlayerInfo(owner, "owner")); + return claim; + } + + private static StrategicResourceAccountingManager fundedAccounting(UUID claimUuid, + int food, + int iron, + int wood, + int stone) { + StrategicResourceAccountingManager accounting = new StrategicResourceAccountingManager(); + accounting.credit(claimUuid, StrategicResourceBucket.FOOD, food, 10L); + accounting.credit(claimUuid, StrategicResourceBucket.IRON, iron, 10L); + accounting.credit(claimUuid, StrategicResourceBucket.WOOD, wood, 10L); + accounting.credit(claimUuid, StrategicResourceBucket.STONE, stone, 10L); + return accounting; + } + + private static PoliticalEntityRecord politicalEntity(UUID politicalId, UUID leader) { + return new PoliticalEntityRecord( + politicalId, + "State", + PoliticalEntityStatus.STATE, + leader, + List.of(), + BlockPos.ZERO, + "", + "", + "", + "", + 0L + ); + } +} diff --git a/src/test/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractServiceTest.java index 0bc996ed..7b596693 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractServiceTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/economy/NpcDemandContractServiceTest.java @@ -1,6 +1,8 @@ package com.talhanation.bannermod.settlement.economy; +import com.talhanation.bannermod.governance.BannerModTreasuryManager; import com.talhanation.bannermod.settlement.SettlementDesiredGoodsSnapshot; +import com.talhanation.bannermod.settlement.SettlementManager; import com.talhanation.bannermod.settlement.SettlementMarketRecord; import com.talhanation.bannermod.settlement.SettlementMarketState; import com.talhanation.bannermod.settlement.SettlementProjectCandidateSnapshot; @@ -10,13 +12,16 @@ import com.talhanation.bannermod.settlement.SettlementSupplySignalState; import com.talhanation.bannermod.settlement.SettlementTradeRouteHandoffSnapshot; 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.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class NpcDemandContractServiceTest { @@ -100,6 +105,84 @@ void contractsAndScheduleSurviveNbtRoundTrip() { assertEquals(2, loaded.contracts(CLAIM).size()); } + @Test + void completionConsumesRequestedBucketPaysRewardAndPreventsSecondPayout() { + NpcDemandContractService service = new NpcDemandContractService(); + service.tickClaim(snapshot(SettlementMarketState.empty(), SettlementTradeRouteHandoffSnapshot.empty()), 0L); + NpcDemandContract contract = service.contracts(CLAIM).get(0); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + SettlementManager settlements = new SettlementManager(); + settlements.putSnapshot(SettlementSnapshot.create(CLAIM, new ChunkPos(1, 2), "blueguild")); + StrategicResourceAccountingManager accounting = new StrategicResourceAccountingManager(); + StrategicResourceBucket requestedBucket = bucketFor(contract); + accounting.credit(CLAIM, requestedBucket, contract.amount(), 10L); + + NpcDemandContractService.CompletionResult result = service.completeContract( + treasury, + settlements, + accounting, + CLAIM, + contract.contractUuid(), + 20L + ); + + assertTrue(result.success()); + assertEquals(0, accounting.getAccount(CLAIM).balance(requestedBucket)); + assertEquals(contract.rewardCoins(), treasury.getLedger(CLAIM).treasuryBalance()); + assertEquals(NpcDemandContract.Status.FULFILLED, service.contracts(CLAIM).get(0).status()); + + NpcDemandContractService.CompletionResult second = service.completeContract( + treasury, + settlements, + accounting, + CLAIM, + contract.contractUuid(), + 30L + ); + + assertFalse(second.success()); + assertEquals("contract_fulfilled", second.reason()); + assertEquals(0, accounting.getAccount(CLAIM).balance(requestedBucket)); + assertEquals(contract.rewardCoins(), treasury.getLedger(CLAIM).treasuryBalance()); + } + + @Test + void failedCompletionLeavesGoodsAndRewardsUnchanged() { + NpcDemandContractService service = new NpcDemandContractService(); + service.tickClaim(snapshot(SettlementMarketState.empty(), SettlementTradeRouteHandoffSnapshot.empty()), 0L); + NpcDemandContract contract = service.contracts(CLAIM).get(0); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + SettlementManager settlements = new SettlementManager(); + settlements.putSnapshot(SettlementSnapshot.create(CLAIM, new ChunkPos(1, 2), "blueguild")); + StrategicResourceAccountingManager accounting = new StrategicResourceAccountingManager(); + StrategicResourceBucket requestedBucket = bucketFor(contract); + accounting.credit(CLAIM, requestedBucket, contract.amount() - 1, 10L); + + NpcDemandContractService.CompletionResult result = service.completeContract( + treasury, + settlements, + accounting, + CLAIM, + contract.contractUuid(), + 20L + ); + + assertFalse(result.success()); + assertEquals("shortage", result.reason()); + assertEquals(contract.amount() - 1, accounting.getAccount(CLAIM).balance(requestedBucket)); + assertNull(treasury.getLedger(CLAIM)); + assertEquals(NpcDemandContract.Status.OPEN, service.contracts(CLAIM).get(0).status()); + } + + private static StrategicResourceBucket bucketFor(NpcDemandContract contract) { + for (StrategicResourceBucket bucket : StrategicResourceBucket.values()) { + if (bucket.id().equals(contract.resourceBucket())) { + return bucket; + } + } + throw new AssertionError("Unknown contract bucket " + contract.resourceBucket()); + } + private static SettlementSnapshot snapshot(SettlementMarketState marketState, SettlementTradeRouteHandoffSnapshot tradeRouteHandoffSnapshot) { return new SettlementSnapshot( diff --git a/src/test/java/com/talhanation/bannermod/settlement/runtime/SettlementTreasuryDerivationServiceTest.java b/src/test/java/com/talhanation/bannermod/settlement/runtime/SettlementTreasuryDerivationServiceTest.java index ace89729..eac4145c 100644 --- a/src/test/java/com/talhanation/bannermod/settlement/runtime/SettlementTreasuryDerivationServiceTest.java +++ b/src/test/java/com/talhanation/bannermod/settlement/runtime/SettlementTreasuryDerivationServiceTest.java @@ -97,6 +97,33 @@ void unpaidRecruitUpkeepDerivesTreasuryDebitInSameAccountingPass() { assertEquals(100L, rollup.accountingTick()); } + @Test + void coarseArmyUpkeepRequestDebitsTreasuryEvenWhenIndividualPaymentIsCurrent() { + UUID claimUuid = UUID.randomUUID(); + BannerModGovernorSnapshot snapshot = BannerModGovernorSnapshot.create(claimUuid, new ChunkPos(4, 7), "blueguild"); + BannerModTreasuryManager treasuryManager = new BannerModTreasuryManager(); + BannerModSettlementBinding.Binding binding = new BannerModSettlementBinding.Binding(BannerModSettlementBinding.Status.FRIENDLY_CLAIM, "blueguild", "blueguild"); + BannerModGovernorHeartbeat.HeartbeatReport report = new BannerModGovernorHeartbeat.HeartbeatReport(6, 12, 12, List.of(), List.of(), 100L, 100L); + + BannerModTreasuryLedgerSnapshot.FiscalRollup rollup = SettlementTreasuryDerivationService.deriveHeartbeatAccounting( + treasuryManager, + snapshot, + binding, + report, + stableRecruitSupply(), + 5 + ); + + BannerModTreasuryLedgerSnapshot ledger = treasuryManager.getLedger(claimUuid); + assertNotNull(ledger); + assertNotNull(rollup); + assertEquals(12, ledger.accruedTaxes()); + assertEquals(5, ledger.spentArmyUpkeep()); + assertEquals(7, ledger.treasuryBalance()); + assertEquals(5, ledger.lastArmyUpkeepDebitAmount()); + assertEquals(7, rollup.treasuryBalance()); + } + @Test void fiscalRollupCanBeAppliedToGovernorSnapshot() { UUID claimUuid = UUID.randomUUID(); diff --git a/src/test/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveRuntimeTest.java b/src/test/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveRuntimeTest.java new file mode 100644 index 00000000..3deebcd6 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/war/runtime/EconomicObjectiveRuntimeTest.java @@ -0,0 +1,56 @@ +package com.talhanation.bannermod.war.runtime; + +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class EconomicObjectiveRuntimeTest { + @Test + void mineDisputeMarksMineContestedUntilResolved() { + EconomicObjectiveRuntime runtime = new EconomicObjectiveRuntime(); + UUID claimUuid = UUID.randomUUID(); + UUID mineSiteId = UUID.randomUUID(); + + EconomicObjectiveRecord objective = runtime.createMineDispute(UUID.randomUUID(), null, claimUuid, mineSiteId, 10L, 40L); + + assertEquals(EconomicObjectiveState.NORMAL, runtime.stateForMine(claimUuid, mineSiteId, 9L)); + assertEquals(EconomicObjectiveState.CONTESTED, runtime.stateForMine(claimUuid, mineSiteId, 20L)); + assertTrue(runtime.resolve(objective.id(), 25L)); + assertEquals(EconomicObjectiveState.NORMAL, runtime.stateForMine(claimUuid, mineSiteId, 26L)); + } + + @Test + void routeAndStorageBlockadesMarkSupplyBlockedUntilExpiry() { + EconomicObjectiveRuntime runtime = new EconomicObjectiveRuntime(); + UUID claimUuid = UUID.randomUUID(); + UUID routeId = UUID.randomUUID(); + UUID storageId = UUID.randomUUID(); + + runtime.createBlockade(null, UUID.randomUUID(), claimUuid, EconomicObjectiveTargetKind.ROUTE, routeId, 10L, 30L); + runtime.createBlockade(null, UUID.randomUUID(), claimUuid, EconomicObjectiveTargetKind.STORAGE, storageId, 10L, 30L); + + assertEquals(EconomicObjectiveState.BLOCKED, runtime.stateForRoute(claimUuid, routeId, 20L)); + assertEquals(EconomicObjectiveState.BLOCKED, runtime.stateForStorage(claimUuid, storageId, 20L)); + assertEquals(EconomicObjectiveState.NORMAL, runtime.stateForRoute(claimUuid, routeId, 30L)); + assertEquals(2, runtime.all().size()); + assertEquals(2, runtime.pruneExpired(30L)); + assertEquals(0, runtime.all().size()); + } + + @Test + void raidStateSurvivesPersistenceRoundTrip() { + EconomicObjectiveRuntime runtime = new EconomicObjectiveRuntime(); + UUID claimUuid = UUID.randomUUID(); + UUID storageId = UUID.randomUUID(); + + runtime.createRaid(UUID.randomUUID(), null, claimUuid, EconomicObjectiveTargetKind.STORAGE, storageId, 1L, 100L); + EconomicObjectiveRuntime loaded = EconomicObjectiveRuntime.fromTag(runtime.toTag()); + + assertEquals(EconomicObjectiveState.RAIDED, loaded.stateForStorage(claimUuid, storageId, 50L)); + assertFalse(loaded.all().isEmpty()); + } +} diff --git a/src/test/java/com/talhanation/bannermod/war/runtime/TreatyRuntimeTest.java b/src/test/java/com/talhanation/bannermod/war/runtime/TreatyRuntimeTest.java new file mode 100644 index 00000000..6a97367f --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/war/runtime/TreatyRuntimeTest.java @@ -0,0 +1,117 @@ +package com.talhanation.bannermod.war.runtime; + +import com.talhanation.bannermod.governance.BannerModTreasuryManager; +import com.talhanation.bannermod.persistence.military.RecruitsClaim; +import com.talhanation.bannermod.persistence.military.RecruitsClaimManager; +import com.talhanation.bannermod.settlement.economy.StrategicResourceAccountingManager; +import com.talhanation.bannermod.settlement.economy.StrategicResourceBucket; +import com.talhanation.bannermod.war.audit.WarAuditLogSavedData; +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.assertTrue; + +class TreatyRuntimeTest { + private static RecruitsClaim claim(String name, UUID owner, ChunkPos pos) { + RecruitsClaim claim = new RecruitsClaim(name, owner); + claim.setCenter(pos); + claim.addChunk(pos); + return claim; + } + + @Test + void dueTributeTransfersTreasuryFunds() { + UUID payer = UUID.randomUUID(); + UUID receiver = UUID.randomUUID(); + TreatyRuntime treaties = new TreatyRuntime(); + TributeTreatyRecord treaty = treaties.addTribute(payer, receiver, StrategicResourceBucket.COINS, + 10, 100L, UUID.randomUUID(), null, 0L); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + RecruitsClaimManager claims = new RecruitsClaimManager(); + RecruitsClaim payerClaim = claim("payer", payer, new ChunkPos(0, 0)); + RecruitsClaim receiverClaim = claim("receiver", receiver, new ChunkPos(10, 10)); + claims.testInsertClaim(payerClaim); + claims.testInsertClaim(receiverClaim); + treasury.depositTaxes(payerClaim.getUUID(), payerClaim.getCenter(), null, 25, 0L); + + TreatyPaymentRuntime payments = new TreatyPaymentRuntime(treaties, treasury, + new StrategicResourceAccountingManager(), claims, new WarAuditLogSavedData()); + payments.processDue(100L); + + assertEquals(15, treasury.getLedger(payerClaim.getUUID()).treasuryBalance()); + assertEquals(10, treasury.getLedger(receiverClaim.getUUID()).treasuryBalance()); + assertEquals(100L, treaties.tributeTreaties().iterator().next().lastPaidAtGameTime()); + assertEquals(treaty.id(), treaties.tributeTreaties().iterator().next().id()); + } + + @Test + void missingTributeRecordsDefaultFactAndMissedPayment() { + UUID payer = UUID.randomUUID(); + UUID receiver = UUID.randomUUID(); + TreatyRuntime treaties = new TreatyRuntime(); + treaties.addTribute(payer, receiver, StrategicResourceBucket.COINS, + 20, 100L, UUID.randomUUID(), null, 0L); + BannerModTreasuryManager treasury = new BannerModTreasuryManager(); + RecruitsClaimManager claims = new RecruitsClaimManager(); + RecruitsClaim payerClaim = claim("payer", payer, new ChunkPos(0, 0)); + RecruitsClaim receiverClaim = claim("receiver", receiver, new ChunkPos(10, 10)); + claims.testInsertClaim(payerClaim); + claims.testInsertClaim(receiverClaim); + treasury.depositTaxes(payerClaim.getUUID(), payerClaim.getCenter(), null, 5, 0L); + + TreatyPaymentRuntime payments = new TreatyPaymentRuntime(treaties, treasury, + new StrategicResourceAccountingManager(), claims, new WarAuditLogSavedData()); + payments.processDue(100L); + + TributeTreatyRecord updated = treaties.tributeTreaties().iterator().next(); + assertEquals(1, updated.missedPayments()); + assertEquals(15, updated.defaultedAmount()); + assertEquals(1, treaties.defaultFacts().size()); + TreatyDefaultFact fact = treaties.defaultFacts().iterator().next(); + assertEquals(20, fact.requestedAmount()); + assertEquals(5, fact.paidAmount()); + assertEquals(15, fact.defaultedAmount()); + assertEquals(100L, updated.lastPaidAtGameTime()); + } + + @Test + void dueTributeTransfersStrategicResources() { + UUID payer = UUID.randomUUID(); + UUID receiver = UUID.randomUUID(); + TreatyRuntime treaties = new TreatyRuntime(); + treaties.addTribute(payer, receiver, StrategicResourceBucket.IRON, + 8, 100L, UUID.randomUUID(), null, 0L); + RecruitsClaimManager claims = new RecruitsClaimManager(); + RecruitsClaim payerClaim = claim("payer", payer, new ChunkPos(0, 0)); + RecruitsClaim receiverClaim = claim("receiver", receiver, new ChunkPos(10, 10)); + claims.testInsertClaim(payerClaim); + claims.testInsertClaim(receiverClaim); + StrategicResourceAccountingManager resources = new StrategicResourceAccountingManager(); + resources.credit(payerClaim.getUUID(), StrategicResourceBucket.IRON, 12, 0L); + + TreatyPaymentRuntime payments = new TreatyPaymentRuntime(treaties, new BannerModTreasuryManager(), + resources, claims, new WarAuditLogSavedData()); + payments.processDue(100L); + + assertEquals(4, resources.getAccount(payerClaim.getUUID()).balance(StrategicResourceBucket.IRON)); + assertEquals(8, resources.getAccount(receiverClaim.getUUID()).balance(StrategicResourceBucket.IRON)); + assertEquals(0, treaties.defaultFacts().size()); + } + + @Test + void vassalRecordIdentifiesPartiesAndObligations() { + UUID overlord = UUID.randomUUID(); + UUID vassal = UUID.randomUUID(); + TreatyRuntime treaties = new TreatyRuntime(); + VassalRelationshipRecord relationship = treaties.addVassalRelationship(overlord, vassal, + "military_support,overlord_diplomacy", 3, 100L, UUID.randomUUID(), 0L); + + assertEquals(overlord, relationship.overlordEntityId()); + assertEquals(vassal, relationship.vassalEntityId()); + assertTrue(relationship.obligations().contains("military_support")); + assertEquals(1, treaties.vassalRelationships().size()); + } +}