From b32a13ab8471e9e944a19303a99636cd364c19ea Mon Sep 17 00:00:00 2001 From: trunksbomb Date: Tue, 24 Mar 2026 22:31:25 -0400 Subject: [PATCH] UI cleanup, trade implementation via hotkey and via /trade player|yes|no --- .../trade/client/TradeClientState.java | 14 +++ .../trade/command/TradeCommand.java | 22 +++- .../trunksbomb/trade/mod/TradeClientMod.java | 48 +++++-- .../com/trunksbomb/trade/mod/TradeConfig.java | 24 ++++ .../com/trunksbomb/trade/mod/TradeMod.java | 4 +- .../trade/network/TradeNetworking.java | 52 ++++---- .../trade/network/TradeRequestPayload.java | 27 ++++ .../trunksbomb/trade/trade/TradeManager.java | 117 ++++++++++++++++++ .../resources/assets/trade/lang/en_us.json | 1 + 9 files changed, 272 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/trunksbomb/trade/mod/TradeConfig.java create mode 100644 src/main/java/com/trunksbomb/trade/network/TradeRequestPayload.java diff --git a/src/main/java/com/trunksbomb/trade/client/TradeClientState.java b/src/main/java/com/trunksbomb/trade/client/TradeClientState.java index a8cd677..f0d322d 100644 --- a/src/main/java/com/trunksbomb/trade/client/TradeClientState.java +++ b/src/main/java/com/trunksbomb/trade/client/TradeClientState.java @@ -18,6 +18,13 @@ public final class TradeClientState { return minecraft; } + public static void showOrUpdate(TradeView view) { + if (minecraft == null) { + minecraft = Minecraft.getInstance(); + } + showOrUpdate(minecraft, view); + } + public static void showOrUpdate(Minecraft minecraftInstance, TradeView view) { minecraft = minecraftInstance; if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(view.sessionId())) { @@ -32,6 +39,13 @@ public final class TradeClientState { minecraft.setScreen(new TradeScreen(new TradeScreen.TradeMenu(0, minecraft.player.getInventory(), view), minecraft.player.getInventory(), Component.literal("Trade"))); } + public static void closeTrade(UUID sessionId, Component reason) { + if (minecraft == null) { + minecraft = Minecraft.getInstance(); + } + closeTrade(minecraft, sessionId, reason); + } + public static void closeTrade(Minecraft minecraftInstance, UUID sessionId, Component reason) { minecraft = minecraftInstance; if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(sessionId)) { diff --git a/src/main/java/com/trunksbomb/trade/command/TradeCommand.java b/src/main/java/com/trunksbomb/trade/command/TradeCommand.java index f115b3e..690bd4f 100644 --- a/src/main/java/com/trunksbomb/trade/command/TradeCommand.java +++ b/src/main/java/com/trunksbomb/trade/command/TradeCommand.java @@ -31,9 +31,11 @@ public final class TradeCommand { .then(Commands.literal("accept").executes(context -> acceptDebug(context.getSource()))) .then(Commands.literal("cancel").executes(context -> cancelDebug(context.getSource()))) .then(Commands.literal("close").executes(context -> closeDebug(context.getSource())))) + .then(Commands.literal("yes").executes(context -> respondTrade(context.getSource(), true))) + .then(Commands.literal("no").executes(context -> respondTrade(context.getSource(), false))) .then(Commands.argument("player", StringArgumentType.word()) .suggests((context, builder) -> suggestPlayers(context.getSource(), builder)) - .executes(context -> startTrade(context.getSource(), StringArgumentType.getString(context, "player"))))); + .executes(context -> requestTrade(context.getSource(), StringArgumentType.getString(context, "player"))))); } private static CompletableFuture suggestPlayers(CommandSourceStack source, SuggestionsBuilder builder) { @@ -45,7 +47,7 @@ public final class TradeCommand { return builder.buildFuture(); } - private static int startTrade(CommandSourceStack source, String targetName) { + private static int requestTrade(CommandSourceStack source, String targetName) { if (!(source.getEntity() instanceof ServerPlayer player)) { source.sendFailure(Component.literal("Only players can start trades.")); return 0; @@ -62,15 +64,25 @@ public final class TradeCommand { return 0; } - if (!TradeManager.get(source.getServer()).startTrade(player, target)) { - source.sendFailure(Component.literal("Trade could not be started. One of you is already trading.")); + if (!TradeManager.get(source.getServer()).requestTrade(player, target)) { return 0; } - source.sendSuccess(() -> Component.literal("Trade opened with " + target.getGameProfile().getName() + "."), false); return 1; } + private static int respondTrade(CommandSourceStack source, boolean accept) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Only players can answer trade requests.")); + return 0; + } + + boolean result = accept + ? TradeManager.get(source.getServer()).acceptPendingTrade(player) + : TradeManager.get(source.getServer()).declinePendingTrade(player); + return result ? 1 : 0; + } + private static int initDebug(CommandSourceStack source) { if (!(source.getEntity() instanceof ServerPlayer player)) { source.sendFailure(Component.literal("Only players can start debug trades.")); diff --git a/src/main/java/com/trunksbomb/trade/mod/TradeClientMod.java b/src/main/java/com/trunksbomb/trade/mod/TradeClientMod.java index 999fc38..8a85867 100644 --- a/src/main/java/com/trunksbomb/trade/mod/TradeClientMod.java +++ b/src/main/java/com/trunksbomb/trade/mod/TradeClientMod.java @@ -1,18 +1,52 @@ package com.trunksbomb.trade.mod; +import com.mojang.blaze3d.platform.InputConstants; import com.trunksbomb.trade.mod.client.TradeClientState; +import com.trunksbomb.trade.mod.network.TradeRequestPayload; +import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Player; import net.neoforged.api.distmarker.Dist; -import net.neoforged.bus.api.SubscribeEvent; -import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.common.Mod; -import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.RegisterKeyMappingsEvent; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.network.PacketDistributor; +import org.lwjgl.glfw.GLFW; @Mod(value = TradeMod.MODID, dist = Dist.CLIENT) -@EventBusSubscriber(modid = TradeMod.MODID, value = Dist.CLIENT) public class TradeClientMod { - @SubscribeEvent - static void onClientSetup(FMLClientSetupEvent event) { - event.enqueueWork(() -> TradeClientState.init(Minecraft.getInstance())); + private static final KeyMapping REQUEST_TRADE = new KeyMapping( + "key.trade.request_trade", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_G, + "key.categories.gameplay"); + + public TradeClientMod(IEventBus modEventBus) { + modEventBus.addListener(this::registerKeyMappings); + NeoForge.EVENT_BUS.addListener(this::onClientTick); + TradeClientState.init(Minecraft.getInstance()); + } + + private void registerKeyMappings(RegisterKeyMappingsEvent event) { + event.register(REQUEST_TRADE); + } + + private void onClientTick(ClientTickEvent.Post event) { + Minecraft minecraft = Minecraft.getInstance(); + while (REQUEST_TRADE.consumeClick()) { + if (minecraft.player == null || minecraft.screen != null) { + continue; + } + + if (!(minecraft.crosshairPickEntity instanceof Player target) || target == minecraft.player) { + minecraft.player.displayClientMessage(Component.literal("Look at a player to send a trade request."), true); + continue; + } + + PacketDistributor.sendToServer(new TradeRequestPayload(target.getId())); + } } } diff --git a/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java b/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java new file mode 100644 index 0000000..4324d4c --- /dev/null +++ b/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java @@ -0,0 +1,24 @@ +package com.trunksbomb.trade.mod; + +import net.neoforged.neoforge.common.ModConfigSpec; + +public final class TradeConfig { + public static final ModConfigSpec SPEC; + private static final ModConfigSpec.IntValue TRADE_COMMAND_PROXIMITY; + + static { + ModConfigSpec.Builder builder = new ModConfigSpec.Builder(); + builder.push("trade"); + TRADE_COMMAND_PROXIMITY = builder + .comment("Maximum distance in blocks to initiate or accept a trade request. 0 disables the distance check.") + .defineInRange("tradeCommandProximity", 0, 0, 1024); + builder.pop(); + SPEC = builder.build(); + } + + private TradeConfig() {} + + public static int tradeCommandProximity() { + return TRADE_COMMAND_PROXIMITY.get(); + } +} diff --git a/src/main/java/com/trunksbomb/trade/mod/TradeMod.java b/src/main/java/com/trunksbomb/trade/mod/TradeMod.java index 4060b0c..84a3288 100644 --- a/src/main/java/com/trunksbomb/trade/mod/TradeMod.java +++ b/src/main/java/com/trunksbomb/trade/mod/TradeMod.java @@ -5,6 +5,7 @@ import com.trunksbomb.trade.mod.command.TradeCommand; import com.trunksbomb.trade.mod.network.TradeNetworking; import com.trunksbomb.trade.mod.trade.TradeManager; import net.neoforged.bus.api.IEventBus; +import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; import net.neoforged.neoforge.common.NeoForge; @@ -18,9 +19,10 @@ public class TradeMod { public static final String MODID = "trade"; public static final Logger LOGGER = LogUtils.getLogger(); - public TradeMod(IEventBus modEventBus) { + public TradeMod(IEventBus modEventBus, ModContainer modContainer) { modEventBus.addListener(this::commonSetup); modEventBus.addListener(this::registerPayloads); + modContainer.registerConfig(net.neoforged.fml.config.ModConfig.Type.SERVER, TradeConfig.SPEC); NeoForge.EVENT_BUS.addListener(this::registerCommands); NeoForge.EVENT_BUS.addListener(this::onPlayerLogout); NeoForge.EVENT_BUS.addListener(this::onItemPickup); diff --git a/src/main/java/com/trunksbomb/trade/network/TradeNetworking.java b/src/main/java/com/trunksbomb/trade/network/TradeNetworking.java index 1c093e6..894b403 100644 --- a/src/main/java/com/trunksbomb/trade/network/TradeNetworking.java +++ b/src/main/java/com/trunksbomb/trade/network/TradeNetworking.java @@ -1,8 +1,6 @@ package com.trunksbomb.trade.mod.network; -import com.trunksbomb.trade.mod.client.TradeScreen; import com.trunksbomb.trade.mod.trade.TradeManager; -import net.minecraft.client.Minecraft; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; import net.neoforged.neoforge.network.handling.DirectionalPayloadHandler; @@ -25,6 +23,10 @@ public final class TradeNetworking { TradeActionPayload.TYPE, TradeActionPayload.STREAM_CODEC, TradeNetworking::handleTradeActionServer); + registrar.playToServer( + TradeRequestPayload.TYPE, + TradeRequestPayload.STREAM_CODEC, + TradeNetworking::handleTradeRequestServer); registrar.playToServer( DebugTradeControlPayload.TYPE, DebugTradeControlPayload.STREAM_CODEC, @@ -32,31 +34,11 @@ public final class TradeNetworking { } private static void handleTradeStateClient(TradeStatePayload payload, IPayloadContext context) { - Minecraft minecraft = Minecraft.getInstance(); - if (minecraft.player == null) { - return; - } - - if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(payload.view().sessionId())) { - screen.updateView(payload.view()); - return; - } - - minecraft.setScreen(new TradeScreen( - new TradeScreen.TradeMenu(0, minecraft.player.getInventory(), payload.view()), - minecraft.player.getInventory(), - Component.literal("Trade"))); + invokeClientState("showOrUpdate", new Class[] {payload.view().getClass()}, payload.view()); } private static void handleTradeCloseClient(TradeClosePayload payload, IPayloadContext context) { - Minecraft minecraft = Minecraft.getInstance(); - if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(payload.sessionId())) { - minecraft.setScreen(null); - } - - if (minecraft.player != null) { - minecraft.player.displayClientMessage(payload.reason(), false); - } + invokeClientState("closeTrade", new Class[] {java.util.UUID.class, Component.class}, payload.sessionId(), payload.reason()); } private static void handleTradeActionServer(TradeActionPayload payload, IPayloadContext context) { @@ -65,6 +47,19 @@ public final class TradeNetworking { } } + private static void handleTradeRequestServer(TradeRequestPayload payload, IPayloadContext context) { + if (!(context.player() instanceof ServerPlayer player)) { + return; + } + + if (!(player.serverLevel().getEntity(payload.targetEntityId()) instanceof ServerPlayer target)) { + player.sendSystemMessage(Component.literal("You must be looking at a player to trade.")); + return; + } + + TradeManager.get(player.server).requestTrade(player, target); + } + private static void handleDebugTradeControlServer(DebugTradeControlPayload payload, IPayloadContext context) { if (context.player() instanceof ServerPlayer player) { TradeManager.get(player.server).handleDebugControl(player, payload.sessionId(), payload.action(), payload.spec()); @@ -74,4 +69,13 @@ public final class TradeNetworking { private static void ignoreTradeStateServer(TradeStatePayload payload, IPayloadContext context) {} private static void ignoreTradeCloseServer(TradeClosePayload payload, IPayloadContext context) {} + + private static void invokeClientState(String methodName, Class[] parameterTypes, Object... arguments) { + try { + Class clientState = Class.forName("com.trunksbomb.trade.mod.client.TradeClientState"); + clientState.getMethod(methodName, parameterTypes).invoke(null, arguments); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException("Failed to invoke TradeClientState#" + methodName, exception); + } + } } diff --git a/src/main/java/com/trunksbomb/trade/network/TradeRequestPayload.java b/src/main/java/com/trunksbomb/trade/network/TradeRequestPayload.java new file mode 100644 index 0000000..b115010 --- /dev/null +++ b/src/main/java/com/trunksbomb/trade/network/TradeRequestPayload.java @@ -0,0 +1,27 @@ +package com.trunksbomb.trade.mod.network; + +import com.trunksbomb.trade.mod.TradeMod; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public record TradeRequestPayload(int targetEntityId) implements CustomPacketPayload { + public static final Type TYPE = new Type<>(ResourceLocation.fromNamespaceAndPath(TradeMod.MODID, "trade_request")); + public static final StreamCodec STREAM_CODEC = new StreamCodec<>() { + @Override + public void encode(RegistryFriendlyByteBuf buf, TradeRequestPayload value) { + buf.writeVarInt(value.targetEntityId()); + } + + @Override + public TradeRequestPayload decode(RegistryFriendlyByteBuf buf) { + return new TradeRequestPayload(buf.readVarInt()); + } + }; + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/com/trunksbomb/trade/trade/TradeManager.java b/src/main/java/com/trunksbomb/trade/trade/TradeManager.java index b0a6e9a..6df0cf1 100644 --- a/src/main/java/com/trunksbomb/trade/trade/TradeManager.java +++ b/src/main/java/com/trunksbomb/trade/trade/TradeManager.java @@ -1,11 +1,14 @@ package com.trunksbomb.trade.mod.trade; +import com.trunksbomb.trade.mod.TradeConfig; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.WeakHashMap; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Style; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.ItemStack; @@ -17,6 +20,7 @@ public class TradeManager { private final Map sessionByPlayer = new HashMap<>(); private final Map debugSessionsById = new HashMap<>(); private final Map debugSessionByPlayer = new HashMap<>(); + private final Map pendingRequestsByTarget = new HashMap<>(); public static TradeManager get(MinecraftServer server) { return INSTANCES.computeIfAbsent(server, ignored -> new TradeManager()); @@ -37,6 +41,82 @@ public class TradeManager { return true; } + public boolean requestTrade(ServerPlayer requester, ServerPlayer target) { + if (requester == target) { + requester.sendSystemMessage(Component.literal("You cannot trade with yourself.")); + return false; + } + + if (isTrading(requester) || isTrading(target)) { + requester.sendSystemMessage(Component.literal("Trade could not be started. One of you is already trading.")); + return false; + } + + if (!withinTradeRange(requester, target)) { + requester.sendSystemMessage(Component.literal("That player is too far away to trade.")); + return false; + } + + TradeRequest existing = pendingRequestsByTarget.get(target.getUUID()); + if (existing != null && existing.requester().equals(requester.getUUID())) { + requester.sendSystemMessage(Component.literal("Trade request already sent to " + target.getGameProfile().getName() + ".")); + return false; + } + + pendingRequestsByTarget.put(target.getUUID(), new TradeRequest(requester.getUUID(), target.getUUID())); + requester.sendSystemMessage(Component.literal("Trade request sent to " + target.getGameProfile().getName() + ".")); + target.sendSystemMessage(Component.literal(requester.getGameProfile().getName() + " would like to trade with you: ") + .append(Component.literal("click to accept") + .withStyle(Style.EMPTY + .withColor(0x55FF55) + .withUnderlined(true) + .withClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/trade yes"))))); + target.sendSystemMessage(Component.literal("You can also use /trade yes or /trade no.")); + return true; + } + + public boolean acceptPendingTrade(ServerPlayer target) { + TradeRequest request = pendingRequestsByTarget.remove(target.getUUID()); + if (request == null) { + target.sendSystemMessage(Component.literal("You have no pending trade request.")); + return false; + } + + ServerPlayer requester = target.server.getPlayerList().getPlayer(request.requester()); + if (requester == null) { + target.sendSystemMessage(Component.literal("That trade request is no longer valid.")); + return false; + } + + if (!withinTradeRange(requester, target)) { + requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is too far away to trade.")); + target.sendSystemMessage(Component.literal(requester.getGameProfile().getName() + " is too far away to trade.")); + return false; + } + + if (!startTrade(requester, target)) { + target.sendSystemMessage(Component.literal("Trade could not be started. One of you is already trading.")); + return false; + } + + return true; + } + + public boolean declinePendingTrade(ServerPlayer target) { + TradeRequest request = pendingRequestsByTarget.remove(target.getUUID()); + if (request == null) { + target.sendSystemMessage(Component.literal("You have no pending trade request.")); + return false; + } + + ServerPlayer requester = target.server.getPlayerList().getPlayer(request.requester()); + if (requester != null) { + requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " declined your trade request.")); + } + target.sendSystemMessage(Component.literal("Trade request declined.")); + return true; + } + public void handleAction(ServerPlayer player, UUID sessionId, TradeAction action, int slot, int amount) { TradeSession session = sessionsById.get(sessionId); if (session != null && session.involves(player)) { @@ -124,6 +204,8 @@ public class TradeManager { } public void handleDisconnect(ServerPlayer player) { + clearPendingRequests(player); + TradeSession session = getSession(player); if (session != null) { cancel(session, Component.literal(player.getGameProfile().getName() + " left the game.")); @@ -306,4 +388,39 @@ public class TradeManager { debugSessionsById.remove(session.id()); debugSessionByPlayer.remove(session.player().getUUID()); } + + private boolean withinTradeRange(ServerPlayer first, ServerPlayer second) { + int maxDistance = TradeConfig.tradeCommandProximity(); + if (maxDistance <= 0) { + return true; + } + if (first.level() != second.level()) { + return false; + } + double maxDistanceSquared = maxDistance * maxDistance; + return first.distanceToSqr(second) <= maxDistanceSquared; + } + + private void clearPendingRequests(ServerPlayer player) { + TradeRequest direct = pendingRequestsByTarget.remove(player.getUUID()); + if (direct != null) { + ServerPlayer requester = player.server.getPlayerList().getPlayer(direct.requester()); + if (requester != null) { + requester.sendSystemMessage(Component.literal("Your trade request to " + player.getGameProfile().getName() + " expired.")); + } + } + + pendingRequestsByTarget.entrySet().removeIf(entry -> { + if (!entry.getValue().requester().equals(player.getUUID())) { + return false; + } + ServerPlayer target = player.server.getPlayerList().getPlayer(entry.getValue().target()); + if (target != null) { + target.sendSystemMessage(Component.literal("The trade request from " + player.getGameProfile().getName() + " expired.")); + } + return true; + }); + } + + private record TradeRequest(UUID requester, UUID target) {} } diff --git a/src/main/resources/assets/trade/lang/en_us.json b/src/main/resources/assets/trade/lang/en_us.json index aed2f1b..6d19496 100644 --- a/src/main/resources/assets/trade/lang/en_us.json +++ b/src/main/resources/assets/trade/lang/en_us.json @@ -1,4 +1,5 @@ { + "key.trade.request_trade": "Request Trade", "trade.trade.title": "Trading Screen", "trade.trade.offer": "Offer screen", "trade.trade.confirm": "Confirmation screen"