From f091ab66995949ddd649c60b8326e6e701cc96ec Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Fri, 6 Nov 2026 06:08:51 +0200 Subject: [PATCH 1/2] Follow the village when listing trades, and bind an automaton to the job site above it. Co-authored-by: Cursor --- CHANGELOG.md | 3 + README.md | 4 +- .../gametest/FabricMarketGameTests.java | 19 +++- .../entity/MarketAutomatonBlockEntity.java | 28 +++++- .../client/MarketAutomatonScreen.java | 38 +++++--- .../client/MarketScreenLogic.java | 16 ++-- .../gametest/MarketGameTests.java | 84 ++++++++++++++++-- .../menu/MarketAutomatonMenu.java | 7 +- .../menu/VillagerMarketMenu.java | 7 +- .../villagermarket/search/JobSites.java | 14 +++ .../villagermarket/search/MarketSearch.java | 88 ++++++++++++++++--- .../villagermarket/search/Section.java | 11 +++ .../search/VillageSections.java | 34 +++++++ .../villagermarket/trade/TradeExecutor.java | 8 +- .../search/VillageSectionsTest.java | 72 +++++++++++++++ .../client/MarketAutomatonScreen.java | 38 +++++--- 16 files changed, 411 insertions(+), 60 deletions(-) create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/search/JobSites.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/search/Section.java create mode 100644 src/main/java/net/modcrafters/minecraft/villagermarket/search/VillageSections.java create mode 100644 src/test/java/net/modcrafters/minecraft/villagermarket/search/VillageSectionsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 016e0d4..a913fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ After publishing, the action prepares the next development version and archives ## Unreleased +- The market and the automaton follow the village. A villager counts while their bed, bell, or job site sits in that village, wherever they are standing. +- An automaton placed directly under a job site trades only with the villager who claimed it. The window lists just that villager's offers. + ## 2.0.1 - The villager faces sit in the barrel instead of sticking out. The market opening is an oval. The automaton opening is an oval over a square cut, and its bottom stays solid. The nose is narrower. diff --git a/README.md b/README.md index d2f4dbc..7de238a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ Trade with nearby villagers from one block, or let a Market Automaton trade for you. -The Villager Market looks for villagers within 48 blocks, and 16 blocks up or down. Their offers are grouped by a custom name when they have one, and by profession otherwise. Show All, on by default, lists offers you cannot pay for yet. x1 buys the selected offer once. xN buys as many as you can pay for and the offer still has left. The window closes when you walk more than 8 blocks away. +The Villager Market follows the village around the block. A villager counts while their bed, bell, or job site sits in that village, wherever they are standing. Offers are grouped by a custom name when they have one, and by profession otherwise. Show All, on by default, lists offers you cannot pay for yet. x1 buys the selected offer once. xN buys as many as you can pay for and the offer still has left. The window closes when you walk more than 8 blocks away. + +The Market Automaton trades on its own. Choose an offer, and hoppers can fill the payment and take the result from the bottom. Put it directly under a job site and it trades only with the villager who claimed that block. ## Jars diff --git a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/gametest/FabricMarketGameTests.java b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/gametest/FabricMarketGameTests.java index 3534887..8ddb16a 100644 --- a/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/gametest/FabricMarketGameTests.java +++ b/fabric/src/main/java/net/modcrafters/minecraft/villagermarket/fabric/gametest/FabricMarketGameTests.java @@ -16,8 +16,13 @@ public void componentsMustMatch(GameTestHelper helper) { } @GameTest(structure = "villagermarket:wide") - public void villagerOutsideTheBoxIsNotTraded(GameTestHelper helper) { - MarketGameTests.villagerOutsideTheBoxIsNotTraded(helper); + public void villagerOutsideTheVillageIsNotTraded(GameTestHelper helper) { + MarketGameTests.villagerOutsideTheVillageIsNotTraded(helper); + } + + @GameTest(structure = "villagermarket:wide") + public void distantVillagerWithAVillageJobIsTraded(GameTestHelper helper) { + MarketGameTests.distantVillagerWithAVillageJobIsTraded(helper); } @GameTest(structure = "villagermarket:wide") @@ -49,4 +54,14 @@ public void automatonStopsWithoutRedstone(GameTestHelper helper) { public void automatonBuysTheCheapestSimilar(GameTestHelper helper) { MarketGameTests.automatonBuysTheCheapestSimilar(helper); } + + @GameTest(structure = "villagermarket:wide") + public void automatonUnderAJobSiteTradesWithItsOwner(GameTestHelper helper) { + MarketGameTests.automatonUnderAJobSiteTradesWithItsOwner(helper); + } + + @GameTest(structure = "villagermarket:wide") + public void unclaimedJobSiteDoesNotTrade(GameTestHelper helper) { + MarketGameTests.unclaimedJobSiteDoesNotTrade(helper); + } } diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/MarketAutomatonBlockEntity.java b/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/MarketAutomatonBlockEntity.java index 81d3edc..98246dc 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/MarketAutomatonBlockEntity.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/block/entity/MarketAutomatonBlockEntity.java @@ -3,9 +3,9 @@ import net.modcrafters.minecraft.villagermarket.ModContent; import net.modcrafters.minecraft.villagermarket.menu.MachineSlots; import net.modcrafters.minecraft.villagermarket.menu.MarketAutomatonMenu; -import net.modcrafters.minecraft.villagermarket.menu.VillagerMarketMenu; import net.modcrafters.minecraft.villagermarket.network.MarketPayloads; import net.modcrafters.minecraft.villagermarket.platform.Services; +import net.modcrafters.minecraft.villagermarket.search.JobSites; import net.modcrafters.minecraft.villagermarket.search.MarketSearch; import net.modcrafters.minecraft.villagermarket.trade.ArmedTrade; import net.modcrafters.minecraft.villagermarket.trade.ListedOffer; @@ -47,13 +47,19 @@ public class MarketAutomatonBlockEntity extends BlockEntity implements WorldlyCo private RedstoneMode redstone = RedstoneMode.IGNORED; private boolean similar; private int ticks; + private boolean sawBinding; + private boolean bound; public MarketAutomatonBlockEntity(BlockPos pos, BlockState state) { super(ModContent.automatonType(), pos, state); } public static void serverTick(Level level, BlockPos pos, BlockState state, MarketAutomatonBlockEntity machine) { - if (!(level instanceof ServerLevel server) || !machine.hasRecipe() || !machine.allowsWork(level)) { + if (!(level instanceof ServerLevel server)) { + return; + } + machine.watchBinding(server, pos); + if (!machine.hasRecipe() || !machine.allowsWork(level)) { return; } if (machine.ticks > 0) { @@ -127,7 +133,7 @@ private boolean trade(ServerLevel server, BlockPos pos) { MerchantOffer chosen = null; int bestA = Integer.MAX_VALUE; int bestB = Integer.MAX_VALUE; - for (Villager villager : MarketSearch.find(server, pos)) { + for (Villager villager : MarketSearch.forAutomaton(server, pos)) { var offers = villager.getOffers(); for (int index = 0; index < offers.size(); index++) { MerchantOffer offer = offers.get(index); @@ -157,8 +163,22 @@ private boolean trade(ServerLevel server, BlockPos pos) { return true; } + private void watchBinding(ServerLevel server, BlockPos pos) { + boolean now = JobSites.workstation(server.getBlockState(pos.above())); + if (!sawBinding) { + sawBinding = true; + bound = now; + return; + } + if (now == bound) { + return; + } + bound = now; + publishOffers(server); + } + private void publishOffers(ServerLevel server) { - List fresh = VillagerMarketMenu.scan(server, this.worldPosition); + List fresh = MarketAutomatonMenu.scan(server, this.worldPosition); for (ServerPlayer player : server.players()) { if (player.containerMenu instanceof MarketAutomatonMenu menu && menu.pos().equals(this.worldPosition)) { menu.replace(fresh); diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java b/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java index 3a5f42e..b4eb53e 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java @@ -2,6 +2,7 @@ import net.modcrafters.minecraft.villagermarket.block.entity.RedstoneMode; import net.modcrafters.minecraft.villagermarket.menu.MachineSlots; +import net.modcrafters.minecraft.villagermarket.search.JobSites; import net.modcrafters.minecraft.villagermarket.menu.MarketAutomatonMenu; import net.modcrafters.minecraft.villagermarket.network.AutomatonPayloads; import net.modcrafters.minecraft.villagermarket.network.MarketPayloads; @@ -76,39 +77,45 @@ public static void applyOffers(MarketPayloads.Offers payload) { @Override public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { super.extractBackground(graphics, mouseX, mouseY, partialTick); + bind(); int left = this.leftPos; int top = this.topPos; drawPanel(graphics, left, top); graphics.text(this.font, this.title, left + 8, top + 6, LABEL, false); - List groups = logic.groups(this.menu.offers()); - graphics.enableScissor(left + MarketScreenLogic.GROUP_X, top + MarketScreenLogic.GROUP_Y, - left + MarketScreenLogic.GROUP_X + MarketScreenLogic.GROUP_W, top + MarketScreenLogic.GROUP_Y + logic.listHeight); - drawGroup(graphics, left, top, mouseX, mouseY, 0, logic.selectedGroup.isEmpty(), Component.translatable("gui.villagermarket.all")); - for (int i = 0; i < groups.size(); i++) { - drawGroup(graphics, left, top, mouseX, mouseY, i + 1, groups.get(i).equals(logic.selectedGroup), VillagerMarketScreen.groupLabel(groups.get(i))); + if (!logic.singleVillager) { + List groups = logic.groups(this.menu.offers()); + graphics.enableScissor(left + MarketScreenLogic.GROUP_X, top + MarketScreenLogic.GROUP_Y, + left + MarketScreenLogic.GROUP_X + MarketScreenLogic.GROUP_W, top + MarketScreenLogic.GROUP_Y + logic.listHeight); + drawGroup(graphics, left, top, mouseX, mouseY, 0, logic.selectedGroup.isEmpty(), Component.translatable("gui.villagermarket.all")); + for (int i = 0; i < groups.size(); i++) { + drawGroup(graphics, left, top, mouseX, mouseY, i + 1, groups.get(i).equals(logic.selectedGroup), VillagerMarketScreen.groupLabel(groups.get(i))); + } + graphics.disableScissor(); } - graphics.disableScissor(); hovered = ItemStack.EMPTY; tip = Component.empty(); Inventory inventory = this.minecraft.player.getInventory(); List shown = logic.visible(this.menu.offers(), inventory); logic.fitOffer(shown.size()); + int offerX = logic.offerX(); int offerW = logic.offerWidth(shown.size()); - graphics.enableScissor(left + MarketScreenLogic.OFFER_X, top + MarketScreenLogic.OFFER_Y, - left + MarketScreenLogic.OFFER_X + offerW, top + MarketScreenLogic.OFFER_Y + logic.listHeight); + graphics.enableScissor(left + offerX, top + MarketScreenLogic.OFFER_Y, + left + offerX + offerW, top + MarketScreenLogic.OFFER_Y + logic.listHeight); for (int i = logic.offerScroll; i < shown.size(); i++) { int y = top + MarketScreenLogic.OFFER_Y + (i - logic.offerScroll) * MarketScreenLogic.OFFER_ROW; if (y + MarketScreenLogic.OFFER_ROW > top + MarketScreenLogic.OFFER_Y + logic.listHeight) { break; } - drawOffer(graphics, shown.get(i), left + MarketScreenLogic.OFFER_X, y, offerW, mouseX, mouseY, inventory); + drawOffer(graphics, shown.get(i), left + offerX, y, offerW, mouseX, mouseY, inventory); } graphics.disableScissor(); drawOfferBar(graphics, left, top, shown.size()); + if (!logic.singleVillager) { + drawSimilar(graphics, left, top, mouseX, mouseY); + } Component show = Component.translatable(logic.showAll ? "gui.villagermarket.show_all" : "gui.villagermarket.available"); drawButton(graphics, left + MarketScreenLogic.SHOW_X, top + MachineSlots.SHOW_Y, MarketScreenLogic.SHOW_W, MarketScreenLogic.SHOW_H, show, logic.showAll || inside(mouseX, mouseY, left + MarketScreenLogic.SHOW_X, top + MachineSlots.SHOW_Y, MarketScreenLogic.SHOW_W, MarketScreenLogic.SHOW_H)); - drawSimilar(graphics, left, top, mouseX, mouseY); drawSlot(graphics, left + MachineSlots.INPUT_X - 1, top + MachineSlots.INPUT_Y - 1); drawSlot(graphics, left + MachineSlots.INPUT_X + 17, top + MachineSlots.INPUT_Y - 1); graphics.blitSprite(RenderPipelines.GUI_TEXTURED, FLOW_ARROW, left + MachineSlots.ARROW_X, top + MachineSlots.INPUT_Y, MachineSlots.ARROW_W, MachineSlots.ARROW_H); @@ -228,8 +235,13 @@ private void drawStack(GuiGraphicsExtractor graphics, ItemStack stack, int x, in } } + private void bind() { + logic.singleVillager = this.minecraft.level != null + && JobSites.workstation(this.minecraft.level.getBlockState(this.menu.pos().above())); + } + private boolean similarHit(double mouseX, double mouseY) { - return inside((int) mouseX, (int) mouseY, this.leftPos + MachineSlots.SIMILAR_X, this.topPos + MachineSlots.SIMILAR_Y, MachineSlots.SIMILAR_SIZE, MachineSlots.SIMILAR_SIZE); + return !logic.singleVillager && inside((int) mouseX, (int) mouseY, this.leftPos + MachineSlots.SIMILAR_X, this.topPos + MachineSlots.SIMILAR_Y, MachineSlots.SIMILAR_SIZE, MachineSlots.SIMILAR_SIZE); } private boolean redstoneHit(double mouseX, double mouseY) { @@ -256,6 +268,7 @@ public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mo @Override public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + bind(); if (similarHit(event.x(), event.y())) { Services.PLATFORM.sendToServer(new AutomatonPayloads.Similar()); return true; @@ -285,6 +298,7 @@ public boolean mouseReleased(MouseButtonEvent event) { @Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + bind(); List shown = logic.visible(this.menu.offers(), this.minecraft.player.getInventory()); logic.scroll(mouseX, mouseY, this.leftPos, this.topPos, scrollY, shown.size(), logic.groups(this.menu.offers()).size()); return true; diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketScreenLogic.java b/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketScreenLogic.java index 146c042..dbb27eb 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketScreenLogic.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketScreenLogic.java @@ -42,6 +42,7 @@ public final class MarketScreenLogic { public int listHeight = OFFER_H; public int showY = SHOW_Y; public boolean showAll = true; + public boolean singleVillager; public String selectedGroup = ""; public int groupScroll; public int offerScroll; @@ -84,8 +85,13 @@ public boolean offerBar(int count) { return count > offerRows(); } + public int offerX() { + return singleVillager ? GROUP_X : OFFER_X; + } + public int offerWidth(int count) { - return offerBar(count) ? BAR_X - OFFER_X - 2 : OFFER_W; + int right = offerBar(count) ? BAR_X - 2 : WIDTH - 8; + return right - offerX(); } public void fitOffer(int count) { @@ -184,7 +190,7 @@ public Click browse(double mouseX, double mouseY, int left, int top, List offers, Inventory inventory) { - if (inside(x, y, GROUP_X, GROUP_Y, GROUP_W, listHeight)) { + if (!singleVillager && inside(x, y, GROUP_X, GROUP_Y, GROUP_W, listHeight)) { int index = groupScroll + (y - GROUP_Y) / GROUP_ROW; List groups = groups(offers); if (index == 0) { @@ -199,7 +205,7 @@ private Click browseAt(int x, int y, List offers, Inventory invento if (pressBar(x, y, shown.size())) { return Click.BAR; } - if (inside(x, y, OFFER_X, OFFER_Y, offerWidth(shown.size()), listHeight)) { + if (inside(x, y, offerX(), OFFER_Y, offerWidth(shown.size()), listHeight)) { int index = offerScroll + (y - OFFER_Y) / OFFER_ROW; selected = index >= 0 && index < shown.size() ? shown.get(index) : null; return Click.OFFER; @@ -211,10 +217,10 @@ public void scroll(double mouseX, double mouseY, int left, int top, double delta int x = (int) mouseX - left; int y = (int) mouseY - top; int step = delta > 0 ? -1 : 1; - if (inside(x, y, GROUP_X, GROUP_Y, GROUP_W, listHeight)) { + if (!singleVillager && inside(x, y, GROUP_X, GROUP_Y, GROUP_W, listHeight)) { int visible = Math.max(1, listHeight / GROUP_ROW); groupScroll = clamp(groupScroll + step, groupCount + 1 - visible); - } else if (inside(x, y, OFFER_X, OFFER_Y, BAR_X + BAR_W - OFFER_X, listHeight)) { + } else if (inside(x, y, offerX(), OFFER_Y, BAR_X + BAR_W - offerX(), listHeight)) { offerScroll = clamp(offerScroll + step, offerCount - offerRows()); } } diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/gametest/MarketGameTests.java b/src/main/java/net/modcrafters/minecraft/villagermarket/gametest/MarketGameTests.java index 07b5355..17f56b6 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/gametest/MarketGameTests.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/gametest/MarketGameTests.java @@ -4,10 +4,11 @@ import net.modcrafters.minecraft.villagermarket.block.entity.MarketAutomatonBlockEntity; import net.modcrafters.minecraft.villagermarket.block.entity.RedstoneMode; import net.modcrafters.minecraft.villagermarket.menu.MachineSlots; -import net.modcrafters.minecraft.villagermarket.search.MarketSearch; import net.modcrafters.minecraft.villagermarket.trade.MainSlots; import net.modcrafters.minecraft.villagermarket.trade.TradeExecutor; import net.minecraft.core.BlockPos; +import net.minecraft.core.GlobalPos; +import net.minecraft.world.entity.ai.memory.MemoryModuleType; import net.minecraft.core.Direction; import net.minecraft.core.component.DataComponents; import net.minecraft.gametest.framework.GameTestHelper; @@ -42,13 +43,16 @@ public record Case(String name, Consumer body) { public static final List CASES = List.of( new Case("one_emerald_buys_bread", MarketGameTests::oneEmeraldBuysBread), new Case("components_must_match", MarketGameTests::componentsMustMatch), - new Case("villager_outside_the_box_is_not_traded", MarketGameTests::villagerOutsideTheBoxIsNotTraded), + new Case("villager_outside_the_village_is_not_traded", MarketGameTests::villagerOutsideTheVillageIsNotTraded), + new Case("distant_villager_with_a_village_job_is_traded", MarketGameTests::distantVillagerWithAVillageJobIsTraded), new Case("adjusted_price_is_what_is_charged", MarketGameTests::adjustedPriceIsWhatIsCharged), new Case("experience_lands_on_the_player", MarketGameTests::experienceLandsOnThePlayer), new Case("automaton_trades_from_its_slots", MarketGameTests::automatonTradesFromItsSlots), new Case("automaton_rejects_a_different_book", MarketGameTests::automatonRejectsADifferentBook), new Case("automaton_stops_without_redstone", MarketGameTests::automatonStopsWithoutRedstone), - new Case("automaton_buys_the_cheapest_similar", MarketGameTests::automatonBuysTheCheapestSimilar) + new Case("automaton_buys_the_cheapest_similar", MarketGameTests::automatonBuysTheCheapestSimilar), + new Case("automaton_under_a_job_site_trades_with_its_owner", MarketGameTests::automatonUnderAJobSiteTradesWithItsOwner), + new Case("unclaimed_job_site_does_not_trade", MarketGameTests::unclaimedJobSiteDoesNotTrade) ); private MarketGameTests() { @@ -80,17 +84,30 @@ public static void componentsMustMatch(GameTestHelper helper) { helper.succeed(); } - public static void villagerOutsideTheBoxIsNotTraded(GameTestHelper helper) { + public static void villagerOutsideTheVillageIsNotTraded(GameTestHelper helper) { + Villager villager = villager(helper, offer(new ItemCost(Items.EMERALD), new ItemStack(Items.BREAD), 4, 0.0f, 0)); + claim(helper, villager, 65, 1, 1); + ServerPlayer player = player(helper); + player.getInventory().add(new ItemStack(Items.EMERALD)); + + TradeExecutor.Result result = TradeExecutor.execute(player, villager.blockPosition(), villager.getId(), 0, 1); + + helper.assertTrue(result.outOfRange(), "a villager from another hall was traded"); + helper.assertTrue(count(player, Items.EMERALD) == 1, "payment was taken"); + helper.succeed(); + } + + public static void distantVillagerWithAVillageJobIsTraded(GameTestHelper helper) { Villager villager = villager(helper, offer(new ItemCost(Items.EMERALD), new ItemStack(Items.BREAD), 4, 0.0f, 0)); BlockPos market = villager.blockPosition(); - villager.setPos(villager.getX() + MarketSearch.RADIUS + 5, villager.getY(), villager.getZ()); + villager.setPos(villager.getX() + 80, villager.getY(), villager.getZ()); ServerPlayer player = player(helper); player.getInventory().add(new ItemStack(Items.EMERALD)); TradeExecutor.Result result = TradeExecutor.execute(player, market, villager.getId(), 0, 1); - helper.assertTrue(result.outOfRange(), "a villager past the box was traded"); - helper.assertTrue(count(player, Items.EMERALD) == 1, "payment was taken"); + helper.assertTrue(result.performed() == 1, "a villager tied to the village was skipped"); + helper.assertTrue(count(player, Items.BREAD) == 1, "bread " + count(player, Items.BREAD)); helper.succeed(); } @@ -130,7 +147,7 @@ public static void experienceLandsOnThePlayer(GameTestHelper helper) { public static void automatonTradesFromItsSlots(GameTestHelper helper) { BlockPos at = new BlockPos(6, 1, 1); helper.setBlock(at, ModContent.automatonBlock()); - // The game tests share one world, and the search box is 48 blocks, so this trade is diamonds rather than emeralds. + // The game tests share one world, so this trade is diamonds rather than emeralds. Villager villager = villager(helper, offer(new ItemCost(Items.DIAMOND), new ItemStack(Items.BREAD), 2, 0.0f, 0)); MarketAutomatonBlockEntity machine = machine(helper, at); MerchantOffer deal = villager.getOffers().get(0); @@ -215,6 +232,43 @@ public static void automatonBuysTheCheapestSimilar(GameTestHelper helper) { helper.succeed(); } + public static void automatonUnderAJobSiteTradesWithItsOwner(GameTestHelper helper) { + BlockPos at = new BlockPos(6, 1, 1); + helper.setBlock(at, ModContent.automatonBlock()); + MerchantOffer ownerDeal = offer(new ItemCost(Items.PRISMARINE_CRYSTALS), new ItemStack(Items.BREAD), 4, 0.2f, 100); + MerchantOffer neighborDeal = offer(new ItemCost(Items.PRISMARINE_CRYSTALS), new ItemStack(Items.BREAD), 4, 0.0f, 0); + int ownerCount = ownerDeal.getCostA().getCount(); + int neighborCount = neighborDeal.getCostA().getCount(); + helper.assertTrue(ownerCount > neighborCount, "the owner's offer was not more expensive"); + Villager owner = villagerAt(helper, ownerDeal, 2, 2, 1); + claim(helper, owner, 6, 2, 1); + Villager neighbor = villagerAt(helper, neighborDeal, 3, 2, 1); + MarketAutomatonBlockEntity machine = machine(helper, at); + machine.arm(ownerDeal.getCostA(), ItemStack.EMPTY, ownerDeal.getResult()); + machine.setSimilar(true); + machine.setItem(MachineSlots.INPUT_A, new ItemStack(Items.PRISMARINE_CRYSTALS, ownerCount)); + tick(helper, machine, helper.absolutePos(at)); + helper.assertTrue(owner.getOffers().get(0).getUses() == 1, "did not buy from the workstation's owner"); + helper.assertTrue(neighbor.getOffers().get(0).getUses() == 0, "bought from another villager in the village"); + helper.assertTrue(machine.getItem(MachineSlots.INPUT_A).isEmpty(), "did not pay the owner's price"); + helper.succeed(); + } + + public static void unclaimedJobSiteDoesNotTrade(GameTestHelper helper) { + BlockPos at = new BlockPos(6, 1, 1); + helper.setBlock(at, ModContent.automatonBlock()); + helper.setBlock(new BlockPos(6, 2, 1), Blocks.LECTERN); + Villager villager = villager(helper, offer(new ItemCost(Items.RABBIT_FOOT), new ItemStack(Items.BREAD), 4, 0.0f, 0)); + MarketAutomatonBlockEntity machine = machine(helper, at); + MerchantOffer deal = villager.getOffers().get(0); + machine.arm(deal.getCostA(), ItemStack.EMPTY, deal.getResult()); + machine.setItem(MachineSlots.INPUT_A, new ItemStack(Items.RABBIT_FOOT)); + tick(helper, machine, helper.absolutePos(at)); + helper.assertTrue(machine.getItem(MachineSlots.OUTPUT).isEmpty(), "an unclaimed workstation traded"); + helper.assertTrue(villager.getOffers().get(0).getUses() == 0, "the nearby villager was traded"); + helper.succeed(); + } + private static Set orbIds(Level level, AABB area) { Set ids = new HashSet<>(); for (ExperienceOrb orb : level.getEntitiesOfClass(ExperienceOrb.class, area)) { @@ -259,13 +313,25 @@ private static MerchantOffer offer(ItemCost cost, ItemStack result, int maxUses, } private static Villager villager(GameTestHelper helper, MerchantOffer offer) { - Villager villager = helper.spawn(EntityTypes.VILLAGER, 1, 2, 1); + return villagerAt(helper, offer, 1, 2, 1); + } + + private static Villager villagerAt(GameTestHelper helper, MerchantOffer offer, int x, int y, int z) { + Villager villager = helper.spawn(EntityTypes.VILLAGER, x, y, z); MerchantOffers offers = new MerchantOffers(); offers.add(offer); villager.setOffers(offers); + claim(helper, villager, x, y - 1, z); return villager; } + private static void claim(GameTestHelper helper, Villager villager, int x, int y, int z) { + BlockPos relative = new BlockPos(x, y, z); + helper.setBlock(relative, Blocks.LECTERN); + villager.getBrain().setMemory(MemoryModuleType.JOB_SITE, + GlobalPos.of(helper.getLevel().dimension(), helper.absolutePos(relative))); + } + @SuppressWarnings("removal") private static ServerPlayer player(GameTestHelper helper) { //? if >=26.1 { diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MarketAutomatonMenu.java b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MarketAutomatonMenu.java index 29d16de..2bf8cdc 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MarketAutomatonMenu.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/MarketAutomatonMenu.java @@ -3,6 +3,7 @@ import net.modcrafters.minecraft.villagermarket.ModContent; import net.modcrafters.minecraft.villagermarket.block.entity.MarketAutomatonBlockEntity; import net.modcrafters.minecraft.villagermarket.block.entity.RedstoneMode; +import net.modcrafters.minecraft.villagermarket.search.MarketSearch; import net.modcrafters.minecraft.villagermarket.trade.ArmedTrade; import net.modcrafters.minecraft.villagermarket.trade.ListedOffer; import net.minecraft.core.BlockPos; @@ -77,7 +78,11 @@ public static AutomatonOpening opening(ServerLevel level, BlockPos pos) { redstone = machine.redstone().ordinal(); similar = machine.similar(); } - return new AutomatonOpening(pos, VillagerMarketMenu.scan(level, pos), costA, costB, result, redstone, similar); + return new AutomatonOpening(pos, scan(level, pos), costA, costB, result, redstone, similar); + } + + public static List scan(ServerLevel level, BlockPos pos) { + return VillagerMarketMenu.list(MarketSearch.forAutomaton(level, pos)); } public BlockPos pos() { diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/menu/VillagerMarketMenu.java b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/VillagerMarketMenu.java index 299441a..5612c66 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/menu/VillagerMarketMenu.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/menu/VillagerMarketMenu.java @@ -6,6 +6,7 @@ import net.modcrafters.minecraft.villagermarket.trade.ListedOffer; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.npc.villager.Villager; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.AbstractContainerMenu; @@ -36,8 +37,12 @@ public static OpeningData opening(ServerLevel level, BlockPos pos) { } public static List scan(ServerLevel level, BlockPos pos) { + return list(MarketSearch.village(level, pos)); + } + + public static List list(List villagers) { List listed = new ArrayList<>(); - for (var villager : MarketSearch.find(level, pos)) { + for (Villager villager : villagers) { listed.addAll(ListedOffer.from(villager)); } return List.copyOf(listed); diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/search/JobSites.java b/src/main/java/net/modcrafters/minecraft/villagermarket/search/JobSites.java new file mode 100644 index 0000000..b0892a8 --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/search/JobSites.java @@ -0,0 +1,14 @@ +package net.modcrafters.minecraft.villagermarket.search; + +import net.minecraft.tags.PoiTypeTags; +import net.minecraft.world.entity.ai.village.poi.PoiTypes; +import net.minecraft.world.level.block.state.BlockState; + +public final class JobSites { + private JobSites() { + } + + public static boolean workstation(BlockState state) { + return PoiTypes.forState(state).filter(type -> type.is(PoiTypeTags.ACQUIRABLE_JOB_SITE)).isPresent(); + } +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/search/MarketSearch.java b/src/main/java/net/modcrafters/minecraft/villagermarket/search/MarketSearch.java index 6c6eb10..4612b31 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/search/MarketSearch.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/search/MarketSearch.java @@ -1,29 +1,95 @@ package net.modcrafters.minecraft.villagermarket.search; import net.minecraft.core.BlockPos; +import net.minecraft.core.GlobalPos; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.ai.memory.MemoryModuleType; import net.minecraft.world.entity.npc.villager.Villager; -import net.minecraft.world.phys.AABB; -import net.minecraft.world.phys.Vec3; +import net.minecraft.world.level.Level; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; +import java.util.Set; public final class MarketSearch { - public static final int RADIUS = 48; - public static final int HEIGHT = 16; - private MarketSearch() { } - public static AABB box(BlockPos marketPos) { - return new AABB(marketPos).inflate(RADIUS, HEIGHT, RADIUS); + public static boolean serves(ServerLevel level, BlockPos marketPos, Villager villager) { + for (Villager member : village(level, marketPos)) { + if (member == villager) { + return true; + } + } + return false; + } + + public static List village(ServerLevel level, BlockPos marketPos) { + List loaded = level.getEntities(EntityTypes.VILLAGER, Villager::isAlive); + List ties = new ArrayList<>(); + Set
centers = new HashSet<>(); + for (Villager villager : loaded) { + for (BlockPos place : ties(level, villager)) { + Section section = Section.of(place.getX(), place.getY(), place.getZ()); + centers.add(section); + if (!villager.getOffers().isEmpty()) { + ties.add(new Tie(villager, section)); + } + } + } + Set
reached = VillageSections.reached(Section.of(marketPos.getX(), marketPos.getY(), marketPos.getZ()), centers); + LinkedHashSet members = new LinkedHashSet<>(); + for (Tie tie : ties) { + if (reached.contains(tie.section)) { + members.add(tie.villager); + } + } + return List.copyOf(members); + } + + public static List forAutomaton(ServerLevel level, BlockPos pos) { + BlockPos above = pos.above(); + if (!JobSites.workstation(level.getBlockState(above))) { + return village(level, pos); + } + Villager owner = owner(level, above); + if (owner == null) { + return List.of(); + } + return List.of(owner); + } + + private static Villager owner(ServerLevel level, BlockPos workstation) { + GlobalPos site = GlobalPos.of(level.dimension(), workstation); + for (Villager villager : level.getEntities(EntityTypes.VILLAGER, Villager::isAlive)) { + Optional job = villager.getBrain().getMemory(MemoryModuleType.JOB_SITE); + if (job.isPresent() && job.get().equals(site) && !villager.getOffers().isEmpty()) { + return villager; + } + } + return null; + } + + private static List ties(Level level, Villager villager) { + List places = new ArrayList<>(3); + remember(level, villager, MemoryModuleType.HOME, places); + remember(level, villager, MemoryModuleType.MEETING_POINT, places); + remember(level, villager, MemoryModuleType.JOB_SITE, places); + return places; } - public static boolean contains(BlockPos marketPos, Vec3 villagerPos) { - return box(marketPos).contains(villagerPos); + private static void remember(Level level, Villager villager, MemoryModuleType memory, List places) { + villager.getBrain().getMemory(memory).ifPresent(global -> { + if (global.dimension().equals(level.dimension())) { + places.add(global.pos()); + } + }); } - public static List find(ServerLevel level, BlockPos marketPos) { - return level.getEntitiesOfClass(Villager.class, box(marketPos), villager -> villager.isAlive() && !villager.getOffers().isEmpty()); + private record Tie(Villager villager, Section section) { } } diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/search/Section.java b/src/main/java/net/modcrafters/minecraft/villagermarket/search/Section.java new file mode 100644 index 0000000..367f7f9 --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/search/Section.java @@ -0,0 +1,11 @@ +package net.modcrafters.minecraft.villagermarket.search; + +public record Section(int x, int y, int z) { + public static Section of(int blockX, int blockY, int blockZ) { + return new Section(blockX >> 4, blockY >> 4, blockZ >> 4); + } + + public int distance(Section other) { + return Math.max(Math.abs(x - other.x), Math.max(Math.abs(y - other.y), Math.abs(z - other.z))); + } +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/search/VillageSections.java b/src/main/java/net/modcrafters/minecraft/villagermarket/search/VillageSections.java new file mode 100644 index 0000000..949649b --- /dev/null +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/search/VillageSections.java @@ -0,0 +1,34 @@ +package net.modcrafters.minecraft.villagermarket.search; + +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.Set; + +public final class VillageSections { + private VillageSections() { + } + + // A claimed bed, bell, or job site makes its chunk section a center. The village is the 3×3×3 of sections + // around each center. Centers whose neighborhoods overlap belong to one village, and the market joins the + // village whose neighborhood contains its own section. + public static Set
reached(Section origin, Set
centers) { + Set
included = new HashSet<>(); + ArrayDeque
queue = new ArrayDeque<>(); + for (Section center : centers) { + if (origin.distance(center) <= 1) { + included.add(center); + queue.add(center); + } + } + while (!queue.isEmpty()) { + Section current = queue.removeFirst(); + for (Section center : centers) { + if (!included.contains(center) && current.distance(center) <= 2) { + included.add(center); + queue.add(center); + } + } + } + return included; + } +} diff --git a/src/main/java/net/modcrafters/minecraft/villagermarket/trade/TradeExecutor.java b/src/main/java/net/modcrafters/minecraft/villagermarket/trade/TradeExecutor.java index 98b91c7..43d7ae6 100644 --- a/src/main/java/net/modcrafters/minecraft/villagermarket/trade/TradeExecutor.java +++ b/src/main/java/net/modcrafters/minecraft/villagermarket/trade/TradeExecutor.java @@ -2,6 +2,7 @@ import net.modcrafters.minecraft.villagermarket.search.MarketSearch; import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.ExperienceOrb; @@ -22,8 +23,11 @@ public record Result(int performed, boolean outOfRange) { } public static Result execute(ServerPlayer player, BlockPos marketPos, int villagerId, int offerIndex, int requestedUses) { - Entity entity = player.level().getEntity(villagerId); - if (!(entity instanceof Villager villager) || !villager.isAlive() || !MarketSearch.contains(marketPos, villager.position())) { + if (!(player.level() instanceof ServerLevel level)) { + return new Result(0, true); + } + Entity entity = level.getEntity(villagerId); + if (!(entity instanceof Villager villager) || !villager.isAlive() || !MarketSearch.serves(level, marketPos, villager)) { return new Result(0, true); } MerchantOffers offers = villager.getOffers(); diff --git a/src/test/java/net/modcrafters/minecraft/villagermarket/search/VillageSectionsTest.java b/src/test/java/net/modcrafters/minecraft/villagermarket/search/VillageSectionsTest.java new file mode 100644 index 0000000..7b8d8ff --- /dev/null +++ b/src/test/java/net/modcrafters/minecraft/villagermarket/search/VillageSectionsTest.java @@ -0,0 +1,72 @@ +package net.modcrafters.minecraft.villagermarket.search; + +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VillageSectionsTest { + @Test + void aJobSiteInTheMarketsSectionIsReached() { + Section origin = new Section(0, 0, 0); + assertEquals(Set.of(origin), VillageSections.reached(origin, Set.of(origin))); + } + + @Test + void aJobSiteOneSectionAwayIsReached() { + Section origin = new Section(0, 0, 0); + Section beside = new Section(1, 0, 0); + assertEquals(Set.of(beside), VillageSections.reached(origin, Set.of(beside))); + } + + @Test + void aJobSiteTwoSectionsAwayIsOut() { + Section origin = new Section(0, 0, 0); + Section away = new Section(2, 0, 0); + assertTrue(VillageSections.reached(origin, Set.of(away)).isEmpty()); + } + + @Test + void overlappingNeighborhoodsJoinAHall() { + Section origin = new Section(0, 4, 1); + Section near = new Section(1, 4, 1); + Section next = new Section(3, 4, 1); + assertEquals(Set.of(near, next), VillageSections.reached(origin, Set.of(near, next))); + } + + @Test + void aGapOfThreeSectionsStaysASeparateHall() { + Section origin = new Section(0, 0, 0); + Section near = new Section(1, 0, 0); + Section far = new Section(4, 0, 0); + assertEquals(Set.of(near), VillageSections.reached(origin, Set.of(near, far))); + } + + @Test + void aRowOfTouchingSectionsReachesTheFarEnd() { + Section origin = new Section(0, 0, 0); + Set
hall = Set.of( + new Section(0, 0, 0), + new Section(1, 0, 0), + new Section(2, 0, 0), + new Section(3, 0, 0), + new Section(4, 0, 0)); + assertEquals(hall, VillageSections.reached(origin, hall)); + } + + @Test + void negativeSectionsUseTheSameStep() { + Section origin = new Section(-1, -1, 0); + Section across = new Section(1, -1, 0); + assertTrue(VillageSections.reached(origin, Set.of(across)).isEmpty()); + assertEquals(Set.of(origin), VillageSections.reached(origin, Set.of(origin))); + } + + @Test + void blockCoordinatesLandOnSixteenBlockSections() { + assertEquals(new Section(0, 0, -1), Section.of(15, 0, -1)); + assertEquals(new Section(-1, 1, 2), Section.of(-16, 16, 32)); + } +} diff --git a/versions/1.21.1/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java b/versions/1.21.1/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java index b4732d3..8cfa11a 100644 --- a/versions/1.21.1/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java +++ b/versions/1.21.1/src/main/java/net/modcrafters/minecraft/villagermarket/client/MarketAutomatonScreen.java @@ -2,6 +2,7 @@ import net.modcrafters.minecraft.villagermarket.block.entity.RedstoneMode; import net.modcrafters.minecraft.villagermarket.menu.MachineSlots; +import net.modcrafters.minecraft.villagermarket.search.JobSites; import net.modcrafters.minecraft.villagermarket.menu.MarketAutomatonMenu; import net.modcrafters.minecraft.villagermarket.network.AutomatonPayloads; import net.modcrafters.minecraft.villagermarket.network.MarketPayloads; @@ -76,39 +77,45 @@ public static void applyOffers(MarketPayloads.Offers payload) { @Override protected void renderBg(GuiGraphics graphics, float partialTick, int mouseX, int mouseY) { + bind(); int left = this.leftPos; int top = this.topPos; drawPanel(graphics, left, top); graphics.drawString(this.font, this.title, left + 8, top + 6, LABEL, false); - List groups = logic.groups(this.menu.offers()); - graphics.enableScissor(left + MarketScreenLogic.GROUP_X, top + MarketScreenLogic.GROUP_Y, - left + MarketScreenLogic.GROUP_X + MarketScreenLogic.GROUP_W, top + MarketScreenLogic.GROUP_Y + logic.listHeight); - drawGroup(graphics, left, top, mouseX, mouseY, 0, logic.selectedGroup.isEmpty(), Component.translatable("gui.villagermarket.all")); - for (int i = 0; i < groups.size(); i++) { - drawGroup(graphics, left, top, mouseX, mouseY, i + 1, groups.get(i).equals(logic.selectedGroup), VillagerMarketScreen.groupLabel(groups.get(i))); + if (!logic.singleVillager) { + List groups = logic.groups(this.menu.offers()); + graphics.enableScissor(left + MarketScreenLogic.GROUP_X, top + MarketScreenLogic.GROUP_Y, + left + MarketScreenLogic.GROUP_X + MarketScreenLogic.GROUP_W, top + MarketScreenLogic.GROUP_Y + logic.listHeight); + drawGroup(graphics, left, top, mouseX, mouseY, 0, logic.selectedGroup.isEmpty(), Component.translatable("gui.villagermarket.all")); + for (int i = 0; i < groups.size(); i++) { + drawGroup(graphics, left, top, mouseX, mouseY, i + 1, groups.get(i).equals(logic.selectedGroup), VillagerMarketScreen.groupLabel(groups.get(i))); + } + graphics.disableScissor(); } - graphics.disableScissor(); hovered = ItemStack.EMPTY; tip = Component.empty(); Inventory inventory = this.minecraft.player.getInventory(); List shown = logic.visible(this.menu.offers(), inventory); logic.fitOffer(shown.size()); + int offerX = logic.offerX(); int offerW = logic.offerWidth(shown.size()); - graphics.enableScissor(left + MarketScreenLogic.OFFER_X, top + MarketScreenLogic.OFFER_Y, - left + MarketScreenLogic.OFFER_X + offerW, top + MarketScreenLogic.OFFER_Y + logic.listHeight); + graphics.enableScissor(left + offerX, top + MarketScreenLogic.OFFER_Y, + left + offerX + offerW, top + MarketScreenLogic.OFFER_Y + logic.listHeight); for (int i = logic.offerScroll; i < shown.size(); i++) { int y = top + MarketScreenLogic.OFFER_Y + (i - logic.offerScroll) * MarketScreenLogic.OFFER_ROW; if (y + MarketScreenLogic.OFFER_ROW > top + MarketScreenLogic.OFFER_Y + logic.listHeight) { break; } - drawOffer(graphics, shown.get(i), left + MarketScreenLogic.OFFER_X, y, offerW, mouseX, mouseY, inventory); + drawOffer(graphics, shown.get(i), left + offerX, y, offerW, mouseX, mouseY, inventory); } graphics.disableScissor(); drawOfferBar(graphics, left, top, shown.size()); + if (!logic.singleVillager) { + drawSimilar(graphics, left, top, mouseX, mouseY); + } Component show = Component.translatable(logic.showAll ? "gui.villagermarket.show_all" : "gui.villagermarket.available"); drawButton(graphics, left + MarketScreenLogic.SHOW_X, top + MachineSlots.SHOW_Y, MarketScreenLogic.SHOW_W, MarketScreenLogic.SHOW_H, show, logic.showAll || inside(mouseX, mouseY, left + MarketScreenLogic.SHOW_X, top + MachineSlots.SHOW_Y, MarketScreenLogic.SHOW_W, MarketScreenLogic.SHOW_H)); - drawSimilar(graphics, left, top, mouseX, mouseY); drawSlot(graphics, left + MachineSlots.INPUT_X - 1, top + MachineSlots.INPUT_Y - 1); drawSlot(graphics, left + MachineSlots.INPUT_X + 17, top + MachineSlots.INPUT_Y - 1); graphics.blitSprite(FLOW_ARROW, left + MachineSlots.ARROW_X, top + MachineSlots.INPUT_Y, MachineSlots.ARROW_W, MachineSlots.ARROW_H); @@ -226,8 +233,13 @@ private void drawStack(GuiGraphics graphics, ItemStack stack, int x, int y, int } } + private void bind() { + logic.singleVillager = this.minecraft.level != null + && JobSites.workstation(this.minecraft.level.getBlockState(this.menu.pos().above())); + } + private boolean similarHit(double mouseX, double mouseY) { - return inside((int) mouseX, (int) mouseY, this.leftPos + MachineSlots.SIMILAR_X, this.topPos + MachineSlots.SIMILAR_Y, MachineSlots.SIMILAR_SIZE, MachineSlots.SIMILAR_SIZE); + return !logic.singleVillager && inside((int) mouseX, (int) mouseY, this.leftPos + MachineSlots.SIMILAR_X, this.topPos + MachineSlots.SIMILAR_Y, MachineSlots.SIMILAR_SIZE, MachineSlots.SIMILAR_SIZE); } private boolean redstoneHit(double mouseX, double mouseY) { @@ -244,6 +256,7 @@ protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { + bind(); if (similarHit(mouseX, mouseY)) { Services.PLATFORM.sendToServer(new AutomatonPayloads.Similar()); return true; @@ -273,6 +286,7 @@ public boolean mouseReleased(double mouseX, double mouseY, int button) { @Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + bind(); List shown = logic.visible(this.menu.offers(), this.minecraft.player.getInventory()); logic.scroll(mouseX, mouseY, this.leftPos, this.topPos, scrollY, shown.size(), logic.groups(this.menu.offers()).size()); return true; From 45105a9f51ee57f91c5ee3de1b8b1ff1b051b443 Mon Sep 17 00:00:00 2001 From: Face o' Cat Date: Fri, 6 Nov 2026 06:34:46 +0200 Subject: [PATCH 2/2] Run the 1.21.1 game tests in the wide structure so a distant villager stays loaded. Co-authored-by: Cursor --- .../neoforge/gametest/NeoForgeMarketGameTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/gametest/NeoForgeMarketGameTests.java b/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/gametest/NeoForgeMarketGameTests.java index 7d07c23..63e938a 100644 --- a/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/gametest/NeoForgeMarketGameTests.java +++ b/neoforge/src/main/java/net/modcrafters/minecraft/villagermarket/neoforge/gametest/NeoForgeMarketGameTests.java @@ -63,7 +63,7 @@ private static void registerTests(RegisterGameTestsEvent event) { @GameTestGenerator public static Collection generated() { return MarketGameTests.CASES.stream() - .map(test -> new TestFunction(test.name(), Constants.MOD_ID + "." + test.name(), "villagermarket:empty", + .map(test -> new TestFunction(test.name(), Constants.MOD_ID + "." + test.name(), "villagermarket:wide", 100, 0L, true, test.body())) .toList(); }