diff --git a/src/main/java/com/trunksbomb/trade/client/TradeClientState.java b/src/main/java/com/trunksbomb/trade/client/TradeClientState.java index f0d322d..0e8f1b1 100644 --- a/src/main/java/com/trunksbomb/trade/client/TradeClientState.java +++ b/src/main/java/com/trunksbomb/trade/client/TradeClientState.java @@ -51,9 +51,5 @@ public final class TradeClientState { if (minecraft.screen instanceof TradeScreen screen && screen.sessionId().equals(sessionId)) { minecraft.setScreen(null); } - - if (minecraft.player != null) { - minecraft.player.displayClientMessage(reason, false); - } } } diff --git a/src/main/java/com/trunksbomb/trade/client/TradeScreen.java b/src/main/java/com/trunksbomb/trade/client/TradeScreen.java index af315d9..00dd06d 100644 --- a/src/main/java/com/trunksbomb/trade/client/TradeScreen.java +++ b/src/main/java/com/trunksbomb/trade/client/TradeScreen.java @@ -3,6 +3,7 @@ package com.trunksbomb.trade.mod.client; import com.trunksbomb.trade.mod.network.TradeActionPayload; import com.trunksbomb.trade.mod.network.DebugTradeControlPayload; import com.trunksbomb.trade.mod.trade.DebugControlAction; +import com.trunksbomb.trade.mod.trade.DebugUnsafeState; import com.trunksbomb.trade.mod.trade.TradeAction; import com.trunksbomb.trade.mod.trade.TradeStage; import com.trunksbomb.trade.mod.trade.TradeView; @@ -45,6 +46,7 @@ public class TradeScreen extends AbstractContainerScreen private static final int DEBUG_BUTTON_WIDTH = 86; private static final int DEBUG_BUTTON_HEIGHT = 20; private static final int DEBUG_BUTTON_GAP = 4; + private static final int DEBUG_SMALL_BUTTON_WIDTH = 41; private ContextMenu contextMenu; public TradeScreen(TradeMenu menu, Inventory inventory, Component title) { @@ -91,6 +93,18 @@ public class TradeScreen extends AbstractContainerScreen addRenderableWidget(Button.builder(Component.literal("Remove Item"), button -> sendDebug(DebugControlAction.REMOVE_LAST)) .bounds(debugX, debugY + 3 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DEBUG_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT) .build()); + + int unsafeY = debugY + 4 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP) + 6; + addRenderableWidget(debugUnsafeButton("Damage", debugX, unsafeY, DebugUnsafeState.DAMAGE)); + addRenderableWidget(debugUnsafeButton("Fire", debugX + DEBUG_SMALL_BUTTON_WIDTH + 4, unsafeY, DebugUnsafeState.FIRE)); + addRenderableWidget(debugUnsafeButton("Liquid", debugX, unsafeY + DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP, DebugUnsafeState.LIQUID)); + addRenderableWidget(debugUnsafeButton("Moving", debugX + DEBUG_SMALL_BUTTON_WIDTH + 4, unsafeY + DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP, DebugUnsafeState.MOVING)); + addRenderableWidget(debugUnsafeButton("Sleep", debugX, unsafeY + 2 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DebugUnsafeState.SLEEPING)); + addRenderableWidget(debugUnsafeButton("Ride", debugX + DEBUG_SMALL_BUTTON_WIDTH + 4, unsafeY + 2 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DebugUnsafeState.RIDING)); + addRenderableWidget(debugUnsafeButton("Glide", debugX, unsafeY + 3 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DebugUnsafeState.GLIDING)); + addRenderableWidget(Button.builder(Component.literal("Clear"), button -> sendDebug(DebugControlAction.CLEAR_UNSAFE)) + .bounds(debugX + DEBUG_SMALL_BUTTON_WIDTH + 4, unsafeY + 3 * (DEBUG_BUTTON_HEIGHT + DEBUG_BUTTON_GAP), DEBUG_SMALL_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT) + .build()); } } @@ -194,10 +208,20 @@ public class TradeScreen extends AbstractContainerScreen } private void sendDebug(DebugControlAction action) { + sendDebug(action, ""); + } + + private void sendDebug(DebugControlAction action, String spec) { if (minecraft == null || minecraft.getConnection() == null) { return; } - PacketDistributor.sendToServer(new DebugTradeControlPayload(menu.view().sessionId(), action, "")); + PacketDistributor.sendToServer(new DebugTradeControlPayload(menu.view().sessionId(), action, spec)); + } + + private Button debugUnsafeButton(String label, int x, int y, DebugUnsafeState state) { + return Button.builder(Component.literal(label), button -> sendDebug(DebugControlAction.SET_UNSAFE, state.name())) + .bounds(x, y, DEBUG_SMALL_BUTTON_WIDTH, DEBUG_BUTTON_HEIGHT) + .build(); } private void drawCenteredColumnText(GuiGraphics guiGraphics, String text, int y, int color) { diff --git a/src/main/java/com/trunksbomb/trade/command/TradeCommand.java b/src/main/java/com/trunksbomb/trade/command/TradeCommand.java index 690bd4f..c7a287b 100644 --- a/src/main/java/com/trunksbomb/trade/command/TradeCommand.java +++ b/src/main/java/com/trunksbomb/trade/command/TradeCommand.java @@ -1,8 +1,11 @@ package com.trunksbomb.trade.mod.command; import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.suggestion.SuggestionsBuilder; +import com.trunksbomb.trade.mod.TradeConfig; +import com.trunksbomb.trade.mod.trade.DebugUnsafeState; import com.trunksbomb.trade.mod.trade.DebugTradeSession; import com.trunksbomb.trade.mod.trade.TradeManager; import java.util.concurrent.CompletableFuture; @@ -15,9 +18,17 @@ public final class TradeCommand { private TradeCommand() {} public static void register(CommandDispatcher dispatcher) { - dispatcher.register(Commands.literal("trade") + var trade = Commands.literal("trade") .then(Commands.literal("debug") - .then(Commands.literal("init").executes(context -> initDebug(context.getSource()))) + .requires(source -> TradeConfig.enableDebugFeaturesSafe()) + .then(Commands.literal("init") + .executes(context -> initDebug(context.getSource(), 0)) + .then(Commands.argument("time", IntegerArgumentType.integer(0)) + .executes(context -> initDebug(context.getSource(), IntegerArgumentType.getInteger(context, "time"))))) + .then(Commands.literal("request") + .executes(context -> requestDebug(context.getSource(), 0)) + .then(Commands.argument("time", IntegerArgumentType.integer(0)) + .executes(context -> requestDebug(context.getSource(), IntegerArgumentType.getInteger(context, "time"))))) .then(Commands.literal("offer") .then(Commands.argument("spec", StringArgumentType.greedyString()) .executes(context -> setDebugOffer( @@ -28,11 +39,34 @@ public final class TradeCommand { .executes(context -> removeDebugOffer( context.getSource(), StringArgumentType.getString(context, "spec"))))) + .then(Commands.literal("unsafe") + .then(Commands.literal("damage").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.DAMAGE, "damage"))) + .then(Commands.literal("fire").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.FIRE, "fire"))) + .then(Commands.literal("liquid").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.LIQUID, "liquid"))) + .then(Commands.literal("moving").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.MOVING, "moving"))) + .then(Commands.literal("sleeping").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.SLEEPING, "sleeping"))) + .then(Commands.literal("riding").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.RIDING, "riding"))) + .then(Commands.literal("gliding").executes(context -> setDebugUnsafe(context.getSource(), DebugUnsafeState.GLIDING, "gliding"))) + .then(Commands.literal("clear").executes(context -> clearDebugUnsafe(context.getSource())))) .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("close").executes(context -> closeDebug(context.getSource())))); + + dispatcher.register(trade .then(Commands.literal("yes").executes(context -> respondTrade(context.getSource(), true))) .then(Commands.literal("no").executes(context -> respondTrade(context.getSource(), false))) + .then(Commands.literal("toggle").executes(context -> toggleTrade(context.getSource()))) + .then(Commands.literal("on").executes(context -> setTradeEnabled(context.getSource(), true))) + .then(Commands.literal("off").executes(context -> setTradeEnabled(context.getSource(), false))) + .then(Commands.literal("ignore") + .then(Commands.argument("player", StringArgumentType.word()) + .suggests((context, builder) -> suggestPlayers(context.getSource(), builder)) + .executes(context -> ignorePlayer(context.getSource(), StringArgumentType.getString(context, "player"))))) + .then(Commands.literal("unignore") + .then(Commands.argument("player", StringArgumentType.word()) + .suggests((context, builder) -> suggestPlayers(context.getSource(), builder)) + .executes(context -> unignorePlayer(context.getSource(), StringArgumentType.getString(context, "player"))))) + .then(Commands.literal("ignorelist").executes(context -> listIgnoredPlayers(context.getSource()))) .then(Commands.argument("player", StringArgumentType.word()) .suggests((context, builder) -> suggestPlayers(context.getSource(), builder)) .executes(context -> requestTrade(context.getSource(), StringArgumentType.getString(context, "player"))))); @@ -78,23 +112,143 @@ public final class TradeCommand { } boolean result = accept - ? TradeManager.get(source.getServer()).acceptPendingTrade(player) + ? acceptPendingTrade(player, source) : TradeManager.get(source.getServer()).declinePendingTrade(player); return result ? 1 : 0; } - private static int initDebug(CommandSourceStack source) { + private static boolean acceptPendingTrade(ServerPlayer player, CommandSourceStack source) { + TradeManager manager = TradeManager.get(source.getServer()); + if (manager.hasPendingTradeRequest(player)) { + return manager.acceptPendingTrade(player); + } + if (manager.hasPendingDebugTradeRequest(player)) { + return manager.acceptPendingDebugTrade(player); + } + player.sendSystemMessage(Component.literal("You have no pending trade request.")); + return false; + } + + private static int toggleTrade(CommandSourceStack source) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Only players can toggle trade requests.")); + return 0; + } + + boolean enabled = TradeManager.get(source.getServer()).toggleTradeEnabled(player); + source.sendSuccess(() -> Component.literal("Trade requests are now " + (enabled ? "enabled" : "disabled") + "."), false); + return 1; + } + + private static int setTradeEnabled(CommandSourceStack source, boolean enabled) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Only players can change trade request settings.")); + return 0; + } + + TradeManager.get(source.getServer()).setTradeEnabled(player, enabled); + source.sendSuccess(() -> Component.literal("Trade requests are now " + (enabled ? "enabled" : "disabled") + "."), false); + return 1; + } + + private static int ignorePlayer(CommandSourceStack source, String targetName) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Only players can manage ignored traders.")); + return 0; + } + + ServerPlayer target = source.getServer().getPlayerList().getPlayerByName(targetName); + if (target == null) { + source.sendFailure(Component.literal("That player is not online.")); + return 0; + } + + if (target == player) { + source.sendFailure(Component.literal("You cannot ignore yourself.")); + return 0; + } + + if (!TradeManager.get(source.getServer()).addIgnoredPlayer(player, target)) { + source.sendFailure(Component.literal(target.getGameProfile().getName() + " is already ignored.")); + return 0; + } + + source.sendSuccess(() -> Component.literal("You are now ignoring trade requests from " + target.getGameProfile().getName() + "."), false); + return 1; + } + + private static int unignorePlayer(CommandSourceStack source, String targetName) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Only players can manage ignored traders.")); + return 0; + } + + ServerPlayer target = source.getServer().getPlayerList().getPlayerByName(targetName); + if (target == null) { + source.sendFailure(Component.literal("That player is not online.")); + return 0; + } + + if (!TradeManager.get(source.getServer()).removeIgnoredPlayer(player, target)) { + source.sendFailure(Component.literal(target.getGameProfile().getName() + " is not on your ignore list.")); + return 0; + } + + source.sendSuccess(() -> Component.literal("You are no longer ignoring trade requests from " + target.getGameProfile().getName() + "."), false); + return 1; + } + + private static int listIgnoredPlayers(CommandSourceStack source) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Only players can manage ignored traders.")); + return 0; + } + + java.util.List ignored = TradeManager.get(source.getServer()).ignoredPlayerNames(player); + if (ignored.isEmpty()) { + source.sendSuccess(() -> Component.literal("Your trade ignore list is empty."), false); + return 1; + } + + source.sendSuccess(() -> Component.literal("Ignored traders: " + String.join(", ", ignored)), false); + return 1; + } + + private static int initDebug(CommandSourceStack source, int delaySeconds) { if (!(source.getEntity() instanceof ServerPlayer player)) { source.sendFailure(Component.literal("Only players can start debug trades.")); return 0; } - if (!TradeManager.get(source.getServer()).initDebugTrade(player)) { + if (!TradeManager.get(source.getServer()).startDebugInit(player, delaySeconds)) { source.sendFailure(Component.literal("You are already in a trade.")); return 0; } - source.sendSuccess(() -> Component.literal("Debug trade initialized."), false); + if (delaySeconds <= 0) { + source.sendSuccess(() -> Component.literal("Debug trade request sent and the debug trader will accept immediately."), false); + } else { + source.sendSuccess(() -> Component.literal("Debug trade request sent and the debug trader will accept in " + delaySeconds + " seconds."), false); + } + return 1; + } + + private static int requestDebug(CommandSourceStack source, int delaySeconds) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Only players can start debug trades.")); + return 0; + } + + if (!TradeManager.get(source.getServer()).scheduleDebugRequest(player, delaySeconds, false)) { + source.sendFailure(Component.literal("You are already in a trade.")); + return 0; + } + + if (delaySeconds <= 0) { + source.sendSuccess(() -> Component.literal("Debug trade request sent."), false); + } else { + source.sendSuccess(() -> Component.literal("Debug trade request scheduled in " + delaySeconds + " seconds."), false); + } return 1; } @@ -155,6 +309,36 @@ public final class TradeCommand { return runDebugAction(source, TradeManager.get(source.getServer())::closeDebug, "Debug trade closed."); } + private static int setDebugUnsafe(CommandSourceStack source, DebugUnsafeState state, String label) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Only players can control debug trades.")); + return 0; + } + + if (!TradeManager.get(source.getServer()).setDebugUnsafeState(player, state)) { + source.sendFailure(Component.literal("Start a debug trade first with /trade debug init.")); + return 0; + } + + source.sendSuccess(() -> Component.literal("Debug unsafe state enabled: " + label + "."), false); + return 1; + } + + private static int clearDebugUnsafe(CommandSourceStack source) { + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Only players can control debug trades.")); + return 0; + } + + if (!TradeManager.get(source.getServer()).clearDebugUnsafeStates(player)) { + source.sendFailure(Component.literal("Start a debug trade first with /trade debug init.")); + return 0; + } + + source.sendSuccess(() -> Component.literal("Debug unsafe states cleared."), false); + return 1; + } + private static int runDebugAction(CommandSourceStack source, java.util.function.Predicate action, String successMessage) { if (!(source.getEntity() instanceof ServerPlayer player)) { source.sendFailure(Component.literal("Only players can control debug trades.")); diff --git a/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java b/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java index 4324d4c..81fbbce 100644 --- a/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java +++ b/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java @@ -5,6 +5,19 @@ import net.neoforged.neoforge.common.ModConfigSpec; public final class TradeConfig { public static final ModConfigSpec SPEC; private static final ModConfigSpec.IntValue TRADE_COMMAND_PROXIMITY; + private static final ModConfigSpec.IntValue REQUEST_TIMEOUT_SECONDS; + private static final ModConfigSpec.BooleanValue ENABLE_DEBUG_FEATURES; + private static final ModConfigSpec.BooleanValue REQUIRE_ON_GROUND; + private static final ModConfigSpec.BooleanValue REQUIRE_STATIONARY; + private static final ModConfigSpec.DoubleValue STATIONARY_SPEED_THRESHOLD; + private static final ModConfigSpec.BooleanValue REQUIRE_NO_RECENT_DAMAGE; + private static final ModConfigSpec.IntValue NO_DAMAGE_SECONDS; + private static final ModConfigSpec.BooleanValue REQUIRE_NOT_ON_FIRE; + private static final ModConfigSpec.BooleanValue REQUIRE_NOT_IN_LIQUID; + private static final ModConfigSpec.BooleanValue REQUIRE_NOT_SLEEPING; + private static final ModConfigSpec.BooleanValue REQUIRE_NOT_FALL_FLYING; + private static final ModConfigSpec.BooleanValue REQUIRE_NOT_RIDING; + private static final ModConfigSpec.BooleanValue REQUIRE_SAME_DIMENSION; static { ModConfigSpec.Builder builder = new ModConfigSpec.Builder(); @@ -12,6 +25,33 @@ public final class TradeConfig { 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); + REQUEST_TIMEOUT_SECONDS = builder + .comment("Seconds before a trade request expires.") + .defineInRange("requestTimeoutSeconds", 30, 1, 3600); + ENABLE_DEBUG_FEATURES = builder.comment("Enable debug trade commands and debug UI/testing tools.") + .define("enableDebugFeatures", false); + REQUIRE_ON_GROUND = builder.comment("Require players to be on solid ground before requesting or accepting a trade.") + .define("requireOnGround", true); + REQUIRE_STATIONARY = builder.comment("Require players to be stationary before requesting or accepting a trade.") + .define("requireStationary", true); + STATIONARY_SPEED_THRESHOLD = builder.comment("Maximum horizontal player speed considered stationary.") + .defineInRange("stationarySpeedThreshold", 0.03D, 0.0D, 10.0D); + REQUIRE_NO_RECENT_DAMAGE = builder.comment("Require players to have taken no damage recently before requesting or accepting a trade.") + .define("requireNoRecentDamage", true); + NO_DAMAGE_SECONDS = builder.comment("How long a player must be out of combat to trade when requireNoRecentDamage is enabled.") + .defineInRange("noDamageSeconds", 10, 0, 3600); + REQUIRE_NOT_ON_FIRE = builder.comment("Require players to not be on fire before requesting or accepting a trade.") + .define("requireNotOnFire", true); + REQUIRE_NOT_IN_LIQUID = builder.comment("Require players to not be in water or lava before requesting or accepting a trade.") + .define("requireNotInLiquid", true); + REQUIRE_NOT_SLEEPING = builder.comment("Require players to not be sleeping before requesting or accepting a trade.") + .define("requireNotSleeping", true); + REQUIRE_NOT_FALL_FLYING = builder.comment("Require players to not be gliding with elytra before requesting or accepting a trade.") + .define("requireNotFallFlying", true); + REQUIRE_NOT_RIDING = builder.comment("Require players to not be riding another entity before requesting or accepting a trade.") + .define("requireNotRiding", true); + REQUIRE_SAME_DIMENSION = builder.comment("Require both players to be in the same dimension.") + .define("requireSameDimension", true); builder.pop(); SPEC = builder.build(); } @@ -21,4 +61,64 @@ public final class TradeConfig { public static int tradeCommandProximity() { return TRADE_COMMAND_PROXIMITY.get(); } + + public static int requestTimeoutSeconds() { + return REQUEST_TIMEOUT_SECONDS.get(); + } + + public static boolean enableDebugFeatures() { + return ENABLE_DEBUG_FEATURES.get(); + } + + public static boolean enableDebugFeaturesSafe() { + try { + return ENABLE_DEBUG_FEATURES.get(); + } catch (IllegalStateException ignored) { + return false; + } + } + + public static boolean requireOnGround() { + return REQUIRE_ON_GROUND.get(); + } + + public static boolean requireStationary() { + return REQUIRE_STATIONARY.get(); + } + + public static double stationarySpeedThreshold() { + return STATIONARY_SPEED_THRESHOLD.get(); + } + + public static boolean requireNoRecentDamage() { + return REQUIRE_NO_RECENT_DAMAGE.get(); + } + + public static int noDamageSeconds() { + return NO_DAMAGE_SECONDS.get(); + } + + public static boolean requireNotOnFire() { + return REQUIRE_NOT_ON_FIRE.get(); + } + + public static boolean requireNotInLiquid() { + return REQUIRE_NOT_IN_LIQUID.get(); + } + + public static boolean requireNotSleeping() { + return REQUIRE_NOT_SLEEPING.get(); + } + + public static boolean requireNotFallFlying() { + return REQUIRE_NOT_FALL_FLYING.get(); + } + + public static boolean requireNotRiding() { + return REQUIRE_NOT_RIDING.get(); + } + + public static boolean requireSameDimension() { + return REQUIRE_SAME_DIMENSION.get(); + } } diff --git a/src/main/java/com/trunksbomb/trade/mod/TradeMod.java b/src/main/java/com/trunksbomb/trade/mod/TradeMod.java index 84a3288..ce16b19 100644 --- a/src/main/java/com/trunksbomb/trade/mod/TradeMod.java +++ b/src/main/java/com/trunksbomb/trade/mod/TradeMod.java @@ -26,6 +26,8 @@ public class TradeMod { NeoForge.EVENT_BUS.addListener(this::registerCommands); NeoForge.EVENT_BUS.addListener(this::onPlayerLogout); NeoForge.EVENT_BUS.addListener(this::onItemPickup); + NeoForge.EVENT_BUS.addListener(this::onServerTick); + NeoForge.EVENT_BUS.addListener(this::onLivingDamage); } private void commonSetup(FMLCommonSetupEvent event) { @@ -53,4 +55,14 @@ public class TradeMod { event.setCanPickup(net.neoforged.neoforge.common.util.TriState.FALSE); } } + + public void onServerTick(net.neoforged.neoforge.event.tick.ServerTickEvent.Post event) { + TradeManager.get(event.getServer()).tick(event.getServer()); + } + + public void onLivingDamage(net.neoforged.neoforge.event.entity.living.LivingDamageEvent.Post event) { + if (event.getEntity() instanceof net.minecraft.server.level.ServerPlayer player) { + TradeManager.get(player.server).recordDamage(player); + } + } } diff --git a/src/main/java/com/trunksbomb/trade/trade/DebugControlAction.java b/src/main/java/com/trunksbomb/trade/trade/DebugControlAction.java index 8bd2e86..6d36118 100644 --- a/src/main/java/com/trunksbomb/trade/trade/DebugControlAction.java +++ b/src/main/java/com/trunksbomb/trade/trade/DebugControlAction.java @@ -5,6 +5,8 @@ public enum DebugControlAction { APPEND_RANDOM, REMOVE_OFFER, REMOVE_LAST, + SET_UNSAFE, + CLEAR_UNSAFE, ACCEPT, CANCEL, CLOSE diff --git a/src/main/java/com/trunksbomb/trade/trade/DebugTradeSession.java b/src/main/java/com/trunksbomb/trade/trade/DebugTradeSession.java index 38fba30..dbe17ab 100644 --- a/src/main/java/com/trunksbomb/trade/trade/DebugTradeSession.java +++ b/src/main/java/com/trunksbomb/trade/trade/DebugTradeSession.java @@ -3,6 +3,7 @@ package com.trunksbomb.trade.mod.trade; import com.trunksbomb.trade.mod.network.TradeClosePayload; import com.trunksbomb.trade.mod.network.TradeStatePayload; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; import java.util.UUID; import net.minecraft.core.registries.BuiltInRegistries; @@ -25,6 +26,7 @@ public class DebugTradeSession { private final List inventorySnapshot; private final List selfOffer = blankOffer(); private final List otherOffer = blankStacks(); + private final EnumSet unsafeStates = EnumSet.noneOf(DebugUnsafeState.class); private boolean selfAccepted; private boolean otherAccepted; private TradeStage stage = TradeStage.OFFERING; @@ -192,6 +194,22 @@ public class DebugTradeSession { return false; } + public void setUnsafeState(DebugUnsafeState state, boolean unsafe) { + if (unsafe) { + unsafeStates.add(state); + } else { + unsafeStates.remove(state); + } + } + + public void clearUnsafeStates() { + unsafeStates.clear(); + } + + public boolean hasUnsafeState(DebugUnsafeState state) { + return unsafeStates.contains(state); + } + public void advanceToConfirmation() { if (stage == TradeStage.OFFERING && selfAccepted && otherAccepted) { stage = TradeStage.CONFIRMING; diff --git a/src/main/java/com/trunksbomb/trade/trade/DebugUnsafeState.java b/src/main/java/com/trunksbomb/trade/trade/DebugUnsafeState.java new file mode 100644 index 0000000..8561ef6 --- /dev/null +++ b/src/main/java/com/trunksbomb/trade/trade/DebugUnsafeState.java @@ -0,0 +1,11 @@ +package com.trunksbomb.trade.mod.trade; + +public enum DebugUnsafeState { + DAMAGE, + FIRE, + LIQUID, + MOVING, + SLEEPING, + RIDING, + GLIDING +} diff --git a/src/main/java/com/trunksbomb/trade/trade/TradeManager.java b/src/main/java/com/trunksbomb/trade/trade/TradeManager.java index 6df0cf1..a8621a4 100644 --- a/src/main/java/com/trunksbomb/trade/trade/TradeManager.java +++ b/src/main/java/com/trunksbomb/trade/trade/TradeManager.java @@ -1,6 +1,7 @@ package com.trunksbomb.trade.mod.trade; import com.trunksbomb.trade.mod.TradeConfig; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -8,12 +9,17 @@ 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.MutableComponent; import net.minecraft.network.chat.Style; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.phys.Vec3; public class TradeManager { + private static final long ACCEPT_GRACE_TICKS = 5L * 20L; + private static final long ACCEPT_HOLD_TICKS = 20L; + private static final Map INSTANCES = new WeakHashMap<>(); private final Map sessionsById = new HashMap<>(); @@ -21,13 +27,18 @@ public class TradeManager { private final Map debugSessionsById = new HashMap<>(); private final Map debugSessionByPlayer = new HashMap<>(); private final Map pendingRequestsByTarget = new HashMap<>(); + private final Map scheduledDebugRequestsByTarget = new HashMap<>(); + private final Map pendingDebugRequestsByTarget = new HashMap<>(); + private final Map pendingAcceptancesByRequester = new HashMap<>(); + private final Map pendingAcceptanceByPlayer = new HashMap<>(); + private final Map lastDamageTickByPlayer = new HashMap<>(); public static TradeManager get(MinecraftServer server) { return INSTANCES.computeIfAbsent(server, ignored -> new TradeManager()); } public boolean startTrade(ServerPlayer first, ServerPlayer second) { - if (isTrading(first) || isTrading(second)) { + if (isBusy(first) || isBusy(second)) { return false; } @@ -47,23 +58,45 @@ public class TradeManager { return false; } - if (isTrading(requester) || isTrading(target)) { + if (isBusy(requester) || isBusy(target)) { requester.sendSystemMessage(Component.literal("Trade could not be started. One of you is already trading.")); return false; } + if (!preferences(requester.server).isTradeEnabled(target.getUUID())) { + requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is not accepting trade requests.")); + return false; + } + + if (preferences(requester.server).isIgnoring(target.getUUID(), requester.getUUID())) { + requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is ignoring your trade requests.")); + return false; + } + if (!withinTradeRange(requester, target)) { requester.sendSystemMessage(Component.literal("That player is too far away to trade.")); return false; } + List requesterUnsafe = tradeSafetyFailures(requester, target.server.getTickCount()); + if (!requesterUnsafe.isEmpty()) { + requester.sendSystemMessage(Component.literal("You cannot request a trade right now: ").append(joinReasons(requesterUnsafe))); + return false; + } + + List targetUnsafe = tradeSafetyFailures(target, target.server.getTickCount()); + if (!targetUnsafe.isEmpty()) { + requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is not in a safe state to trade right now: ").append(joinReasons(targetUnsafe))); + 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())); + pendingRequestsByTarget.put(target.getUUID(), new TradeRequest(requester.getUUID(), target.getUUID(), target.server.getTickCount())); 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") @@ -88,12 +121,37 @@ public class TradeManager { return false; } + if (!preferences(target.server).isTradeEnabled(target.getUUID())) { + target.sendSystemMessage(Component.literal("You are not accepting trade requests right now.")); + requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is not accepting trade requests.")); + 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; } + long currentTick = target.server.getTickCount(); + List requesterUnsafe = tradeSafetyFailures(requester, currentTick); + if (!requesterUnsafe.isEmpty()) { + if (canDelayTradeAcceptance(requester, currentTick)) { + beginPendingAcceptance(requester, target, currentTick); + return true; + } + requester.sendSystemMessage(Component.literal("Trade request cancelled: you are no longer in a safe state to trade (").append(joinReasons(requesterUnsafe)).append(Component.literal(")."))); + target.sendSystemMessage(Component.literal("Trade request cancelled because " + requester.getGameProfile().getName() + " is no longer in a safe state to trade: ").append(joinReasons(requesterUnsafe))); + return false; + } + + List targetUnsafe = tradeSafetyFailures(target, currentTick); + if (!targetUnsafe.isEmpty()) { + target.sendSystemMessage(Component.literal("You cannot accept the trade right now: ").append(joinReasons(targetUnsafe))); + requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is not in a safe state to trade right now: ").append(joinReasons(targetUnsafe))); + return false; + } + if (!startTrade(requester, target)) { target.sendSystemMessage(Component.literal("Trade could not be started. One of you is already trading.")); return false; @@ -102,7 +160,24 @@ public class TradeManager { return true; } + public boolean hasPendingTradeRequest(ServerPlayer player) { + return pendingRequestsByTarget.containsKey(player.getUUID()); + } + + public boolean hasPendingDebugTradeRequest(ServerPlayer player) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } + return pendingDebugRequestsByTarget.containsKey(player.getUUID()); + } + public boolean declinePendingTrade(ServerPlayer target) { + DebugTradeRequest debugRequest = pendingDebugRequestsByTarget.remove(target.getUUID()); + if (debugRequest != null) { + target.sendSystemMessage(Component.literal("Debug trade request declined.")); + return true; + } + TradeRequest request = pendingRequestsByTarget.remove(target.getUUID()); if (request == null) { target.sendSystemMessage(Component.literal("You have no pending trade request.")); @@ -168,6 +243,14 @@ public class TradeManager { } private void handleDebugAction(DebugTradeSession session, TradeAction action, int slot, int amount) { + if (action == TradeAction.ACCEPT) { + List unsafe = tradeSafetyFailures(session, session.player(), session.player().server.getTickCount()); + if (!unsafe.isEmpty()) { + session.player().sendSystemMessage(Component.literal("You cannot accept the debug trade right now: ").append(joinReasons(unsafe))); + return; + } + } + boolean changed = switch (action) { case ADD_ITEM -> session.addFromInventory(slot, amount); case REMOVE_ITEM -> session.removeFromOffer(slot, amount); @@ -205,6 +288,8 @@ public class TradeManager { public void handleDisconnect(ServerPlayer player) { clearPendingRequests(player); + clearDebugRequests(player); + clearPendingAcceptance(player, null, null); TradeSession session = getSession(player); if (session != null) { @@ -222,8 +307,57 @@ public class TradeManager { return sessionByPlayer.containsKey(player.getUUID()) || debugSessionByPlayer.containsKey(player.getUUID()); } + public void recordDamage(ServerPlayer player) { + lastDamageTickByPlayer.put(player.getUUID(), (long) player.server.getTickCount()); + } + + public void tick(MinecraftServer server) { + expireRequests(server); + processScheduledDebugRequests(server); + processPendingAcceptances(server); + validateActiveTrades(server); + } + + public boolean setTradeEnabled(ServerPlayer player, boolean enabled) { + TradePreferencesData preferences = preferences(player.server); + boolean changed = preferences.isTradeEnabled(player.getUUID()) != enabled; + preferences.setTradeEnabled(player.getUUID(), enabled); + return changed; + } + + public boolean toggleTradeEnabled(ServerPlayer player) { + boolean enabled = !preferences(player.server).isTradeEnabled(player.getUUID()); + preferences(player.server).setTradeEnabled(player.getUUID(), enabled); + return enabled; + } + + public boolean isTradeEnabled(ServerPlayer player) { + return preferences(player.server).isTradeEnabled(player.getUUID()); + } + + public boolean addIgnoredPlayer(ServerPlayer player, ServerPlayer ignored) { + return preferences(player.server).addIgnoredPlayer(player.getUUID(), ignored.getUUID()); + } + + public boolean removeIgnoredPlayer(ServerPlayer player, ServerPlayer ignored) { + return preferences(player.server).removeIgnoredPlayer(player.getUUID(), ignored.getUUID()); + } + + public List ignoredPlayerNames(ServerPlayer player) { + List result = new ArrayList<>(); + for (UUID ignored : preferences(player.server).ignoredPlayers(player.getUUID())) { + ServerPlayer online = player.server.getPlayerList().getPlayer(ignored); + result.add(online != null ? online.getGameProfile().getName() : ignored.toString()); + } + result.sort(String::compareToIgnoreCase); + return result; + } + public boolean initDebugTrade(ServerPlayer player) { - if (isTrading(player)) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } + if (isBusy(player)) { return false; } @@ -235,7 +369,30 @@ public class TradeManager { return true; } + public boolean startDebugInit(ServerPlayer player, int delaySeconds) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } + return scheduleDebugRequest(player, delaySeconds, true); + } + + public boolean scheduleDebugRequest(ServerPlayer player, int delaySeconds, boolean autoAccept) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } + if (isBusy(player) || hasPendingDebugRequest(player)) { + return false; + } + + long triggerTick = player.server.getTickCount() + Math.max(0, delaySeconds) * 20L; + scheduledDebugRequestsByTarget.put(player.getUUID(), new ScheduledDebugRequest(player.getUUID(), triggerTick, autoAccept)); + return true; + } + public boolean setDebugOffer(ServerPlayer player, List offer) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } DebugTradeSession session = getDebugSession(player); if (session == null) { return false; @@ -247,6 +404,9 @@ public class TradeManager { } public int removeDebugOffer(ServerPlayer player, String spec) { + if (!TradeConfig.enableDebugFeatures()) { + return -1; + } DebugTradeSession session = getDebugSession(player); if (session == null) { return -1; @@ -262,11 +422,20 @@ public class TradeManager { } public boolean acceptDebug(ServerPlayer player) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } DebugTradeSession session = getDebugSession(player); if (session == null) { return false; } + List unsafe = tradeSafetyFailures(session, player, player.server.getTickCount()); + if (!unsafe.isEmpty()) { + player.sendSystemMessage(Component.literal("You cannot accept the debug trade right now: ").append(joinReasons(unsafe))); + return false; + } + session.acceptOther(); if (!session.isConfirmationStage()) { session.advanceToConfirmation(); @@ -286,7 +455,38 @@ public class TradeManager { return true; } + public boolean setDebugUnsafeState(ServerPlayer player, DebugUnsafeState state) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } + DebugTradeSession session = getDebugSession(player); + if (session == null) { + return false; + } + + session.setUnsafeState(state, true); + session.sync(); + return true; + } + + public boolean clearDebugUnsafeStates(ServerPlayer player) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } + DebugTradeSession session = getDebugSession(player); + if (session == null) { + return false; + } + + session.clearUnsafeStates(); + session.sync(); + return true; + } + public boolean cancelDebug(ServerPlayer player) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } DebugTradeSession session = getDebugSession(player); if (session == null) { return false; @@ -297,6 +497,9 @@ public class TradeManager { } public boolean closeDebug(ServerPlayer player) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } DebugTradeSession session = getDebugSession(player); if (session == null) { return false; @@ -307,6 +510,9 @@ public class TradeManager { } public void handleDebugControl(ServerPlayer player, UUID sessionId, DebugControlAction action, String spec) { + if (!TradeConfig.enableDebugFeatures()) { + return; + } DebugTradeSession session = debugSessionsById.get(sessionId); if (session == null || session.player() != player) { return; @@ -333,6 +539,15 @@ public class TradeManager { session.sync(); } } + case SET_UNSAFE -> { + DebugUnsafeState state = parseDebugUnsafeState(spec); + session.setUnsafeState(state, true); + session.sync(); + } + case CLEAR_UNSAFE -> { + session.clearUnsafeStates(); + session.sync(); + } case ACCEPT -> acceptDebug(player); case CANCEL -> cancelDebug(player); case CLOSE -> closeDebug(player); @@ -390,13 +605,13 @@ public class TradeManager { } private boolean withinTradeRange(ServerPlayer first, ServerPlayer second) { + if (TradeConfig.requireSameDimension() && first.level() != second.level()) { + return false; + } 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; } @@ -422,5 +637,463 @@ public class TradeManager { }); } - private record TradeRequest(UUID requester, UUID target) {} + private void clearDebugRequests(ServerPlayer player) { + scheduledDebugRequestsByTarget.remove(player.getUUID()); + pendingDebugRequestsByTarget.remove(player.getUUID()); + } + + private void expireRequests(MinecraftServer server) { + long timeoutTicks = TradeConfig.requestTimeoutSeconds() * 20L; + if (timeoutTicks <= 0) { + return; + } + + pendingRequestsByTarget.entrySet().removeIf(entry -> { + TradeRequest request = entry.getValue(); + if (server.getTickCount() - request.createdTick() < timeoutTicks) { + return false; + } + + ServerPlayer requester = server.getPlayerList().getPlayer(request.requester()); + ServerPlayer target = server.getPlayerList().getPlayer(request.target()); + if (requester != null) { + requester.sendSystemMessage(Component.literal("Your trade request expired.")); + } + if (target != null) { + target.sendSystemMessage(Component.literal("Trade request expired.")); + } + return true; + }); + } + + private void processScheduledDebugRequests(MinecraftServer server) { + List scheduledRequests = new ArrayList<>(scheduledDebugRequestsByTarget.values()); + for (ScheduledDebugRequest scheduled : scheduledRequests) { + if (server.getTickCount() < scheduled.triggerTick()) { + continue; + } + + scheduledDebugRequestsByTarget.remove(scheduled.target()); + ServerPlayer target = server.getPlayerList().getPlayer(scheduled.target()); + if (target == null || isBusy(target)) { + continue; + } + + if (scheduled.autoAccept()) { + startOrDelayDebugTrade(target); + continue; + } + + pendingDebugRequestsByTarget.put(target.getUUID(), new DebugTradeRequest(target.getUUID(), server.getTickCount())); + target.sendSystemMessage(Component.literal("Debug Trader 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.")); + } + } + + private void validateActiveTrades(MinecraftServer server) { + List sessions = new ArrayList<>(sessionsById.values()); + for (TradeSession session : sessions) { + ServerPlayer first = session.firstPlayer(); + ServerPlayer second = session.secondPlayer(); + if (first.isRemoved() || second.isRemoved()) { + cancel(session, Component.literal("Trade cancelled because one player became unavailable.")); + continue; + } + + if (!withinTradeRange(first, second)) { + cancel(session, Component.literal("Trade cancelled because players moved too far apart.")); + continue; + } + + List firstFailures = tradeSafetyFailures(first, server.getTickCount()); + if (!firstFailures.isEmpty()) { + cancel(session, Component.literal("Trade cancelled because " + first.getGameProfile().getName() + " is no longer in a safe state to trade: ").append(joinReasons(firstFailures))); + continue; + } + + List secondFailures = tradeSafetyFailures(second, server.getTickCount()); + if (!secondFailures.isEmpty()) { + cancel(session, Component.literal("Trade cancelled because " + second.getGameProfile().getName() + " is no longer in a safe state to trade: ").append(joinReasons(secondFailures))); + } + } + + List debugSessions = new ArrayList<>(debugSessionsById.values()); + for (DebugTradeSession session : debugSessions) { + ServerPlayer player = session.player(); + if (player.isRemoved()) { + closeDebug(session, Component.literal("Debug trade closed because you became unavailable.")); + continue; + } + + List failures = tradeSafetyFailures(session, player, server.getTickCount()); + if (!failures.isEmpty()) { + closeDebug(session, Component.literal("Debug trade cancelled because you are no longer in a safe state to trade: ").append(joinReasons(failures))); + } + } + } + + private void processPendingAcceptances(MinecraftServer server) { + List pendingAcceptances = new ArrayList<>(pendingAcceptancesByRequester.values()); + for (PendingAcceptance pending : pendingAcceptances) { + ServerPlayer requester = server.getPlayerList().getPlayer(pending.requester()); + ServerPlayer target = pending.target() == null ? null : server.getPlayerList().getPlayer(pending.target()); + if (requester == null || (!pending.debug() && target == null)) { + clearPendingAcceptanceByRequester(pending.requester()); + continue; + } + + if (isTrading(requester) || (target != null && isTrading(target))) { + clearPendingAcceptanceByRequester(pending.requester()); + continue; + } + + if (target != null && !withinTradeRange(requester, target)) { + clearPendingAcceptance( + requester, + Component.literal("Trade request cancelled because " + target.getGameProfile().getName() + " is too far away to trade."), + Component.literal("Trade request cancelled because players moved too far apart.")); + continue; + } + + long currentTick = server.getTickCount(); + if (hasNonTransientSafetyFailure(requester, currentTick)) { + List failures = tradeSafetyFailures(requester, currentTick); + clearPendingAcceptance( + requester, + Component.literal("Trade request cancelled: you are no longer in a safe state to trade (").append(joinReasons(failures)).append(Component.literal(").")), + Component.literal("Trade request cancelled because " + requester.getGameProfile().getName() + " is no longer in a safe state to trade: ").append(joinReasons(failures))); + continue; + } + + boolean needsGround = TradeConfig.requireOnGround() && !requester.onGround(); + boolean needsStill = TradeConfig.requireStationary() && !isStationary(requester); + Long stationarySinceTick = pending.stationarySinceTick(); + + if (needsGround || needsStill) { + pending.stationarySinceTick(null); + if (currentTick > pending.deadlineTick()) { + List failures = tradeSafetyFailures(requester, currentTick); + clearPendingAcceptance( + requester, + Component.literal("Trade request cancelled because you did not get to safety in time: ").append(joinReasons(failures)), + Component.literal("Trade request cancelled because " + requester.getGameProfile().getName() + " did not get to safety in time.")); + } else { + showPendingAcceptanceCountdown(requester, pending, needsGround, needsStill, currentTick); + } + continue; + } + + if (stationarySinceTick == null) { + if (currentTick > pending.deadlineTick()) { + clearPendingAcceptance( + requester, + Component.literal("Trade request cancelled because you did not stay still long enough."), + Component.literal("Trade request cancelled because " + requester.getGameProfile().getName() + " did not stay still long enough.")); + continue; + } + pending.stationarySinceTick(currentTick); + stationarySinceTick = currentTick; + } + + long heldTicks = currentTick - stationarySinceTick; + if (heldTicks >= ACCEPT_HOLD_TICKS) { + clearPendingAcceptanceByRequester(pending.requester()); + if (pending.debug()) { + if (!initDebugTrade(requester)) { + requester.sendSystemMessage(Component.literal("Debug trade could not be started.")); + } + } else if (!startTrade(requester, target)) { + requester.sendSystemMessage(Component.literal("Trade could not be started. One of you is already trading.")); + target.sendSystemMessage(Component.literal("Trade could not be started. One of you is already trading.")); + } + continue; + } + + long remainingHoldTicks = ACCEPT_HOLD_TICKS - heldTicks; + requester.displayClientMessage(Component.literal("Trade opening: hold still for " + formatSecondsTenths(remainingHoldTicks) + "s"), true); + } + } + + private List tradeSafetyFailures(ServerPlayer player, long currentTick) { + List failures = new ArrayList<>(); + if (!player.isAlive() || player.isDeadOrDying()) { + failures.add(Component.literal("you must be alive")); + } + if (TradeConfig.requireOnGround() && !player.onGround()) { + failures.add(Component.literal("you must be on solid ground")); + } + if (TradeConfig.requireStationary() && !isStationary(player)) { + failures.add(Component.literal("you must be standing still")); + } + if (TradeConfig.requireNoRecentDamage()) { + long lastDamageTick = lastDamageTickByPlayer.getOrDefault(player.getUUID(), Long.MIN_VALUE / 4); + long elapsed = currentTick - lastDamageTick; + if (elapsed < TradeConfig.noDamageSeconds() * 20L) { + failures.add(Component.literal("you were damaged too recently")); + } + } + if (TradeConfig.requireNotOnFire() && player.isOnFire()) { + failures.add(Component.literal("you cannot be on fire")); + } + if (TradeConfig.requireNotInLiquid() && (player.isInWaterOrBubble() || player.isInLava())) { + failures.add(Component.literal("you cannot be in liquid")); + } + if (TradeConfig.requireNotSleeping() && player.isSleeping()) { + failures.add(Component.literal("you cannot be sleeping")); + } + if (TradeConfig.requireNotFallFlying() && player.isFallFlying()) { + failures.add(Component.literal("you cannot be gliding")); + } + if (TradeConfig.requireNotRiding() && player.isPassenger()) { + failures.add(Component.literal("you cannot be mounted")); + } + if (player.isSpectator()) { + failures.add(Component.literal("spectators cannot trade")); + } + return failures; + } + + private List tradeSafetyFailures(DebugTradeSession session, ServerPlayer player, long currentTick) { + List failures = new ArrayList<>(); + if (session.hasUnsafeState(DebugUnsafeState.DAMAGE) && TradeConfig.requireNoRecentDamage()) { + failures.add(Component.literal("you were damaged too recently")); + } + if (session.hasUnsafeState(DebugUnsafeState.FIRE) && TradeConfig.requireNotOnFire()) { + failures.add(Component.literal("you cannot be on fire")); + } + if (session.hasUnsafeState(DebugUnsafeState.LIQUID) && TradeConfig.requireNotInLiquid()) { + failures.add(Component.literal("you cannot be in liquid")); + } + if (session.hasUnsafeState(DebugUnsafeState.MOVING) && TradeConfig.requireStationary()) { + failures.add(Component.literal("you must be standing still")); + } + if (session.hasUnsafeState(DebugUnsafeState.SLEEPING) && TradeConfig.requireNotSleeping()) { + failures.add(Component.literal("you cannot be sleeping")); + } + if (session.hasUnsafeState(DebugUnsafeState.RIDING) && TradeConfig.requireNotRiding()) { + failures.add(Component.literal("you cannot be mounted")); + } + if (session.hasUnsafeState(DebugUnsafeState.GLIDING) && TradeConfig.requireNotFallFlying()) { + failures.add(Component.literal("you cannot be gliding")); + } + failures.addAll(tradeSafetyFailures(player, currentTick)); + return failures; + } + + private Component joinReasons(List reasons) { + MutableComponent message = Component.empty(); + for (int i = 0; i < reasons.size(); i++) { + if (i > 0) { + message = message.append(Component.literal(", ")); + } + message = message.append(reasons.get(i)); + } + return message; + } + + private boolean isBusy(ServerPlayer player) { + return isTrading(player) + || pendingAcceptanceByPlayer.containsKey(player.getUUID()) + || scheduledDebugRequestsByTarget.containsKey(player.getUUID()) + || pendingDebugRequestsByTarget.containsKey(player.getUUID()); + } + + private boolean canDelayTradeAcceptance(ServerPlayer requester, long currentTick) { + if (hasNonTransientSafetyFailure(requester, currentTick)) { + return false; + } + return (TradeConfig.requireOnGround() && !requester.onGround()) + || (TradeConfig.requireStationary() && !isStationary(requester)); + } + + private boolean hasNonTransientSafetyFailure(ServerPlayer player, long currentTick) { + if (!player.isAlive() || player.isDeadOrDying()) { + return true; + } + if (TradeConfig.requireNoRecentDamage()) { + long lastDamageTick = lastDamageTickByPlayer.getOrDefault(player.getUUID(), Long.MIN_VALUE / 4); + long elapsed = currentTick - lastDamageTick; + if (elapsed < TradeConfig.noDamageSeconds() * 20L) { + return true; + } + } + if (TradeConfig.requireNotOnFire() && player.isOnFire()) { + return true; + } + if (TradeConfig.requireNotInLiquid() && (player.isInWaterOrBubble() || player.isInLava())) { + return true; + } + if (TradeConfig.requireNotSleeping() && player.isSleeping()) { + return true; + } + if (TradeConfig.requireNotFallFlying() && player.isFallFlying()) { + return true; + } + if (TradeConfig.requireNotRiding() && player.isPassenger()) { + return true; + } + return player.isSpectator(); + } + + private void beginPendingAcceptance(ServerPlayer requester, ServerPlayer target, long currentTick) { + clearPendingAcceptanceByRequester(requester.getUUID()); + PendingAcceptance pending = new PendingAcceptance(requester.getUUID(), target == null ? null : target.getUUID(), currentTick + ACCEPT_GRACE_TICKS, target == null); + pendingAcceptancesByRequester.put(requester.getUUID(), pending); + pendingAcceptanceByPlayer.put(requester.getUUID(), requester.getUUID()); + if (target != null) { + pendingAcceptanceByPlayer.put(target.getUUID(), requester.getUUID()); + requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " accepted your trade request. Land and stand still to open the trade.")); + target.sendSystemMessage(Component.literal("Waiting for " + requester.getGameProfile().getName() + " to land and stand still before opening the trade.")); + } else { + requester.sendSystemMessage(Component.literal("Debug Trader accepted your trade request. Land and stand still to open the trade.")); + } + showPendingAcceptanceCountdown(requester, pending, TradeConfig.requireOnGround() && !requester.onGround(), TradeConfig.requireStationary() && !isStationary(requester), currentTick); + } + + private void showPendingAcceptanceCountdown(ServerPlayer requester, PendingAcceptance pending, boolean needsGround, boolean needsStill, long currentTick) { + long remainingTicks = Math.max(0L, pending.deadlineTick() - currentTick); + MutableComponent message = Component.literal("Trade opening: "); + if (needsGround && needsStill) { + message.append("land and stop moving"); + } else if (needsGround) { + message.append("land on solid ground"); + } else { + message.append("stop moving"); + } + message.append(" within ").append(Component.literal(String.valueOf((remainingTicks + 19L) / 20L))).append("s"); + requester.displayClientMessage(message, true); + } + + private void clearPendingAcceptance(ServerPlayer requester, Component requesterMessage, Component targetMessage) { + UUID requesterId = requester.getUUID(); + PendingAcceptance pending = pendingAcceptancesByRequester.get(requesterId); + if (pending == null) { + return; + } + + ServerPlayer liveRequester = requester.server.getPlayerList().getPlayer(pending.requester()); + ServerPlayer target = pending.target() == null ? null : requester.server.getPlayerList().getPlayer(pending.target()); + clearPendingAcceptanceByRequester(requesterId); + if (requesterMessage != null && liveRequester != null) { + liveRequester.sendSystemMessage(requesterMessage); + } + if (targetMessage != null && target != null) { + target.sendSystemMessage(targetMessage); + } + } + + private void clearPendingAcceptanceByRequester(UUID requesterId) { + PendingAcceptance pending = pendingAcceptancesByRequester.remove(requesterId); + if (pending == null) { + return; + } + pendingAcceptanceByPlayer.remove(pending.requester()); + if (pending.target() != null) { + pendingAcceptanceByPlayer.remove(pending.target()); + } + } + + private boolean hasPendingDebugRequest(ServerPlayer player) { + return pendingDebugRequestsByTarget.containsKey(player.getUUID()) || scheduledDebugRequestsByTarget.containsKey(player.getUUID()); + } + + private boolean startOrDelayDebugTrade(ServerPlayer player) { + long currentTick = player.server.getTickCount(); + List unsafe = tradeSafetyFailures(player, currentTick); + if (!unsafe.isEmpty()) { + if (canDelayTradeAcceptance(player, currentTick)) { + beginPendingAcceptance(player, null, currentTick); + return true; + } + player.sendSystemMessage(Component.literal("Debug trade request cancelled: you are not in a safe state to trade (").append(joinReasons(unsafe)).append(Component.literal(")."))); + return false; + } + + return initDebugTrade(player); + } + + public boolean acceptPendingDebugTrade(ServerPlayer player) { + if (!TradeConfig.enableDebugFeatures()) { + return false; + } + DebugTradeRequest request = pendingDebugRequestsByTarget.remove(player.getUUID()); + if (request == null) { + return false; + } + return startOrDelayDebugTrade(player); + } + + private String formatSecondsTenths(long ticks) { + long tenths = Math.max(0L, (ticks * 10L + 19L) / 20L); + return String.format(java.util.Locale.ROOT, "%.1f", tenths / 10.0D); + } + + private DebugUnsafeState parseDebugUnsafeState(String spec) { + try { + return DebugUnsafeState.valueOf(spec.trim().toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("Unknown debug unsafe state: " + spec); + } + } + + private boolean isStationary(ServerPlayer player) { + Vec3 movement = player.getDeltaMovement(); + double horizontalLengthSqr = (movement.x * movement.x) + (movement.z * movement.z); + double threshold = TradeConfig.stationarySpeedThreshold(); + return horizontalLengthSqr <= threshold * threshold; + } + + private TradePreferencesData preferences(MinecraftServer server) { + return TradePreferencesData.get(server); + } + + private record TradeRequest(UUID requester, UUID target, long createdTick) {} + + private record DebugTradeRequest(UUID target, long createdTick) {} + + private record ScheduledDebugRequest(UUID target, long triggerTick, boolean autoAccept) {} + + private static final class PendingAcceptance { + private final UUID requester; + private final UUID target; + private final long deadlineTick; + private final boolean debug; + private Long stationarySinceTick; + + private PendingAcceptance(UUID requester, UUID target, long deadlineTick, boolean debug) { + this.requester = requester; + this.target = target; + this.deadlineTick = deadlineTick; + this.debug = debug; + } + + public UUID requester() { + return requester; + } + + public UUID target() { + return target; + } + + public long deadlineTick() { + return deadlineTick; + } + + public boolean debug() { + return debug; + } + + public Long stationarySinceTick() { + return stationarySinceTick; + } + + public void stationarySinceTick(Long stationarySinceTick) { + this.stationarySinceTick = stationarySinceTick; + } + } } diff --git a/src/main/java/com/trunksbomb/trade/trade/TradePreferencesData.java b/src/main/java/com/trunksbomb/trade/trade/TradePreferencesData.java new file mode 100644 index 0000000..03daeef --- /dev/null +++ b/src/main/java/com/trunksbomb/trade/trade/TradePreferencesData.java @@ -0,0 +1,114 @@ +package com.trunksbomb.trade.mod.trade; + +import com.mojang.datafixers.util.Pair; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.nbt.Tag; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.saveddata.SavedData; + +public class TradePreferencesData extends SavedData { + private static final String DATA_NAME = "trade_preferences"; + + private final Map tradeEnabled = new HashMap<>(); + private final Map> ignoredPlayers = new HashMap<>(); + + public static TradePreferencesData get(MinecraftServer server) { + return server.overworld().getDataStorage().computeIfAbsent(factory(), DATA_NAME); + } + + private static Factory factory() { + return new Factory<>(TradePreferencesData::new, TradePreferencesData::load); + } + + private static TradePreferencesData load(CompoundTag tag, HolderLookup.Provider registries) { + TradePreferencesData data = new TradePreferencesData(); + + CompoundTag enabledTag = tag.getCompound("trade_enabled"); + for (String key : enabledTag.getAllKeys()) { + data.tradeEnabled.put(UUID.fromString(key), enabledTag.getBoolean(key)); + } + + CompoundTag ignoredTag = tag.getCompound("ignored_players"); + for (String key : ignoredTag.getAllKeys()) { + ListTag values = ignoredTag.getList(key, Tag.TAG_STRING); + Set ignored = new HashSet<>(); + for (Tag value : values) { + ignored.add(UUID.fromString(value.getAsString())); + } + data.ignoredPlayers.put(UUID.fromString(key), ignored); + } + + return data; + } + + public boolean isTradeEnabled(UUID playerId) { + return tradeEnabled.getOrDefault(playerId, true); + } + + public void setTradeEnabled(UUID playerId, boolean enabled) { + tradeEnabled.put(playerId, enabled); + setDirty(); + } + + public boolean isIgnoring(UUID playerId, UUID ignoredPlayerId) { + return ignoredPlayers.getOrDefault(playerId, Set.of()).contains(ignoredPlayerId); + } + + public boolean addIgnoredPlayer(UUID playerId, UUID ignoredPlayerId) { + boolean changed = ignoredPlayers.computeIfAbsent(playerId, ignored -> new HashSet<>()).add(ignoredPlayerId); + if (changed) { + setDirty(); + } + return changed; + } + + public boolean removeIgnoredPlayer(UUID playerId, UUID ignoredPlayerId) { + Set ignored = ignoredPlayers.get(playerId); + if (ignored == null) { + return false; + } + + boolean changed = ignored.remove(ignoredPlayerId); + if (ignored.isEmpty()) { + ignoredPlayers.remove(playerId); + } + if (changed) { + setDirty(); + } + return changed; + } + + public List ignoredPlayers(UUID playerId) { + return new ArrayList<>(ignoredPlayers.getOrDefault(playerId, Set.of())); + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + CompoundTag enabledTag = new CompoundTag(); + for (Map.Entry entry : tradeEnabled.entrySet()) { + enabledTag.putBoolean(entry.getKey().toString(), entry.getValue()); + } + tag.put("trade_enabled", enabledTag); + + CompoundTag ignoredTag = new CompoundTag(); + for (Map.Entry> entry : ignoredPlayers.entrySet()) { + ListTag list = new ListTag(); + for (UUID ignored : entry.getValue()) { + list.add(StringTag.valueOf(ignored.toString())); + } + ignoredTag.put(entry.getKey().toString(), list); + } + tag.put("ignored_players", ignoredTag); + return tag; + } +}