diff --git a/README.md b/README.md index 43ddefa..27f1ba8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ Players can request a trade, review both offers in a shared GUI, accept once to - Warning if items are removed/modified - Inventory-safe finalization: nothing changes until safety and security checks pass and the trade succeeds - Configurable trade safety checks to prevent dangerous mid-combat or mid-movement trading +- Configurable per-player request cooldowns - Per-player trade toggles and ignore list support +- Admin-controlled player and item trade blacklists, with config defaults and live commands - File-based trade audit log for server operators ## Player Usage @@ -72,6 +74,23 @@ If a player reduces or removes items from an offer: /trade ignorelist ``` +### Admin commands + +Server operators can manage persistent live blacklists with: + +```mcfunction +/trade admin playerblock add +/trade admin playerblock remove +/trade admin playerblock list +/trade admin itemblock add item +/trade admin itemblock remove item +/trade admin itemblock add tag +/trade admin itemblock remove tag +/trade admin itemblock add mod +/trade admin itemblock remove mod +/trade admin itemblock list +``` + ### Command behavior - `/trade ` sends a trade request @@ -135,6 +154,10 @@ Server config values live under the `trade` section. - default: `30` - number of seconds before a pending trade request expires +- `requestCooldownSeconds` + - default: `5` + - number of seconds a player must wait before sending another trade request + ### Debug - `enableDebugFeatures` @@ -186,6 +209,26 @@ Server config values live under the `trade` section. - `requireSameDimension` - default: `true` +### Admin blacklists + +- `adminDisabledPlayerUuids` + - default: `[]` + - UUIDs of players who cannot use the trade system at all + +- `adminBlacklistedItemIds` + - default: command blocks, command block minecarts, barriers, bedrock, end portal blocks/frames, structure blocks, jigsaws, lights, and spawners + - exact item ids that cannot be traded, like `minecraft:diamond` + +- `adminBlacklistedItemTags` + - default: `[]` + - item tags that cannot be traded, like `minecraft:logs` + +- `adminBlacklistedMods` + - default: `[]` + - mod namespaces that cannot be traded, like `minecraft` + +Config blacklist entries are treated as defaults. Live admin commands add and remove separate saved entries that persist across restarts. + ## Audit Log The mod writes a trade audit log to: diff --git a/src/main/java/com/trunksbomb/trade/client/TradeScreen.java b/src/main/java/com/trunksbomb/trade/client/TradeScreen.java index a6c09e8..da88969 100644 --- a/src/main/java/com/trunksbomb/trade/client/TradeScreen.java +++ b/src/main/java/com/trunksbomb/trade/client/TradeScreen.java @@ -10,6 +10,7 @@ import com.trunksbomb.trade.trade.TradeView; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; @@ -251,10 +252,11 @@ public class TradeScreen extends AbstractContainerScreen acceptButton.setMessage(acceptButtonLabel()); } super.render(guiGraphics, mouseX, mouseY, partialTick); + renderProhibitedSlotWarnings(guiGraphics); renderChangedSlotWarnings(guiGraphics); renderContextMenu(guiGraphics, mouseX, mouseY); renderAmountPrompt(guiGraphics); - renderTooltip(guiGraphics, mouseX, mouseY); + renderTradeTooltip(guiGraphics, mouseX, mouseY); } private Component acceptButtonLabel() { @@ -291,6 +293,51 @@ public class TradeScreen extends AbstractContainerScreen guiGraphics.drawString(font, "!", x + 11, y - 2, color, false); } + private void renderProhibitedSlotWarnings(GuiGraphics guiGraphics) { + int color = 0xFFE09A24; + for (Slot slot : menu.slots) { + if (isProhibitedSlot(slot)) { + renderProhibitedSlotWarning(guiGraphics, slot, color); + } + } + } + + private void renderProhibitedSlotWarning(GuiGraphics guiGraphics, Slot slot, int color) { + int x = leftPos + slot.x; + int y = topPos + slot.y; + guiGraphics.fill(x - 1, y - 1, x + 17, y, color); + guiGraphics.fill(x - 1, y + 16, x + 17, y + 17, color); + guiGraphics.fill(x - 1, y, x, y + 16, color); + guiGraphics.fill(x + 16, y, x + 17, y + 16, color); + guiGraphics.drawString(font, "X", x + 9, y - 2, color, false); + } + + private boolean isProhibitedSlot(Slot slot) { + if (slot == null || !slot.hasItem()) { + return false; + } + if (slot instanceof GhostInventorySlot ghostInventorySlot) { + return menu.view().inventoryBlockedSlots().get(ghostInventorySlot.inventoryIndex()); + } + if (slot instanceof SelfOfferSlot selfOfferSlot) { + return menu.view().selfBlockedSlots().get(selfOfferSlot.offerIndex()); + } + if (slot instanceof OtherOfferSlot otherOfferSlot) { + return menu.view().otherBlockedSlots().get(otherOfferSlot.offerIndex()); + } + return false; + } + + private void renderTradeTooltip(GuiGraphics guiGraphics, int mouseX, int mouseY) { + if (hoveredSlot != null && hoveredSlot.hasItem() && isProhibitedSlot(hoveredSlot) && minecraft != null) { + List tooltip = new ArrayList<>(getTooltipFromItem(minecraft, hoveredSlot.getItem())); + tooltip.add(Component.literal("This item cannot be traded").withStyle(ChatFormatting.RED)); + guiGraphics.renderTooltip(font, tooltip, hoveredSlot.getItem().getTooltipImage(), mouseX, mouseY); + return; + } + renderTooltip(guiGraphics, mouseX, mouseY); + } + private void sendAction(TradeAction action, int slot, int amount) { if (minecraft == null || minecraft.getConnection() == null) { return; diff --git a/src/main/java/com/trunksbomb/trade/command/TradeCommand.java b/src/main/java/com/trunksbomb/trade/command/TradeCommand.java index 2f1ff0b..ddd5179 100644 --- a/src/main/java/com/trunksbomb/trade/command/TradeCommand.java +++ b/src/main/java/com/trunksbomb/trade/command/TradeCommand.java @@ -8,10 +8,12 @@ import com.trunksbomb.trade.mod.TradeConfig; import com.trunksbomb.trade.trade.DebugUnsafeState; import com.trunksbomb.trade.trade.DebugTradeSession; import com.trunksbomb.trade.trade.TradeManager; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; public final class TradeCommand { @@ -53,6 +55,7 @@ public final class TradeCommand { .then(Commands.literal("close").executes(context -> closeDebug(context.getSource())))); dispatcher.register(trade + .then(adminCommands()) .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()))) @@ -72,6 +75,43 @@ public final class TradeCommand { .executes(context -> requestTrade(context.getSource(), StringArgumentType.getString(context, "player"))))); } + private static com.mojang.brigadier.builder.LiteralArgumentBuilder adminCommands() { + return Commands.literal("admin") + .requires(source -> source.hasPermission(2)) + .then(Commands.literal("playerblock") + .then(Commands.literal("add") + .then(Commands.argument("player", StringArgumentType.word()) + .suggests((context, builder) -> suggestPlayers(context.getSource(), builder)) + .executes(context -> addPlayerBlock(context.getSource(), StringArgumentType.getString(context, "player"))))) + .then(Commands.literal("remove") + .then(Commands.argument("player", StringArgumentType.word()) + .suggests((context, builder) -> suggestPlayers(context.getSource(), builder)) + .executes(context -> removePlayerBlock(context.getSource(), StringArgumentType.getString(context, "player"))))) + .then(Commands.literal("list").executes(context -> listPlayerBlocks(context.getSource())))) + .then(Commands.literal("itemblock") + .then(Commands.literal("add") + .then(Commands.literal("item") + .then(Commands.argument("id", StringArgumentType.word()) + .executes(context -> addItemBlock(context.getSource(), BlacklistKind.ITEM, StringArgumentType.getString(context, "id"))))) + .then(Commands.literal("tag") + .then(Commands.argument("id", StringArgumentType.word()) + .executes(context -> addItemBlock(context.getSource(), BlacklistKind.TAG, StringArgumentType.getString(context, "id"))))) + .then(Commands.literal("mod") + .then(Commands.argument("id", StringArgumentType.word()) + .executes(context -> addItemBlock(context.getSource(), BlacklistKind.MOD, StringArgumentType.getString(context, "id")))))) + .then(Commands.literal("remove") + .then(Commands.literal("item") + .then(Commands.argument("id", StringArgumentType.word()) + .executes(context -> removeItemBlock(context.getSource(), BlacklistKind.ITEM, StringArgumentType.getString(context, "id"))))) + .then(Commands.literal("tag") + .then(Commands.argument("id", StringArgumentType.word()) + .executes(context -> removeItemBlock(context.getSource(), BlacklistKind.TAG, StringArgumentType.getString(context, "id"))))) + .then(Commands.literal("mod") + .then(Commands.argument("id", StringArgumentType.word()) + .executes(context -> removeItemBlock(context.getSource(), BlacklistKind.MOD, StringArgumentType.getString(context, "id")))))) + .then(Commands.literal("list").executes(context -> listItemBlocks(context.getSource())))); + } + private static CompletableFuture suggestPlayers(CommandSourceStack source, SuggestionsBuilder builder) { for (ServerPlayer player : source.getServer().getPlayerList().getPlayers()) { if (!player.getGameProfile().getName().equalsIgnoreCase(source.getTextName())) { @@ -214,6 +254,146 @@ public final class TradeCommand { return 1; } + private static int addPlayerBlock(CommandSourceStack source, String targetSpec) { + ResolvedPlayer target = resolvePlayerOrUuid(source, targetSpec); + if (target == null) { + source.sendFailure(Component.literal("That player is not online, and the value is not a valid UUID.")); + return 0; + } + + TradeManager manager = TradeManager.get(source.getServer()); + boolean changed = target.player() != null + ? manager.addAdminDisabledPlayer(target.player()) + : manager.addAdminDisabledPlayer(target.uuid(), source.getServer()); + if (!changed) { + source.sendFailure(Component.literal(target.label() + " is already blocked from trading.")); + return 0; + } + + source.sendSuccess(() -> Component.literal(target.label() + " can no longer use the trade system."), true); + return 1; + } + + private static int removePlayerBlock(CommandSourceStack source, String targetSpec) { + ResolvedPlayer target = resolvePlayerOrUuid(source, targetSpec); + if (target == null) { + source.sendFailure(Component.literal("That player is not online, and the value is not a valid UUID.")); + return 0; + } + + TradeManager manager = TradeManager.get(source.getServer()); + boolean changed = target.player() != null + ? manager.removeAdminDisabledPlayer(target.player()) + : manager.removeAdminDisabledPlayer(target.uuid(), source.getServer()); + if (!changed) { + source.sendFailure(Component.literal(target.label() + " is not blocked from trading.")); + return 0; + } + + source.sendSuccess(() -> Component.literal(target.label() + " can use the trade system again."), true); + return 1; + } + + private static int listPlayerBlocks(CommandSourceStack source) { + java.util.List blocked = TradeManager.get(source.getServer()).adminDisabledPlayerNames(source.getServer()); + if (blocked.isEmpty()) { + source.sendSuccess(() -> Component.literal("No admin trade-blocked players are stored in data."), false); + return 1; + } + + source.sendSuccess(() -> Component.literal("Admin trade-blocked players: " + String.join(", ", blocked)), false); + return 1; + } + + private static int addItemBlock(CommandSourceStack source, BlacklistKind kind, String rawValue) { + String value = normalizeBlacklistValue(kind, rawValue); + if (value == null) { + source.sendFailure(Component.literal(kind.invalidMessage())); + return 0; + } + + TradeManager manager = TradeManager.get(source.getServer()); + boolean changed = switch (kind) { + case ITEM -> manager.addAdminBlacklistedItemId(source.getServer(), value); + case TAG -> manager.addAdminBlacklistedItemTag(source.getServer(), value); + case MOD -> manager.addAdminBlacklistedMod(source.getServer(), value); + }; + if (!changed) { + source.sendFailure(Component.literal(kind.label() + " blacklist already contains " + value + ".")); + return 0; + } + + source.sendSuccess(() -> Component.literal("Added " + kind.label() + " blacklist entry: " + value + "."), true); + return 1; + } + + private static int removeItemBlock(CommandSourceStack source, BlacklistKind kind, String rawValue) { + String value = normalizeBlacklistValue(kind, rawValue); + if (value == null) { + source.sendFailure(Component.literal(kind.invalidMessage())); + return 0; + } + + TradeManager manager = TradeManager.get(source.getServer()); + boolean changed = switch (kind) { + case ITEM -> manager.removeAdminBlacklistedItemId(source.getServer(), value); + case TAG -> manager.removeAdminBlacklistedItemTag(source.getServer(), value); + case MOD -> manager.removeAdminBlacklistedMod(source.getServer(), value); + }; + if (!changed) { + source.sendFailure(Component.literal(kind.label() + " blacklist does not contain " + value + ".")); + return 0; + } + + source.sendSuccess(() -> Component.literal("Removed " + kind.label() + " blacklist entry: " + value + "."), true); + return 1; + } + + private static int listItemBlocks(CommandSourceStack source) { + TradeManager manager = TradeManager.get(source.getServer()); + java.util.List itemIds = manager.adminBlacklistedItemIds(source.getServer()); + java.util.List tags = manager.adminBlacklistedItemTags(source.getServer()); + java.util.List mods = manager.adminBlacklistedMods(source.getServer()); + source.sendSuccess( + () -> Component.literal("Admin item blocks | item ids: " + + joinOrNone(itemIds) + + " | tags: " + + joinOrNone(tags) + + " | mods: " + + joinOrNone(mods)), + false); + return 1; + } + + private static String joinOrNone(java.util.List values) { + return values.isEmpty() ? "(none)" : String.join(", ", values); + } + + private static String normalizeBlacklistValue(BlacklistKind kind, String rawValue) { + String value = rawValue.trim().toLowerCase(java.util.Locale.ROOT); + if (value.isEmpty()) { + return null; + } + return switch (kind) { + case ITEM, TAG -> ResourceLocation.tryParse(value) != null ? value : null; + case MOD -> value.matches("[a-z0-9_.-]+") ? value : null; + }; + } + + private static ResolvedPlayer resolvePlayerOrUuid(CommandSourceStack source, String targetSpec) { + ServerPlayer online = source.getServer().getPlayerList().getPlayerByName(targetSpec); + if (online != null) { + return new ResolvedPlayer(online, online.getUUID(), online.getGameProfile().getName()); + } + + try { + UUID uuid = UUID.fromString(targetSpec); + return new ResolvedPlayer(null, uuid, uuid.toString()); + } catch (IllegalArgumentException ignored) { + return null; + } + } + private static int initDebug(CommandSourceStack source, int delaySeconds) { if (!(source.getEntity() instanceof ServerPlayer player)) { source.sendFailure(Component.literal("Only players can start debug trades.")); @@ -357,4 +537,28 @@ public final class TradeCommand { source.sendSuccess(() -> Component.literal(successMessage), false); return 1; } + + private enum BlacklistKind { + ITEM("item id", "Item ids must look like namespace:path."), + TAG("tag", "Tags must look like namespace:path."), + MOD("mod", "Mod ids may only contain lowercase letters, numbers, underscore, dash, and dot."); + + private final String label; + private final String invalidMessage; + + BlacklistKind(String label, String invalidMessage) { + this.label = label; + this.invalidMessage = invalidMessage; + } + + public String label() { + return label; + } + + public String invalidMessage() { + return invalidMessage; + } + } + + private record ResolvedPlayer(ServerPlayer player, UUID uuid, String label) {} } diff --git a/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java b/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java index 23d20bb..6bd5fda 100644 --- a/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java +++ b/src/main/java/com/trunksbomb/trade/mod/TradeConfig.java @@ -6,6 +6,7 @@ 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.IntValue REQUEST_COOLDOWN_SECONDS; private static final ModConfigSpec.BooleanValue ENABLE_DEBUG_FEATURES; private static final ModConfigSpec.BooleanValue REQUIRE_SECOND_CONFIRMATION; private static final ModConfigSpec.BooleanValue SHOW_TRADE_MODIFIED_WARNINGS; @@ -20,6 +21,10 @@ public final class TradeConfig { 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; + private static final ModConfigSpec.ConfigValue> ADMIN_DISABLED_PLAYER_UUIDS; + private static final ModConfigSpec.ConfigValue> ADMIN_BLACKLISTED_ITEM_IDS; + private static final ModConfigSpec.ConfigValue> ADMIN_BLACKLISTED_ITEM_TAGS; + private static final ModConfigSpec.ConfigValue> ADMIN_BLACKLISTED_MODS; static { ModConfigSpec.Builder builder = new ModConfigSpec.Builder(); @@ -30,6 +35,9 @@ public final class TradeConfig { REQUEST_TIMEOUT_SECONDS = builder .comment("Seconds before a trade request expires.") .defineInRange("requestTimeoutSeconds", 30, 1, 3600); + REQUEST_COOLDOWN_SECONDS = builder + .comment("Seconds a player must wait between trade requests. 0 disables the cooldown.") + .defineInRange("requestCooldownSeconds", 5, 0, 3600); ENABLE_DEBUG_FEATURES = builder.comment("Enable debug trade commands and debug UI/testing tools.") .define("enableDebugFeatures", false); REQUIRE_SECOND_CONFIRMATION = builder.comment("Require a second confirmation step after both players accept the initial offer.") @@ -58,6 +66,33 @@ public final class TradeConfig { .define("requireNotRiding", true); REQUIRE_SAME_DIMENSION = builder.comment("Require both players to be in the same dimension.") .define("requireSameDimension", true); + ADMIN_DISABLED_PLAYER_UUIDS = builder + .comment("Players who cannot use the trade system at all, identified by UUID.") + .defineListAllowEmpty("adminDisabledPlayerUuids", java.util.List.of(), value -> value instanceof String); + ADMIN_BLACKLISTED_ITEM_IDS = builder + .comment("Exact item ids that cannot be traded, like minecraft:diamond.") + .defineListAllowEmpty( + "adminBlacklistedItemIds", + java.util.List.of( + "minecraft:command_block", + "minecraft:chain_command_block", + "minecraft:repeating_command_block", + "minecraft:command_block_minecart", + "minecraft:barrier", + "minecraft:bedrock", + "minecraft:end_portal_frame", + "minecraft:end_portal", + "minecraft:structure_block", + "minecraft:jigsaw", + "minecraft:light", + "minecraft:spawner"), + value -> value instanceof String); + ADMIN_BLACKLISTED_ITEM_TAGS = builder + .comment("Item tags that cannot be traded, like c:gems or minecraft:logs.") + .defineListAllowEmpty("adminBlacklistedItemTags", java.util.List.of(), value -> value instanceof String); + ADMIN_BLACKLISTED_MODS = builder + .comment("Item namespaces/mod ids that cannot be traded.") + .defineListAllowEmpty("adminBlacklistedMods", java.util.List.of(), value -> value instanceof String); builder.pop(); SPEC = builder.build(); } @@ -72,6 +107,10 @@ public final class TradeConfig { return REQUEST_TIMEOUT_SECONDS.get(); } + public static int requestCooldownSeconds() { + return REQUEST_COOLDOWN_SECONDS.get(); + } + public static boolean enableDebugFeatures() { return ENABLE_DEBUG_FEATURES.get(); } @@ -135,4 +174,24 @@ public final class TradeConfig { public static boolean requireSameDimension() { return REQUIRE_SAME_DIMENSION.get(); } + + public static java.util.List adminDisabledPlayerUuids() { + return copyStringList(ADMIN_DISABLED_PLAYER_UUIDS.get()); + } + + public static java.util.List adminBlacklistedItemIds() { + return copyStringList(ADMIN_BLACKLISTED_ITEM_IDS.get()); + } + + public static java.util.List adminBlacklistedItemTags() { + return copyStringList(ADMIN_BLACKLISTED_ITEM_TAGS.get()); + } + + public static java.util.List adminBlacklistedMods() { + return copyStringList(ADMIN_BLACKLISTED_MODS.get()); + } + + private static java.util.List copyStringList(java.util.List values) { + return new java.util.ArrayList<>(values); + } } diff --git a/src/main/java/com/trunksbomb/trade/trade/DebugTradeSession.java b/src/main/java/com/trunksbomb/trade/trade/DebugTradeSession.java index a44fea9..227b2e9 100644 --- a/src/main/java/com/trunksbomb/trade/trade/DebugTradeSession.java +++ b/src/main/java/com/trunksbomb/trade/trade/DebugTradeSession.java @@ -285,8 +285,11 @@ public class DebugTradeSession { showModifiedWarnings && hasChangedSlots(), inventoryDisplay(), emptyReservedSnapshot(), + blankInventoryBlockedSlots(), selfOfferSnapshot(), otherOfferSnapshot(), + blankChangedSlots(), + blankChangedSlots(), showModifiedWarnings ? changedSlotSnapshot(selfChangedSlots) : blankChangedSlots(), showModifiedWarnings ? changedSlotSnapshot(otherChangedSlots) : blankChangedSlots()); } @@ -490,6 +493,14 @@ public class DebugTradeSession { return result; } + private static List blankInventoryBlockedSlots() { + List result = new ArrayList<>(INVENTORY_SLOT_COUNT); + for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) { + result.add(false); + } + return result; + } + private static List blankChangedSlots() { List result = new ArrayList<>(OFFER_SLOT_COUNT); for (int i = 0; i < OFFER_SLOT_COUNT; i++) { diff --git a/src/main/java/com/trunksbomb/trade/trade/TradeAdminData.java b/src/main/java/com/trunksbomb/trade/trade/TradeAdminData.java new file mode 100644 index 0000000..80e3d97 --- /dev/null +++ b/src/main/java/com/trunksbomb/trade/trade/TradeAdminData.java @@ -0,0 +1,165 @@ +package com.trunksbomb.trade.trade; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +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 TradeAdminData extends SavedData { + private static final String DATA_NAME = "trade_admin"; + + private final Set disabledPlayers = new HashSet<>(); + private final Set blacklistedItemIds = new HashSet<>(); + private final Set blacklistedItemTags = new HashSet<>(); + private final Set blacklistedMods = new HashSet<>(); + + public static TradeAdminData get(MinecraftServer server) { + return server.overworld().getDataStorage().computeIfAbsent(factory(), DATA_NAME); + } + + private static Factory factory() { + return new Factory<>(TradeAdminData::new, TradeAdminData::load); + } + + private static TradeAdminData load(CompoundTag tag, HolderLookup.Provider registries) { + TradeAdminData data = new TradeAdminData(); + readUuidSet(tag.getList("disabled_players", Tag.TAG_STRING), data.disabledPlayers); + readStringSet(tag.getList("blacklisted_item_ids", Tag.TAG_STRING), data.blacklistedItemIds); + readStringSet(tag.getList("blacklisted_item_tags", Tag.TAG_STRING), data.blacklistedItemTags); + readStringSet(tag.getList("blacklisted_mods", Tag.TAG_STRING), data.blacklistedMods); + return data; + } + + public boolean isPlayerDisabled(UUID playerId) { + return disabledPlayers.contains(playerId); + } + + public boolean addDisabledPlayer(UUID playerId) { + boolean changed = disabledPlayers.add(playerId); + if (changed) { + setDirty(); + } + return changed; + } + + public boolean removeDisabledPlayer(UUID playerId) { + boolean changed = disabledPlayers.remove(playerId); + if (changed) { + setDirty(); + } + return changed; + } + + public List disabledPlayers() { + return new ArrayList<>(disabledPlayers); + } + + public boolean addBlacklistedItemId(String itemId) { + return addNormalized(blacklistedItemIds, itemId); + } + + public boolean removeBlacklistedItemId(String itemId) { + return removeNormalized(blacklistedItemIds, itemId); + } + + public List blacklistedItemIds() { + return sortedCopy(blacklistedItemIds); + } + + public boolean addBlacklistedItemTag(String tagId) { + return addNormalized(blacklistedItemTags, tagId); + } + + public boolean removeBlacklistedItemTag(String tagId) { + return removeNormalized(blacklistedItemTags, tagId); + } + + public List blacklistedItemTags() { + return sortedCopy(blacklistedItemTags); + } + + public boolean addBlacklistedMod(String modId) { + return addNormalized(blacklistedMods, modId); + } + + public boolean removeBlacklistedMod(String modId) { + return removeNormalized(blacklistedMods, modId); + } + + public List blacklistedMods() { + return sortedCopy(blacklistedMods); + } + + @Override + public CompoundTag save(CompoundTag tag, HolderLookup.Provider registries) { + tag.put("disabled_players", writeUuidSet(disabledPlayers)); + tag.put("blacklisted_item_ids", writeStringSet(blacklistedItemIds)); + tag.put("blacklisted_item_tags", writeStringSet(blacklistedItemTags)); + tag.put("blacklisted_mods", writeStringSet(blacklistedMods)); + return tag; + } + + private boolean addNormalized(Set values, String value) { + String normalized = value.trim().toLowerCase(java.util.Locale.ROOT); + boolean changed = !normalized.isEmpty() && values.add(normalized); + if (changed) { + setDirty(); + } + return changed; + } + + private boolean removeNormalized(Set values, String value) { + boolean changed = values.remove(value.trim().toLowerCase(java.util.Locale.ROOT)); + if (changed) { + setDirty(); + } + return changed; + } + + private static void readUuidSet(ListTag list, Set target) { + for (Tag value : list) { + try { + target.add(UUID.fromString(value.getAsString())); + } catch (IllegalArgumentException ignored) { + // Ignore invalid persisted values so bad data does not break loading. + } + } + } + + private static void readStringSet(ListTag list, Set target) { + for (Tag value : list) { + String normalized = value.getAsString().trim().toLowerCase(java.util.Locale.ROOT); + if (!normalized.isEmpty()) { + target.add(normalized); + } + } + } + + private static ListTag writeUuidSet(Set values) { + ListTag list = new ListTag(); + for (UUID value : values.stream().sorted().toList()) { + list.add(StringTag.valueOf(value.toString())); + } + return list; + } + + private static ListTag writeStringSet(Set values) { + ListTag list = new ListTag(); + for (String value : values.stream().sorted().toList()) { + list.add(StringTag.valueOf(value)); + } + return list; + } + + private static List sortedCopy(Set values) { + return values.stream().sorted().toList(); + } +} diff --git a/src/main/java/com/trunksbomb/trade/trade/TradeManager.java b/src/main/java/com/trunksbomb/trade/trade/TradeManager.java index 1c005eb..0c47983 100644 --- a/src/main/java/com/trunksbomb/trade/trade/TradeManager.java +++ b/src/main/java/com/trunksbomb/trade/trade/TradeManager.java @@ -2,20 +2,28 @@ package com.trunksbomb.trade.trade; import com.trunksbomb.trade.mod.TradeAuditLog; import com.trunksbomb.trade.mod.TradeConfig; +import com.trunksbomb.trade.network.TradeStatePayload; import java.util.ArrayList; +import java.util.HashSet; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.WeakHashMap; +import net.minecraft.core.Holder; +import net.minecraft.core.registries.BuiltInRegistries; 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.resources.ResourceLocation; +import net.minecraft.tags.TagKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.item.ItemStack; import net.minecraft.world.phys.Vec3; +import net.neoforged.neoforge.network.PacketDistributor; public class TradeManager { private static final long ACCEPT_GRACE_TICKS = 5L * 20L; @@ -33,6 +41,7 @@ public class TradeManager { private final Map pendingAcceptancesByRequester = new HashMap<>(); private final Map pendingAcceptanceByPlayer = new HashMap<>(); private final Map lastDamageTickByPlayer = new HashMap<>(); + private final Map lastRequestTickByPlayer = new HashMap<>(); public static TradeManager get(MinecraftServer server) { return INSTANCES.computeIfAbsent(server, ignored -> new TradeManager()); @@ -43,11 +52,18 @@ public class TradeManager { return false; } + Component disabledFailure = tradeDisabledFailure(first, second); + if (disabledFailure != null) { + first.sendSystemMessage(disabledFailure); + second.sendSystemMessage(disabledFailure); + return false; + } + TradeSession session = new TradeSession(first, second); sessionsById.put(session.id(), session); sessionByPlayer.put(first.getUUID(), session.id()); sessionByPlayer.put(second.getUUID(), session.id()); - session.syncToPlayers(); + syncSession(session); TradeAuditLog.log(first.server, "OPEN " + playerAudit(first) + " <-> " + playerAudit(second)); first.sendSystemMessage(Component.literal("Trade opened with " + second.getGameProfile().getName() + ".")); second.sendSystemMessage(Component.literal("Trade opened with " + first.getGameProfile().getName() + ".")); @@ -65,6 +81,12 @@ public class TradeManager { return false; } + Component disabledFailure = tradeDisabledFailure(requester, target); + if (disabledFailure != null) { + requester.sendSystemMessage(disabledFailure); + return false; + } + if (!preferences(requester.server).isTradeEnabled(target.getUUID())) { requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is not accepting trade requests.")); return false; @@ -99,7 +121,14 @@ public class TradeManager { return false; } + long cooldownRemainingTicks = remainingRequestCooldownTicks(requester); + if (cooldownRemainingTicks > 0L) { + requester.sendSystemMessage(Component.literal("You must wait " + formatSecondsTenths(cooldownRemainingTicks) + "s before sending another trade request.")); + return false; + } + pendingRequestsByTarget.put(target.getUUID(), new TradeRequest(requester.getUUID(), target.getUUID(), target.server.getTickCount())); + lastRequestTickByPlayer.put(requester.getUUID(), (long) target.server.getTickCount()); TradeAuditLog.log(requester.server, "REQUEST " + playerAudit(requester) + " -> " + playerAudit(target)); requester.sendSystemMessage(Component.literal("Trade request sent to " + target.getGameProfile().getName() + ".")); target.sendSystemMessage(Component.literal(requester.getGameProfile().getName() + " would like to trade with you: ") @@ -125,6 +154,13 @@ public class TradeManager { return false; } + Component disabledFailure = tradeDisabledFailure(requester, target); + if (disabledFailure != null) { + requester.sendSystemMessage(disabledFailure); + target.sendSystemMessage(disabledFailure); + 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.")); @@ -216,6 +252,15 @@ public class TradeManager { } private void handleRealAction(ServerPlayer player, TradeSession session, TradeAction action, int slot, int amount) { + if (action == TradeAction.ADD_ITEM) { + ItemStack stack = session.offeredInventoryStack(player, slot); + ItemBlockResult blockedItem = blockedItem(player.server, stack); + if (blockedItem != null) { + player.sendSystemMessage(Component.literal("That item cannot be traded: " + blockedItem.reason() + ".")); + return; + } + } + boolean changed = switch (action) { case ADD_ITEM -> session.addFromInventory(player, slot, amount); case REMOVE_ITEM -> session.removeFromOffer(player, slot, amount); @@ -234,18 +279,30 @@ public class TradeManager { } if (!session.bothAccepted()) { - session.syncToPlayers(); + syncSession(session); return; } if (!session.isConfirmationStage()) { if (TradeConfig.requireSecondConfirmation()) { session.advanceToConfirmation(); - session.syncToPlayers(); + syncSession(session); return; } } + ItemBlockResult selfBlocked = blockedOffer(player.server, session.firstOfferSnapshot()); + if (selfBlocked != null) { + cancel(session, Component.literal("Trade cancelled because a blocked item was offered: " + selfBlocked.reason() + ".")); + return; + } + + ItemBlockResult otherBlocked = blockedOffer(player.server, session.secondOfferSnapshot()); + if (otherBlocked != null) { + cancel(session, Component.literal("Trade cancelled because a blocked item was offered: " + otherBlocked.reason() + ".")); + return; + } + TradeSession.CompletionResult completion = session.completeTrade(); if (completion.successful()) { finish(session, Component.literal("Trade completed.")); @@ -255,6 +312,13 @@ public class TradeManager { } private void handleDebugAction(DebugTradeSession session, TradeAction action, int slot, int amount) { + if (action == TradeAction.ADD_ITEM) { + ItemBlockResult blockedItem = blockedItem(session.player().server, session.player().getInventory().getItem(slot)); + if (blockedItem != null) { + session.player().sendSystemMessage(Component.literal("That item cannot be traded: " + blockedItem.reason() + ".")); + return; + } + } if (action == TradeAction.ACCEPT) { List unsafe = tradeSafetyFailures(session, session.player(), session.player().server.getTickCount()); if (!unsafe.isEmpty()) { @@ -281,18 +345,30 @@ public class TradeManager { } if (!session.bothAccepted()) { - session.sync(); + syncDebugSession(session); return; } if (!session.isConfirmationStage()) { if (TradeConfig.requireSecondConfirmation()) { session.advanceToConfirmation(); - session.sync(); + syncDebugSession(session); return; } } + ItemBlockResult selfBlocked = blockedOffer(session.player().server, session.selfOfferSnapshot()); + if (selfBlocked != null) { + closeDebug(session, Component.literal("Debug trade cancelled because your offer contains a blocked item: " + selfBlocked.reason() + ".")); + return; + } + + ItemBlockResult otherBlocked = blockedOffer(session.player().server, session.otherOfferSnapshot()); + if (otherBlocked != null) { + closeDebug(session, Component.literal("Debug trade cancelled because the debug offer contains a blocked item: " + otherBlocked.reason() + ".")); + return; + } + if (session.completeTrade()) { finishDebug(session, Component.literal("Debug trade completed.")); } else { @@ -304,6 +380,7 @@ public class TradeManager { clearPendingRequests(player); clearDebugRequests(player); clearPendingAcceptance(player, null, null); + lastRequestTickByPlayer.remove(player.getUUID()); TradeSession session = getSession(player); if (session != null) { @@ -367,10 +444,76 @@ public class TradeManager { return result; } + public boolean addAdminDisabledPlayer(ServerPlayer player) { + return adminData(player.server).addDisabledPlayer(player.getUUID()); + } + + public boolean addAdminDisabledPlayer(UUID playerId, MinecraftServer server) { + return adminData(server).addDisabledPlayer(playerId); + } + + public boolean removeAdminDisabledPlayer(ServerPlayer player) { + return adminData(player.server).removeDisabledPlayer(player.getUUID()); + } + + public boolean removeAdminDisabledPlayer(UUID playerId, MinecraftServer server) { + return adminData(server).removeDisabledPlayer(playerId); + } + + public List adminDisabledPlayerNames(MinecraftServer server) { + List result = new ArrayList<>(); + for (UUID playerId : adminData(server).disabledPlayers()) { + result.add(nameFor(playerId, server)); + } + result.sort(String::compareToIgnoreCase); + return result; + } + + public boolean addAdminBlacklistedItemId(MinecraftServer server, String itemId) { + return adminData(server).addBlacklistedItemId(itemId); + } + + public boolean removeAdminBlacklistedItemId(MinecraftServer server, String itemId) { + return adminData(server).removeBlacklistedItemId(itemId); + } + + public List adminBlacklistedItemIds(MinecraftServer server) { + return adminData(server).blacklistedItemIds(); + } + + public boolean addAdminBlacklistedItemTag(MinecraftServer server, String tagId) { + return adminData(server).addBlacklistedItemTag(tagId); + } + + public boolean removeAdminBlacklistedItemTag(MinecraftServer server, String tagId) { + return adminData(server).removeBlacklistedItemTag(tagId); + } + + public List adminBlacklistedItemTags(MinecraftServer server) { + return adminData(server).blacklistedItemTags(); + } + + public boolean addAdminBlacklistedMod(MinecraftServer server, String modId) { + return adminData(server).addBlacklistedMod(modId); + } + + public boolean removeAdminBlacklistedMod(MinecraftServer server, String modId) { + return adminData(server).removeBlacklistedMod(modId); + } + + public List adminBlacklistedMods(MinecraftServer server) { + return adminData(server).blacklistedMods(); + } + public boolean initDebugTrade(ServerPlayer player) { if (!TradeConfig.enableDebugFeatures()) { return false; } + Component disabledFailure = tradeDisabledFailure(player); + if (disabledFailure != null) { + player.sendSystemMessage(disabledFailure); + return false; + } if (isBusy(player)) { return false; } @@ -378,7 +521,7 @@ public class TradeManager { DebugTradeSession session = new DebugTradeSession(player); debugSessionsById.put(session.id(), session); debugSessionByPlayer.put(player.getUUID(), session.id()); - session.sync(); + syncDebugSession(session); player.sendSystemMessage(Component.literal("Debug trade opened.")); return true; } @@ -394,6 +537,11 @@ public class TradeManager { if (!TradeConfig.enableDebugFeatures()) { return false; } + Component disabledFailure = tradeDisabledFailure(player); + if (disabledFailure != null) { + player.sendSystemMessage(disabledFailure); + return false; + } if (isBusy(player) || hasPendingDebugRequest(player)) { return false; } @@ -412,8 +560,14 @@ public class TradeManager { return false; } + ItemBlockResult blockedItem = blockedOffer(player.server, offer); + if (blockedItem != null) { + player.sendSystemMessage(Component.literal("That debug offer contains a blocked item: " + blockedItem.reason() + ".")); + return false; + } + session.setOtherOffer(offer); - session.sync(); + syncDebugSession(session); return true; } @@ -431,7 +585,7 @@ public class TradeManager { boolean changed = session.removeOtherOffer(spec); if (changed) { - session.sync(); + syncDebugSession(session); return 1; } @@ -455,14 +609,14 @@ public class TradeManager { session.acceptOther(); if (!session.bothAccepted()) { - session.sync(); + syncDebugSession(session); return true; } if (!session.isConfirmationStage()) { if (TradeConfig.requireSecondConfirmation()) { session.advanceToConfirmation(); - session.sync(); + syncDebugSession(session); return true; } } @@ -487,7 +641,7 @@ public class TradeManager { } session.setUnsafeState(state, true); - session.sync(); + syncDebugSession(session); return true; } @@ -501,7 +655,7 @@ public class TradeManager { } session.clearUnsafeStates(); - session.sync(); + syncDebugSession(session); return true; } @@ -548,7 +702,7 @@ public class TradeManager { return; } session.setOtherOffer(DebugTradeSession.parseOfferSpec(spec)); - session.sync(); + syncDebugSession(session); } case APPEND_RANDOM -> { if (session.isConfirmationStage()) { @@ -556,7 +710,7 @@ public class TradeManager { return; } if (session.appendOtherOffer(DebugTradeSession.randomSingleStackOffer())) { - session.sync(); + syncDebugSession(session); } } case REMOVE_OFFER -> { @@ -565,7 +719,7 @@ public class TradeManager { return; } if (session.removeOtherOffer(spec)) { - session.sync(); + syncDebugSession(session); } } case REMOVE_LAST -> { @@ -574,17 +728,17 @@ public class TradeManager { return; } if (session.removeLastOtherOffer()) { - session.sync(); + syncDebugSession(session); } } case SET_UNSAFE -> { DebugUnsafeState state = parseDebugUnsafeState(spec); session.setUnsafeState(state, true); - session.sync(); + syncDebugSession(session); } case CLEAR_UNSAFE -> { session.clearUnsafeStates(); - session.sync(); + syncDebugSession(session); } case ACCEPT -> acceptDebug(player); case CANCEL -> cancelDebug(player); @@ -770,6 +924,12 @@ public class TradeManager { continue; } + Component disabledFailure = tradeDisabledFailure(first, second); + if (disabledFailure != null) { + cancel(session, disabledFailure); + 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))); @@ -779,6 +939,18 @@ public class TradeManager { 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))); + continue; + } + + ItemBlockResult firstBlocked = blockedOffer(server, session.firstOfferSnapshot()); + if (firstBlocked != null) { + cancel(session, Component.literal("Trade cancelled because " + first.getGameProfile().getName() + " offered a blocked item: " + firstBlocked.reason() + ".")); + continue; + } + + ItemBlockResult secondBlocked = blockedOffer(server, session.secondOfferSnapshot()); + if (secondBlocked != null) { + cancel(session, Component.literal("Trade cancelled because " + second.getGameProfile().getName() + " offered a blocked item: " + secondBlocked.reason() + ".")); } } @@ -790,9 +962,27 @@ public class TradeManager { continue; } + Component disabledFailure = tradeDisabledFailure(player); + if (disabledFailure != null) { + closeDebug(session, disabledFailure); + 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))); + continue; + } + + ItemBlockResult selfBlocked = blockedOffer(server, session.selfOfferSnapshot()); + if (selfBlocked != null) { + closeDebug(session, Component.literal("Debug trade cancelled because your offer contains a blocked item: " + selfBlocked.reason() + ".")); + continue; + } + + ItemBlockResult otherBlocked = blockedOffer(server, session.otherOfferSnapshot()); + if (otherBlocked != null) { + closeDebug(session, Component.literal("Debug trade cancelled because the debug offer contains a blocked item: " + otherBlocked.reason() + ".")); } } } @@ -812,6 +1002,15 @@ public class TradeManager { continue; } + Component disabledFailure = target == null ? tradeDisabledFailure(requester) : tradeDisabledFailure(requester, target); + if (disabledFailure != null) { + clearPendingAcceptance( + requester, + disabledFailure, + target == null ? null : disabledFailure); + continue; + } + Component rangeFailure = target == null ? null : tradeRangeFailure(requester, target); if (rangeFailure != null) { clearPendingAcceptance( @@ -1064,6 +1263,11 @@ public class TradeManager { } private boolean startOrDelayDebugTrade(ServerPlayer player) { + Component disabledFailure = tradeDisabledFailure(player); + if (disabledFailure != null) { + player.sendSystemMessage(disabledFailure); + return false; + } long currentTick = player.server.getTickCount(); List unsafe = tradeSafetyFailures(player, currentTick); if (!unsafe.isEmpty()) { @@ -1139,6 +1343,165 @@ public class TradeManager { return trader + "=" + (entries.isEmpty() ? "(nothing)" : String.join("; ", entries)); } + private long remainingRequestCooldownTicks(ServerPlayer player) { + int cooldownSeconds = TradeConfig.requestCooldownSeconds(); + if (cooldownSeconds <= 0) { + return 0L; + } + long lastTick = lastRequestTickByPlayer.getOrDefault(player.getUUID(), Long.MIN_VALUE / 4); + long elapsed = player.server.getTickCount() - lastTick; + return Math.max(0L, cooldownSeconds * 20L - elapsed); + } + + private Component tradeDisabledFailure(ServerPlayer player) { + if (isAdminTradeDisabled(player)) { + return Component.literal("You cannot use the trade system on this server."); + } + return null; + } + + private Component tradeDisabledFailure(ServerPlayer first, ServerPlayer second) { + if (isAdminTradeDisabled(first)) { + return Component.literal(first.getGameProfile().getName() + " cannot use the trade system on this server."); + } + if (isAdminTradeDisabled(second)) { + return Component.literal(second.getGameProfile().getName() + " cannot use the trade system on this server."); + } + return null; + } + + private boolean isAdminTradeDisabled(ServerPlayer player) { + if (adminData(player.server).isPlayerDisabled(player.getUUID())) { + return true; + } + for (String configured : TradeConfig.adminDisabledPlayerUuids()) { + try { + if (UUID.fromString(configured).equals(player.getUUID())) { + return true; + } + } catch (IllegalArgumentException ignored) { + // Ignore invalid config entries instead of failing live servers. + } + } + return false; + } + + private ItemBlockResult blockedOffer(MinecraftServer server, List offer) { + for (ItemStack stack : offer) { + ItemBlockResult result = blockedItem(server, stack); + if (result != null) { + return result; + } + } + return null; + } + + private ItemBlockResult blockedItem(MinecraftServer server, ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return null; + } + + ResourceLocation itemId = BuiltInRegistries.ITEM.getKey(stack.getItem()); + if (itemId == null) { + return null; + } + + String itemKey = itemId.toString(); + if (effectiveBlacklistedItemIds(server).contains(itemKey)) { + return new ItemBlockResult(itemKey, "item " + itemKey + " is blacklisted"); + } + + String namespace = itemId.getNamespace().toLowerCase(java.util.Locale.ROOT); + if (effectiveBlacklistedMods(server).contains(namespace)) { + return new ItemBlockResult(itemKey, "mod " + namespace + " is blacklisted"); + } + + for (String tagId : effectiveBlacklistedItemTags(server)) { + ResourceLocation parsed = ResourceLocation.tryParse(tagId); + if (parsed == null) { + continue; + } + TagKey tagKey = TagKey.create(net.minecraft.core.registries.Registries.ITEM, parsed); + Holder holder = stack.getItemHolder(); + if (holder.is(tagKey)) { + return new ItemBlockResult(itemKey, "tag #" + tagId + " is blacklisted"); + } + } + + return null; + } + + private Set effectiveBlacklistedItemIds(MinecraftServer server) { + Set values = new HashSet<>(adminData(server).blacklistedItemIds()); + values.addAll(normalizedConfigEntries(TradeConfig.adminBlacklistedItemIds())); + return values; + } + + private Set effectiveBlacklistedItemTags(MinecraftServer server) { + Set values = new HashSet<>(adminData(server).blacklistedItemTags()); + values.addAll(normalizedConfigEntries(TradeConfig.adminBlacklistedItemTags())); + return values; + } + + private Set effectiveBlacklistedMods(MinecraftServer server) { + Set values = new HashSet<>(adminData(server).blacklistedMods()); + values.addAll(normalizedConfigEntries(TradeConfig.adminBlacklistedMods())); + return values; + } + + private Set normalizedConfigEntries(List values) { + Set result = new HashSet<>(); + for (String value : values) { + String normalized = value.trim().toLowerCase(java.util.Locale.ROOT); + if (!normalized.isEmpty()) { + result.add(normalized); + } + } + return result; + } + + private void syncSession(TradeSession session) { + sendTradeView(session.firstPlayer(), decorateView(session.firstPlayer().server, session.viewFor(session.firstPlayer()))); + sendTradeView(session.secondPlayer(), decorateView(session.secondPlayer().server, session.viewFor(session.secondPlayer()))); + } + + private void syncDebugSession(DebugTradeSession session) { + sendTradeView(session.player(), decorateView(session.player().server, session.view())); + } + + private void sendTradeView(ServerPlayer player, TradeView view) { + PacketDistributor.sendToPlayer(player, new TradeStatePayload(view)); + } + + private TradeView decorateView(MinecraftServer server, TradeView view) { + return new TradeView( + view.sessionId(), + view.selfName(), + view.otherName(), + view.debugMode(), + view.stage(), + view.selfAccepted(), + view.otherAccepted(), + view.itemsChanged(), + view.inventory(), + view.reservedCounts(), + blockedSlots(server, view.inventory()), + view.selfOffer(), + view.otherOffer(), + blockedSlots(server, view.selfOffer()), + blockedSlots(server, view.otherOffer()), + view.selfChangedSlots(), + view.otherChangedSlots()); + } + + private List blockedSlots(MinecraftServer server, List stacks) { + List result = new ArrayList<>(stacks.size()); + for (ItemStack stack : stacks) { + result.add(blockedItem(server, stack) != null); + } + return result; + } + private DebugUnsafeState parseDebugUnsafeState(String spec) { try { return DebugUnsafeState.valueOf(spec.trim().toUpperCase(java.util.Locale.ROOT)); @@ -1158,12 +1521,18 @@ public class TradeManager { return TradePreferencesData.get(server); } + private TradeAdminData adminData(MinecraftServer server) { + return TradeAdminData.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 record ItemBlockResult(String itemId, String reason) {} + private static final class PendingAcceptance { private final UUID requester; private final UUID target; diff --git a/src/main/java/com/trunksbomb/trade/trade/TradeSession.java b/src/main/java/com/trunksbomb/trade/trade/TradeSession.java index cc63662..2810925 100644 --- a/src/main/java/com/trunksbomb/trade/trade/TradeSession.java +++ b/src/main/java/com/trunksbomb/trade/trade/TradeSession.java @@ -74,6 +74,13 @@ public class TradeSession { PacketDistributor.sendToPlayer(second, new TradeClosePayload(id, reason)); } + public ItemStack offeredInventoryStack(ServerPlayer player, int inventorySlot) { + if (inventorySlot < 0 || inventorySlot >= INVENTORY_SLOT_COUNT) { + return ItemStack.EMPTY; + } + return inventoryFor(player).get(inventorySlot); + } + public boolean addFromInventory(ServerPlayer player, int inventorySlot, int amount) { if (stage != TradeStage.OFFERING || inventorySlot < 0 || inventorySlot >= INVENTORY_SLOT_COUNT) { return false; @@ -207,8 +214,11 @@ public class TradeSession { showModifiedWarnings && hasChangedSlots(), inventoryDisplayFor(player), emptyReservedSnapshot(), + blankInventoryBlockedSlots(), offerSnapshot(isFirst ? firstOffer : secondOffer), offerSnapshot(isFirst ? secondOffer : firstOffer), + blankChangedSlots(), + blankChangedSlots(), showModifiedWarnings ? changedSlotSnapshot(isFirst ? firstChangedSlots : secondChangedSlots) : blankChangedSlots(), showModifiedWarnings ? changedSlotSnapshot(isFirst ? secondChangedSlots : firstChangedSlots) : blankChangedSlots()); } @@ -400,6 +410,14 @@ public class TradeSession { return result; } + private static List blankInventoryBlockedSlots() { + List result = new ArrayList<>(INVENTORY_SLOT_COUNT); + for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) { + result.add(false); + } + return result; + } + private static List blankChangedSlots() { List result = new ArrayList<>(OFFER_SLOT_COUNT); for (int i = 0; i < OFFER_SLOT_COUNT; i++) { diff --git a/src/main/java/com/trunksbomb/trade/trade/TradeView.java b/src/main/java/com/trunksbomb/trade/trade/TradeView.java index 747f3c5..a217336 100644 --- a/src/main/java/com/trunksbomb/trade/trade/TradeView.java +++ b/src/main/java/com/trunksbomb/trade/trade/TradeView.java @@ -18,8 +18,11 @@ public record TradeView( boolean itemsChanged, List inventory, List reservedCounts, + List inventoryBlockedSlots, List selfOffer, List otherOffer, + List selfBlockedSlots, + List otherBlockedSlots, List selfChangedSlots, List otherChangedSlots) { @@ -39,8 +42,11 @@ public record TradeView( buf.writeBoolean(value.itemsChanged); writeStacks(buf, value.inventory, INVENTORY_SLOT_COUNT); writeInts(buf, value.reservedCounts, INVENTORY_SLOT_COUNT); + writeBooleans(buf, value.inventoryBlockedSlots, INVENTORY_SLOT_COUNT); writeStacks(buf, value.selfOffer, OFFER_SLOT_COUNT); writeStacks(buf, value.otherOffer, OFFER_SLOT_COUNT); + writeBooleans(buf, value.selfBlockedSlots, OFFER_SLOT_COUNT); + writeBooleans(buf, value.otherBlockedSlots, OFFER_SLOT_COUNT); writeBooleans(buf, value.selfChangedSlots, OFFER_SLOT_COUNT); writeBooleans(buf, value.otherChangedSlots, OFFER_SLOT_COUNT); } @@ -58,9 +64,12 @@ public record TradeView( buf.readBoolean(), readStacks(buf, INVENTORY_SLOT_COUNT), readInts(buf, INVENTORY_SLOT_COUNT), + readBooleans(buf, INVENTORY_SLOT_COUNT), readStacks(buf, OFFER_SLOT_COUNT), readStacks(buf, OFFER_SLOT_COUNT), readBooleans(buf, OFFER_SLOT_COUNT), + readBooleans(buf, OFFER_SLOT_COUNT), + readBooleans(buf, OFFER_SLOT_COUNT), readBooleans(buf, OFFER_SLOT_COUNT)); } };