Compare commits

...

3 Commits

Author SHA1 Message Date
trunksbomb
4cd7674996 more trade admin functionality, item/tag/mod blacklists, player blocks (can't use trade), trade timeouts
Some checks failed
Build / build (push) Has been cancelled
2026-03-25 16:48:55 -04:00
trunksbomb
9cb4979662 add shift click to move a whole stack to trade window 2026-03-25 12:31:39 -04:00
trunksbomb
89d6545533 add "trade modified" warning if items are removed/reduced, configurable
Add config for requiring second confirmation screen, enabled by default
More resilience to adding items to trade after moving to confirmation screen
2026-03-25 12:27:20 -04:00
9 changed files with 1183 additions and 52 deletions

View File

@@ -12,9 +12,12 @@ Players can request a trade, review both offers in a shared GUI, accept once to
- Shared trade screen with separate offer areas for both players - Shared trade screen with separate offer areas for both players
- Two-step confirmation flow - Two-step confirmation flow
- Quantity-based item selection with quick amounts and `Trade X` - Quantity-based item selection with quick amounts and `Trade X`
- Warning if items are removed/modified
- Inventory-safe finalization: nothing changes until safety and security checks pass and the trade succeeds - 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 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 - 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 - File-based trade audit log for server operators
## Player Usage ## Player Usage
@@ -49,6 +52,12 @@ They can also respond manually:
- Once both players accept, the screen enters the confirmation stage - Once both players accept, the screen enters the confirmation stage
- Click `Confirm` to finalize - Click `Confirm` to finalize
If a player reduces or removes items from an offer:
- any pending accepts / confirms are invalidated
- the changed offer slot gets a blinking red border and `!`
- the trade window shows a red `Trade Modified` warning
## Commands ## Commands
### Player commands ### Player commands
@@ -65,6 +74,23 @@ They can also respond manually:
/trade ignorelist /trade ignorelist
``` ```
### Admin commands
Server operators can manage persistent live blacklists with:
```mcfunction
/trade admin playerblock add <player|uuid>
/trade admin playerblock remove <player|uuid>
/trade admin playerblock list
/trade admin itemblock add item <namespace:item>
/trade admin itemblock remove item <namespace:item>
/trade admin itemblock add tag <namespace:tag>
/trade admin itemblock remove tag <namespace:tag>
/trade admin itemblock add mod <modid>
/trade admin itemblock remove mod <modid>
/trade admin itemblock list
```
### Command behavior ### Command behavior
- `/trade <player>` sends a trade request - `/trade <player>` sends a trade request
@@ -109,6 +135,7 @@ The trade verifies:
- both players are still within configured trade distance, if enabled - both players are still within configured trade distance, if enabled
- both live inventories still match the snapshots taken when the trade started - both live inventories still match the snapshots taken when the trade started
- both players can receive the incoming items - both players can receive the incoming items
- both players have re-accepted after any offer changes
If any check fails, the trade is cancelled and both players receive an explicit chat message explaining why. If any check fails, the trade is cancelled and both players receive an explicit chat message explaining why.
@@ -127,12 +154,26 @@ Server config values live under the `trade` section.
- default: `30` - default: `30`
- number of seconds before a pending trade request expires - 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 ### Debug
- `enableDebugFeatures` - `enableDebugFeatures`
- default: `false` - default: `false`
- enables debug commands and debug UI/testing tools - enables debug commands and debug UI/testing tools
### Trade flow
- `requireSecondConfirmation`
- default: `true`
- requires the second confirm step after both players accept the offer
- `showTradeModifiedWarnings`
- default: `true`
- shows the `Trade Modified` warning and changed-slot highlights when offers are reduced or removed
### Safety ### Safety
- `requireOnGround` - `requireOnGround`
@@ -168,6 +209,26 @@ Server config values live under the `trade` section.
- `requireSameDimension` - `requireSameDimension`
- default: `true` - 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 ## Audit Log
The mod writes a trade audit log to: The mod writes a trade audit log to:

View File

@@ -10,6 +10,7 @@ import com.trunksbomb.trade.trade.TradeView;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.EditBox;
@@ -37,8 +38,9 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
private static final int BANNER_X = 58; private static final int BANNER_X = 58;
private static final int BANNER_Y = 6; private static final int BANNER_Y = 6;
private static final int CENTER_COLUMN_X = 115; private static final int CENTER_COLUMN_X = 115;
private static final int STATUS_LABEL_Y = 54; private static final int MODIFIED_LABEL_Y = 17;
private static final int CONFIRM_LABEL_Y = 34; private static final int STATUS_LABEL_Y = 51;
private static final int CONFIRM_LABEL_Y = 26;
private static final int ACCEPT_BUTTON_Y = 74; private static final int ACCEPT_BUTTON_Y = 74;
private static final int CANCEL_BUTTON_Y = 98; private static final int CANCEL_BUTTON_Y = 98;
private static final int ACTION_BUTTON_WIDTH = 48; private static final int ACTION_BUTTON_WIDTH = 48;
@@ -196,6 +198,8 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
if (hoveredSlot instanceof GhostInventorySlot ghostInventorySlot) { if (hoveredSlot instanceof GhostInventorySlot ghostInventorySlot) {
if (button == 1) { if (button == 1) {
openContextMenu(mouseX, mouseY, TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), hoveredSlot.getItem(), "Trade"); openContextMenu(mouseX, mouseY, TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), hoveredSlot.getItem(), "Trade");
} else if (hasShiftDown()) {
sendAction(TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), hoveredSlot.getItem().getCount());
} else { } else {
sendAction(TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), 1); sendAction(TradeAction.ADD_ITEM, ghostInventorySlot.inventoryIndex(), 1);
} }
@@ -218,6 +222,9 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) {
guiGraphics.drawString(font, "Trading with " + menu.view().otherName(), titleLabelX, titleLabelY, 0x404040, false); guiGraphics.drawString(font, "Trading with " + menu.view().otherName(), titleLabelX, titleLabelY, 0x404040, false);
guiGraphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false); guiGraphics.drawString(font, playerInventoryTitle, inventoryLabelX, inventoryLabelY, 0x404040, false);
if (menu.view().itemsChanged()) {
drawScaledCenteredColumnText(guiGraphics, "Trade Modified", MODIFIED_LABEL_Y, 0xB02020, 0.7F);
}
if (menu.view().stage() == TradeStage.CONFIRMING) { if (menu.view().stage() == TradeStage.CONFIRMING) {
drawScaledCenteredColumnText(guiGraphics, "Are you", CONFIRM_LABEL_Y, 0xB02020, 0.7F); drawScaledCenteredColumnText(guiGraphics, "Are you", CONFIRM_LABEL_Y, 0xB02020, 0.7F);
drawScaledCenteredColumnText(guiGraphics, "sure you want", CONFIRM_LABEL_Y + 7, 0xB02020, 0.7F); drawScaledCenteredColumnText(guiGraphics, "sure you want", CONFIRM_LABEL_Y + 7, 0xB02020, 0.7F);
@@ -245,15 +252,92 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
acceptButton.setMessage(acceptButtonLabel()); acceptButton.setMessage(acceptButtonLabel());
} }
super.render(guiGraphics, mouseX, mouseY, partialTick); super.render(guiGraphics, mouseX, mouseY, partialTick);
renderProhibitedSlotWarnings(guiGraphics);
renderChangedSlotWarnings(guiGraphics);
renderContextMenu(guiGraphics, mouseX, mouseY); renderContextMenu(guiGraphics, mouseX, mouseY);
renderAmountPrompt(guiGraphics); renderAmountPrompt(guiGraphics);
renderTooltip(guiGraphics, mouseX, mouseY); renderTradeTooltip(guiGraphics, mouseX, mouseY);
} }
private Component acceptButtonLabel() { private Component acceptButtonLabel() {
return Component.literal(menu.view().stage() == TradeStage.CONFIRMING ? "Confirm" : "Accept"); return Component.literal(menu.view().stage() == TradeStage.CONFIRMING ? "Confirm" : "Accept");
} }
private void renderChangedSlotWarnings(GuiGraphics guiGraphics) {
if (!menu.view().itemsChanged()) {
return;
}
boolean blinkOn = ((System.currentTimeMillis() / 300L) & 1L) == 0L;
int color = blinkOn ? 0xFFFF4040 : 0xFF7A1010;
for (Slot slot : menu.slots) {
if (slot instanceof SelfOfferSlot selfOfferSlot) {
if (menu.view().selfChangedSlots().get(selfOfferSlot.offerIndex())) {
renderChangedSlotWarning(guiGraphics, slot, color);
}
} else if (slot instanceof OtherOfferSlot otherOfferSlot) {
if (menu.view().otherChangedSlots().get(otherOfferSlot.offerIndex())) {
renderChangedSlotWarning(guiGraphics, slot, color);
}
}
}
}
private void renderChangedSlotWarning(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 + 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<Component> 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) { private void sendAction(TradeAction action, int slot, int amount) {
if (minecraft == null || minecraft.getConnection() == null) { if (minecraft == null || minecraft.getConnection() == null) {
return; return;
@@ -575,6 +659,10 @@ public class TradeScreen extends AbstractContainerScreen<TradeScreen.TradeMenu>
super(container, slot, x, y); super(container, slot, x, y);
} }
public int offerIndex() {
return getSlotIndex();
}
@Override @Override
public boolean mayPickup(Player player) { public boolean mayPickup(Player player) {
return false; return false;

View File

@@ -8,10 +8,12 @@ import com.trunksbomb.trade.mod.TradeConfig;
import com.trunksbomb.trade.trade.DebugUnsafeState; import com.trunksbomb.trade.trade.DebugUnsafeState;
import com.trunksbomb.trade.trade.DebugTradeSession; import com.trunksbomb.trade.trade.DebugTradeSession;
import com.trunksbomb.trade.trade.TradeManager; import com.trunksbomb.trade.trade.TradeManager;
import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
public final class TradeCommand { public final class TradeCommand {
@@ -53,6 +55,7 @@ public final class TradeCommand {
.then(Commands.literal("close").executes(context -> closeDebug(context.getSource())))); .then(Commands.literal("close").executes(context -> closeDebug(context.getSource()))));
dispatcher.register(trade dispatcher.register(trade
.then(adminCommands())
.then(Commands.literal("yes").executes(context -> respondTrade(context.getSource(), true))) .then(Commands.literal("yes").executes(context -> respondTrade(context.getSource(), true)))
.then(Commands.literal("no").executes(context -> respondTrade(context.getSource(), false))) .then(Commands.literal("no").executes(context -> respondTrade(context.getSource(), false)))
.then(Commands.literal("toggle").executes(context -> toggleTrade(context.getSource()))) .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"))))); .executes(context -> requestTrade(context.getSource(), StringArgumentType.getString(context, "player")))));
} }
private static com.mojang.brigadier.builder.LiteralArgumentBuilder<CommandSourceStack> 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<com.mojang.brigadier.suggestion.Suggestions> suggestPlayers(CommandSourceStack source, SuggestionsBuilder builder) { private static CompletableFuture<com.mojang.brigadier.suggestion.Suggestions> suggestPlayers(CommandSourceStack source, SuggestionsBuilder builder) {
for (ServerPlayer player : source.getServer().getPlayerList().getPlayers()) { for (ServerPlayer player : source.getServer().getPlayerList().getPlayers()) {
if (!player.getGameProfile().getName().equalsIgnoreCase(source.getTextName())) { if (!player.getGameProfile().getName().equalsIgnoreCase(source.getTextName())) {
@@ -214,6 +254,146 @@ public final class TradeCommand {
return 1; 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<String> 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<String> itemIds = manager.adminBlacklistedItemIds(source.getServer());
java.util.List<String> tags = manager.adminBlacklistedItemTags(source.getServer());
java.util.List<String> 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<String> 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) { private static int initDebug(CommandSourceStack source, int delaySeconds) {
if (!(source.getEntity() instanceof ServerPlayer player)) { if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Only players can start debug trades.")); source.sendFailure(Component.literal("Only players can start debug trades."));
@@ -260,7 +440,7 @@ public final class TradeCommand {
try { try {
if (!TradeManager.get(source.getServer()).setDebugOffer(player, DebugTradeSession.parseOfferSpec(spec))) { if (!TradeManager.get(source.getServer()).setDebugOffer(player, DebugTradeSession.parseOfferSpec(spec))) {
source.sendFailure(Component.literal("Start a debug trade first with /trade debug init.")); source.sendFailure(Component.literal("Start a debug trade first with /trade debug init, and only change offers before confirmation."));
return 0; return 0;
} }
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
@@ -285,6 +465,10 @@ public final class TradeCommand {
try { try {
int result = TradeManager.get(source.getServer()).removeDebugOffer(player, spec); int result = TradeManager.get(source.getServer()).removeDebugOffer(player, spec);
if (result < 0) { if (result < 0) {
if (result == -2) {
source.sendFailure(Component.literal("Trade offers cannot be changed during confirmation."));
return 0;
}
source.sendFailure(Component.literal("Start a debug trade first with /trade debug init.")); source.sendFailure(Component.literal("Start a debug trade first with /trade debug init."));
return 0; return 0;
} }
@@ -353,4 +537,28 @@ public final class TradeCommand {
source.sendSuccess(() -> Component.literal(successMessage), false); source.sendSuccess(() -> Component.literal(successMessage), false);
return 1; 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) {}
} }

View File

@@ -6,7 +6,10 @@ public final class TradeConfig {
public static final ModConfigSpec SPEC; public static final ModConfigSpec SPEC;
private static final ModConfigSpec.IntValue TRADE_COMMAND_PROXIMITY; private static final ModConfigSpec.IntValue TRADE_COMMAND_PROXIMITY;
private static final ModConfigSpec.IntValue REQUEST_TIMEOUT_SECONDS; 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 ENABLE_DEBUG_FEATURES;
private static final ModConfigSpec.BooleanValue REQUIRE_SECOND_CONFIRMATION;
private static final ModConfigSpec.BooleanValue SHOW_TRADE_MODIFIED_WARNINGS;
private static final ModConfigSpec.BooleanValue REQUIRE_ON_GROUND; private static final ModConfigSpec.BooleanValue REQUIRE_ON_GROUND;
private static final ModConfigSpec.BooleanValue REQUIRE_STATIONARY; private static final ModConfigSpec.BooleanValue REQUIRE_STATIONARY;
private static final ModConfigSpec.DoubleValue STATIONARY_SPEED_THRESHOLD; private static final ModConfigSpec.DoubleValue STATIONARY_SPEED_THRESHOLD;
@@ -18,6 +21,10 @@ public final class TradeConfig {
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_FALL_FLYING; private static final ModConfigSpec.BooleanValue REQUIRE_NOT_FALL_FLYING;
private static final ModConfigSpec.BooleanValue REQUIRE_NOT_RIDING; private static final ModConfigSpec.BooleanValue REQUIRE_NOT_RIDING;
private static final ModConfigSpec.BooleanValue REQUIRE_SAME_DIMENSION; private static final ModConfigSpec.BooleanValue REQUIRE_SAME_DIMENSION;
private static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> ADMIN_DISABLED_PLAYER_UUIDS;
private static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> ADMIN_BLACKLISTED_ITEM_IDS;
private static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> ADMIN_BLACKLISTED_ITEM_TAGS;
private static final ModConfigSpec.ConfigValue<java.util.List<? extends String>> ADMIN_BLACKLISTED_MODS;
static { static {
ModConfigSpec.Builder builder = new ModConfigSpec.Builder(); ModConfigSpec.Builder builder = new ModConfigSpec.Builder();
@@ -28,8 +35,15 @@ public final class TradeConfig {
REQUEST_TIMEOUT_SECONDS = builder REQUEST_TIMEOUT_SECONDS = builder
.comment("Seconds before a trade request expires.") .comment("Seconds before a trade request expires.")
.defineInRange("requestTimeoutSeconds", 30, 1, 3600); .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.") ENABLE_DEBUG_FEATURES = builder.comment("Enable debug trade commands and debug UI/testing tools.")
.define("enableDebugFeatures", false); .define("enableDebugFeatures", false);
REQUIRE_SECOND_CONFIRMATION = builder.comment("Require a second confirmation step after both players accept the initial offer.")
.define("requireSecondConfirmation", true);
SHOW_TRADE_MODIFIED_WARNINGS = builder.comment("Show Trade Modified warnings and changed-slot highlights when offers are reduced or removed.")
.define("showTradeModifiedWarnings", true);
REQUIRE_ON_GROUND = builder.comment("Require players to be on solid ground before requesting or accepting a trade.") REQUIRE_ON_GROUND = builder.comment("Require players to be on solid ground before requesting or accepting a trade.")
.define("requireOnGround", true); .define("requireOnGround", true);
REQUIRE_STATIONARY = builder.comment("Require players to be stationary before requesting or accepting a trade.") REQUIRE_STATIONARY = builder.comment("Require players to be stationary before requesting or accepting a trade.")
@@ -52,6 +66,33 @@ public final class TradeConfig {
.define("requireNotRiding", true); .define("requireNotRiding", true);
REQUIRE_SAME_DIMENSION = builder.comment("Require both players to be in the same dimension.") REQUIRE_SAME_DIMENSION = builder.comment("Require both players to be in the same dimension.")
.define("requireSameDimension", true); .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(); builder.pop();
SPEC = builder.build(); SPEC = builder.build();
} }
@@ -66,6 +107,10 @@ public final class TradeConfig {
return REQUEST_TIMEOUT_SECONDS.get(); return REQUEST_TIMEOUT_SECONDS.get();
} }
public static int requestCooldownSeconds() {
return REQUEST_COOLDOWN_SECONDS.get();
}
public static boolean enableDebugFeatures() { public static boolean enableDebugFeatures() {
return ENABLE_DEBUG_FEATURES.get(); return ENABLE_DEBUG_FEATURES.get();
} }
@@ -78,6 +123,14 @@ public final class TradeConfig {
} }
} }
public static boolean requireSecondConfirmation() {
return REQUIRE_SECOND_CONFIRMATION.get();
}
public static boolean showTradeModifiedWarnings() {
return SHOW_TRADE_MODIFIED_WARNINGS.get();
}
public static boolean requireOnGround() { public static boolean requireOnGround() {
return REQUIRE_ON_GROUND.get(); return REQUIRE_ON_GROUND.get();
} }
@@ -121,4 +174,24 @@ public final class TradeConfig {
public static boolean requireSameDimension() { public static boolean requireSameDimension() {
return REQUIRE_SAME_DIMENSION.get(); return REQUIRE_SAME_DIMENSION.get();
} }
public static java.util.List<String> adminDisabledPlayerUuids() {
return copyStringList(ADMIN_DISABLED_PLAYER_UUIDS.get());
}
public static java.util.List<String> adminBlacklistedItemIds() {
return copyStringList(ADMIN_BLACKLISTED_ITEM_IDS.get());
}
public static java.util.List<String> adminBlacklistedItemTags() {
return copyStringList(ADMIN_BLACKLISTED_ITEM_TAGS.get());
}
public static java.util.List<String> adminBlacklistedMods() {
return copyStringList(ADMIN_BLACKLISTED_MODS.get());
}
private static java.util.List<String> copyStringList(java.util.List<? extends String> values) {
return new java.util.ArrayList<>(values);
}
} }

View File

@@ -2,6 +2,7 @@ package com.trunksbomb.trade.trade;
import com.trunksbomb.trade.network.TradeClosePayload; import com.trunksbomb.trade.network.TradeClosePayload;
import com.trunksbomb.trade.network.TradeStatePayload; import com.trunksbomb.trade.network.TradeStatePayload;
import com.trunksbomb.trade.mod.TradeConfig;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
@@ -26,6 +27,8 @@ public class DebugTradeSession {
private final List<ItemStack> inventorySnapshot; private final List<ItemStack> inventorySnapshot;
private final List<TradeEntry> selfOffer = blankOffer(); private final List<TradeEntry> selfOffer = blankOffer();
private final List<ItemStack> otherOffer = blankStacks(); private final List<ItemStack> otherOffer = blankStacks();
private final List<Boolean> selfChangedSlots = blankChangedSlots();
private final List<Boolean> otherChangedSlots = blankChangedSlots();
private final EnumSet<DebugUnsafeState> unsafeStates = EnumSet.noneOf(DebugUnsafeState.class); private final EnumSet<DebugUnsafeState> unsafeStates = EnumSet.noneOf(DebugUnsafeState.class);
private boolean selfAccepted; private boolean selfAccepted;
private boolean otherAccepted; private boolean otherAccepted;
@@ -90,7 +93,7 @@ public class DebugTradeSession {
ItemStack merged = entry.stack().copy(); ItemStack merged = entry.stack().copy();
merged.grow(moveAmount); merged.grow(moveAmount);
selfOffer.set(i, new TradeEntry(inventorySlot, merged)); selfOffer.set(i, new TradeEntry(inventorySlot, merged));
clearAccepts(); clearAccepts(false, false, -1);
return true; return true;
} }
} }
@@ -101,7 +104,7 @@ public class DebugTradeSession {
} }
selfOffer.set(freeSlot, new TradeEntry(inventorySlot, sourceStack.copyWithCount(moveAmount))); selfOffer.set(freeSlot, new TradeEntry(inventorySlot, sourceStack.copyWithCount(moveAmount)));
clearAccepts(); clearAccepts(false, false, -1);
return true; return true;
} }
@@ -124,7 +127,7 @@ public class DebugTradeSession {
selfOffer.set(offerSlot, null); selfOffer.set(offerSlot, null);
} }
clearAccepts(); clearAccepts(true, false, offerSlot);
return true; return true;
} }
@@ -140,7 +143,7 @@ public class DebugTradeSession {
for (int i = 0; i < OFFER_SLOT_COUNT; i++) { for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
otherOffer.set(i, i < offer.size() ? offer.get(i).copy() : ItemStack.EMPTY); otherOffer.set(i, i < offer.size() ? offer.get(i).copy() : ItemStack.EMPTY);
} }
clearAccepts(); clearAccepts(false, false, -1);
} }
public boolean appendOtherOffer(List<ItemStack> offer) { public boolean appendOtherOffer(List<ItemStack> offer) {
@@ -156,18 +159,25 @@ public class DebugTradeSession {
} }
if (changed) { if (changed) {
clearAccepts(); clearAccepts(false, false, -1);
} }
return changed; return changed;
} }
public boolean removeOtherOffer(String spec) { public boolean removeOtherOffer(String spec) {
if ("all".equalsIgnoreCase(spec)) { if ("all".equalsIgnoreCase(spec)) {
boolean changed = false;
for (int i = 0; i < OFFER_SLOT_COUNT; i++) { for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
if (!otherOffer.get(i).isEmpty()) {
otherChangedSlots.set(i, true);
changed = true;
}
otherOffer.set(i, ItemStack.EMPTY); otherOffer.set(i, ItemStack.EMPTY);
} }
clearAccepts(); if (changed) {
return true; clearAccepts(false, false, -1);
}
return changed;
} }
int split = spec.indexOf(':'); int split = spec.indexOf(':');
@@ -194,7 +204,7 @@ public class DebugTradeSession {
otherOffer.set(slot, updated); otherOffer.set(slot, updated);
} }
clearAccepts(); clearAccepts(false, true, slot);
return true; return true;
} }
@@ -204,7 +214,7 @@ public class DebugTradeSession {
continue; continue;
} }
otherOffer.set(i, ItemStack.EMPTY); otherOffer.set(i, ItemStack.EMPTY);
clearAccepts(); clearAccepts(false, true, i);
return true; return true;
} }
return false; return false;
@@ -263,6 +273,7 @@ public class DebugTradeSession {
} }
public TradeView view() { public TradeView view() {
boolean showModifiedWarnings = TradeConfig.showTradeModifiedWarnings();
return new TradeView( return new TradeView(
id, id,
player.getGameProfile().getName(), player.getGameProfile().getName(),
@@ -271,10 +282,16 @@ public class DebugTradeSession {
stage, stage,
selfAccepted, selfAccepted,
otherAccepted, otherAccepted,
showModifiedWarnings && hasChangedSlots(),
inventoryDisplay(), inventoryDisplay(),
emptyReservedSnapshot(), emptyReservedSnapshot(),
blankInventoryBlockedSlots(),
selfOfferSnapshot(), selfOfferSnapshot(),
otherOfferSnapshot()); otherOfferSnapshot(),
blankChangedSlots(),
blankChangedSlots(),
showModifiedWarnings ? changedSlotSnapshot(selfChangedSlots) : blankChangedSlots(),
showModifiedWarnings ? changedSlotSnapshot(otherChangedSlots) : blankChangedSlots());
} }
public static List<ItemStack> parseOfferSpec(String spec) { public static List<ItemStack> parseOfferSpec(String spec) {
@@ -418,9 +435,26 @@ public class DebugTradeSession {
return count; return count;
} }
private void clearAccepts() { private void clearAccepts(boolean markSelfChanged, boolean markOtherChanged, int changedSlot) {
selfAccepted = false; selfAccepted = false;
otherAccepted = false; otherAccepted = false;
if (changedSlot >= 0) {
if (markSelfChanged) {
selfChangedSlots.set(changedSlot, true);
}
if (markOtherChanged) {
otherChangedSlots.set(changedSlot, true);
}
}
}
private boolean hasChangedSlots() {
for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
if (selfChangedSlots.get(i) || otherChangedSlots.get(i)) {
return true;
}
}
return false;
} }
private List<ItemStack> inventorySnapshotCopy() { private List<ItemStack> inventorySnapshotCopy() {
@@ -459,6 +493,26 @@ public class DebugTradeSession {
return result; return result;
} }
private static List<Boolean> blankInventoryBlockedSlots() {
List<Boolean> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
result.add(false);
}
return result;
}
private static List<Boolean> blankChangedSlots() {
List<Boolean> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
result.add(false);
}
return result;
}
private static List<Boolean> changedSlotSnapshot(List<Boolean> changedSlots) {
return new ArrayList<>(changedSlots);
}
private static List<ItemStack> inventorySnapshot(ServerPlayer player) { private static List<ItemStack> inventorySnapshot(ServerPlayer player) {
List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT); List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
Inventory inventory = player.getInventory(); Inventory inventory = player.getInventory();

View File

@@ -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<UUID> disabledPlayers = new HashSet<>();
private final Set<String> blacklistedItemIds = new HashSet<>();
private final Set<String> blacklistedItemTags = new HashSet<>();
private final Set<String> blacklistedMods = new HashSet<>();
public static TradeAdminData get(MinecraftServer server) {
return server.overworld().getDataStorage().computeIfAbsent(factory(), DATA_NAME);
}
private static Factory<TradeAdminData> 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<UUID> 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<String> blacklistedItemIds() {
return sortedCopy(blacklistedItemIds);
}
public boolean addBlacklistedItemTag(String tagId) {
return addNormalized(blacklistedItemTags, tagId);
}
public boolean removeBlacklistedItemTag(String tagId) {
return removeNormalized(blacklistedItemTags, tagId);
}
public List<String> blacklistedItemTags() {
return sortedCopy(blacklistedItemTags);
}
public boolean addBlacklistedMod(String modId) {
return addNormalized(blacklistedMods, modId);
}
public boolean removeBlacklistedMod(String modId) {
return removeNormalized(blacklistedMods, modId);
}
public List<String> 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<String> 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<String> 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<UUID> 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<String> 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<UUID> 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<String> values) {
ListTag list = new ListTag();
for (String value : values.stream().sorted().toList()) {
list.add(StringTag.valueOf(value));
}
return list;
}
private static List<String> sortedCopy(Set<String> values) {
return values.stream().sorted().toList();
}
}

View File

@@ -2,20 +2,28 @@ package com.trunksbomb.trade.trade;
import com.trunksbomb.trade.mod.TradeAuditLog; import com.trunksbomb.trade.mod.TradeAuditLog;
import com.trunksbomb.trade.mod.TradeConfig; import com.trunksbomb.trade.mod.TradeConfig;
import com.trunksbomb.trade.network.TradeStatePayload;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.WeakHashMap; 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.Component;
import net.minecraft.network.chat.ClickEvent; import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.Style; 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.MinecraftServer;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import net.neoforged.neoforge.network.PacketDistributor;
public class TradeManager { public class TradeManager {
private static final long ACCEPT_GRACE_TICKS = 5L * 20L; private static final long ACCEPT_GRACE_TICKS = 5L * 20L;
@@ -33,6 +41,7 @@ public class TradeManager {
private final Map<UUID, PendingAcceptance> pendingAcceptancesByRequester = new HashMap<>(); private final Map<UUID, PendingAcceptance> pendingAcceptancesByRequester = new HashMap<>();
private final Map<UUID, UUID> pendingAcceptanceByPlayer = new HashMap<>(); private final Map<UUID, UUID> pendingAcceptanceByPlayer = new HashMap<>();
private final Map<UUID, Long> lastDamageTickByPlayer = new HashMap<>(); private final Map<UUID, Long> lastDamageTickByPlayer = new HashMap<>();
private final Map<UUID, Long> lastRequestTickByPlayer = new HashMap<>();
public static TradeManager get(MinecraftServer server) { public static TradeManager get(MinecraftServer server) {
return INSTANCES.computeIfAbsent(server, ignored -> new TradeManager()); return INSTANCES.computeIfAbsent(server, ignored -> new TradeManager());
@@ -43,11 +52,18 @@ public class TradeManager {
return false; return false;
} }
Component disabledFailure = tradeDisabledFailure(first, second);
if (disabledFailure != null) {
first.sendSystemMessage(disabledFailure);
second.sendSystemMessage(disabledFailure);
return false;
}
TradeSession session = new TradeSession(first, second); TradeSession session = new TradeSession(first, second);
sessionsById.put(session.id(), session); sessionsById.put(session.id(), session);
sessionByPlayer.put(first.getUUID(), session.id()); sessionByPlayer.put(first.getUUID(), session.id());
sessionByPlayer.put(second.getUUID(), session.id()); sessionByPlayer.put(second.getUUID(), session.id());
session.syncToPlayers(); syncSession(session);
TradeAuditLog.log(first.server, "OPEN " + playerAudit(first) + " <-> " + playerAudit(second)); TradeAuditLog.log(first.server, "OPEN " + playerAudit(first) + " <-> " + playerAudit(second));
first.sendSystemMessage(Component.literal("Trade opened with " + second.getGameProfile().getName() + ".")); first.sendSystemMessage(Component.literal("Trade opened with " + second.getGameProfile().getName() + "."));
second.sendSystemMessage(Component.literal("Trade opened with " + first.getGameProfile().getName() + ".")); second.sendSystemMessage(Component.literal("Trade opened with " + first.getGameProfile().getName() + "."));
@@ -65,6 +81,12 @@ public class TradeManager {
return false; return false;
} }
Component disabledFailure = tradeDisabledFailure(requester, target);
if (disabledFailure != null) {
requester.sendSystemMessage(disabledFailure);
return false;
}
if (!preferences(requester.server).isTradeEnabled(target.getUUID())) { if (!preferences(requester.server).isTradeEnabled(target.getUUID())) {
requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is not accepting trade requests.")); requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is not accepting trade requests."));
return false; return false;
@@ -99,7 +121,14 @@ public class TradeManager {
return false; 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())); 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)); TradeAuditLog.log(requester.server, "REQUEST " + playerAudit(requester) + " -> " + playerAudit(target));
requester.sendSystemMessage(Component.literal("Trade request sent to " + target.getGameProfile().getName() + ".")); requester.sendSystemMessage(Component.literal("Trade request sent to " + target.getGameProfile().getName() + "."));
target.sendSystemMessage(Component.literal(requester.getGameProfile().getName() + " would like to trade with you: ") target.sendSystemMessage(Component.literal(requester.getGameProfile().getName() + " would like to trade with you: ")
@@ -125,6 +154,13 @@ public class TradeManager {
return false; 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())) { if (!preferences(target.server).isTradeEnabled(target.getUUID())) {
target.sendSystemMessage(Component.literal("You are not accepting trade requests right now.")); target.sendSystemMessage(Component.literal("You are not accepting trade requests right now."));
requester.sendSystemMessage(Component.literal(target.getGameProfile().getName() + " is not accepting trade requests.")); 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) { 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) { boolean changed = switch (action) {
case ADD_ITEM -> session.addFromInventory(player, slot, amount); case ADD_ITEM -> session.addFromInventory(player, slot, amount);
case REMOVE_ITEM -> session.removeFromOffer(player, slot, amount); case REMOVE_ITEM -> session.removeFromOffer(player, slot, amount);
@@ -233,14 +278,28 @@ public class TradeManager {
return; return;
} }
if (!session.isConfirmationStage()) { if (!session.bothAccepted()) {
session.advanceToConfirmation(); syncSession(session);
session.syncToPlayers();
return; return;
} }
if (!session.bothAccepted()) { if (!session.isConfirmationStage()) {
session.syncToPlayers(); if (TradeConfig.requireSecondConfirmation()) {
session.advanceToConfirmation();
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; return;
} }
@@ -253,6 +312,13 @@ public class TradeManager {
} }
private void handleDebugAction(DebugTradeSession session, TradeAction action, int slot, int amount) { 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) { if (action == TradeAction.ACCEPT) {
List<Component> unsafe = tradeSafetyFailures(session, session.player(), session.player().server.getTickCount()); List<Component> unsafe = tradeSafetyFailures(session, session.player(), session.player().server.getTickCount());
if (!unsafe.isEmpty()) { if (!unsafe.isEmpty()) {
@@ -278,14 +344,28 @@ public class TradeManager {
return; return;
} }
if (!session.isConfirmationStage()) { if (!session.bothAccepted()) {
session.advanceToConfirmation(); syncDebugSession(session);
session.sync();
return; return;
} }
if (!session.bothAccepted()) { if (!session.isConfirmationStage()) {
session.sync(); if (TradeConfig.requireSecondConfirmation()) {
session.advanceToConfirmation();
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; return;
} }
@@ -300,6 +380,7 @@ public class TradeManager {
clearPendingRequests(player); clearPendingRequests(player);
clearDebugRequests(player); clearDebugRequests(player);
clearPendingAcceptance(player, null, null); clearPendingAcceptance(player, null, null);
lastRequestTickByPlayer.remove(player.getUUID());
TradeSession session = getSession(player); TradeSession session = getSession(player);
if (session != null) { if (session != null) {
@@ -363,10 +444,76 @@ public class TradeManager {
return result; 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<String> adminDisabledPlayerNames(MinecraftServer server) {
List<String> 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<String> 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<String> 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<String> adminBlacklistedMods(MinecraftServer server) {
return adminData(server).blacklistedMods();
}
public boolean initDebugTrade(ServerPlayer player) { public boolean initDebugTrade(ServerPlayer player) {
if (!TradeConfig.enableDebugFeatures()) { if (!TradeConfig.enableDebugFeatures()) {
return false; return false;
} }
Component disabledFailure = tradeDisabledFailure(player);
if (disabledFailure != null) {
player.sendSystemMessage(disabledFailure);
return false;
}
if (isBusy(player)) { if (isBusy(player)) {
return false; return false;
} }
@@ -374,7 +521,7 @@ public class TradeManager {
DebugTradeSession session = new DebugTradeSession(player); DebugTradeSession session = new DebugTradeSession(player);
debugSessionsById.put(session.id(), session); debugSessionsById.put(session.id(), session);
debugSessionByPlayer.put(player.getUUID(), session.id()); debugSessionByPlayer.put(player.getUUID(), session.id());
session.sync(); syncDebugSession(session);
player.sendSystemMessage(Component.literal("Debug trade opened.")); player.sendSystemMessage(Component.literal("Debug trade opened."));
return true; return true;
} }
@@ -390,6 +537,11 @@ public class TradeManager {
if (!TradeConfig.enableDebugFeatures()) { if (!TradeConfig.enableDebugFeatures()) {
return false; return false;
} }
Component disabledFailure = tradeDisabledFailure(player);
if (disabledFailure != null) {
player.sendSystemMessage(disabledFailure);
return false;
}
if (isBusy(player) || hasPendingDebugRequest(player)) { if (isBusy(player) || hasPendingDebugRequest(player)) {
return false; return false;
} }
@@ -404,12 +556,18 @@ public class TradeManager {
return false; return false;
} }
DebugTradeSession session = getDebugSession(player); DebugTradeSession session = getDebugSession(player);
if (session == null) { if (session == null || session.isConfirmationStage()) {
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; return false;
} }
session.setOtherOffer(offer); session.setOtherOffer(offer);
session.sync(); syncDebugSession(session);
return true; return true;
} }
@@ -421,10 +579,13 @@ public class TradeManager {
if (session == null) { if (session == null) {
return -1; return -1;
} }
if (session.isConfirmationStage()) {
return -2;
}
boolean changed = session.removeOtherOffer(spec); boolean changed = session.removeOtherOffer(spec);
if (changed) { if (changed) {
session.sync(); syncDebugSession(session);
return 1; return 1;
} }
@@ -447,20 +608,25 @@ public class TradeManager {
} }
session.acceptOther(); session.acceptOther();
if (!session.isConfirmationStage()) { if (!session.bothAccepted()) {
session.advanceToConfirmation(); syncDebugSession(session);
session.sync();
return true; return true;
} }
if (!session.isConfirmationStage()) {
if (TradeConfig.requireSecondConfirmation()) {
session.advanceToConfirmation();
syncDebugSession(session);
return true;
}
}
if (session.bothAccepted()) { if (session.bothAccepted()) {
if (session.completeTrade()) { if (session.completeTrade()) {
finishDebug(session, Component.literal("Debug trade completed.")); finishDebug(session, Component.literal("Debug trade completed."));
} else { } else {
closeDebug(session, Component.literal("Debug trade cancelled because the items would not fit.")); closeDebug(session, Component.literal("Debug trade cancelled because the items would not fit."));
} }
} else {
session.sync();
} }
return true; return true;
} }
@@ -475,7 +641,7 @@ public class TradeManager {
} }
session.setUnsafeState(state, true); session.setUnsafeState(state, true);
session.sync(); syncDebugSession(session);
return true; return true;
} }
@@ -489,7 +655,7 @@ public class TradeManager {
} }
session.clearUnsafeStates(); session.clearUnsafeStates();
session.sync(); syncDebugSession(session);
return true; return true;
} }
@@ -531,32 +697,48 @@ public class TradeManager {
try { try {
switch (action) { switch (action) {
case SET_OFFER -> { case SET_OFFER -> {
if (session.isConfirmationStage()) {
player.sendSystemMessage(Component.literal("Trade offers cannot be changed during confirmation."));
return;
}
session.setOtherOffer(DebugTradeSession.parseOfferSpec(spec)); session.setOtherOffer(DebugTradeSession.parseOfferSpec(spec));
session.sync(); syncDebugSession(session);
} }
case APPEND_RANDOM -> { case APPEND_RANDOM -> {
if (session.isConfirmationStage()) {
player.sendSystemMessage(Component.literal("Trade offers cannot be changed during confirmation."));
return;
}
if (session.appendOtherOffer(DebugTradeSession.randomSingleStackOffer())) { if (session.appendOtherOffer(DebugTradeSession.randomSingleStackOffer())) {
session.sync(); syncDebugSession(session);
} }
} }
case REMOVE_OFFER -> { case REMOVE_OFFER -> {
if (session.isConfirmationStage()) {
player.sendSystemMessage(Component.literal("Trade offers cannot be changed during confirmation."));
return;
}
if (session.removeOtherOffer(spec)) { if (session.removeOtherOffer(spec)) {
session.sync(); syncDebugSession(session);
} }
} }
case REMOVE_LAST -> { case REMOVE_LAST -> {
if (session.isConfirmationStage()) {
player.sendSystemMessage(Component.literal("Trade offers cannot be changed during confirmation."));
return;
}
if (session.removeLastOtherOffer()) { if (session.removeLastOtherOffer()) {
session.sync(); syncDebugSession(session);
} }
} }
case SET_UNSAFE -> { case SET_UNSAFE -> {
DebugUnsafeState state = parseDebugUnsafeState(spec); DebugUnsafeState state = parseDebugUnsafeState(spec);
session.setUnsafeState(state, true); session.setUnsafeState(state, true);
session.sync(); syncDebugSession(session);
} }
case CLEAR_UNSAFE -> { case CLEAR_UNSAFE -> {
session.clearUnsafeStates(); session.clearUnsafeStates();
session.sync(); syncDebugSession(session);
} }
case ACCEPT -> acceptDebug(player); case ACCEPT -> acceptDebug(player);
case CANCEL -> cancelDebug(player); case CANCEL -> cancelDebug(player);
@@ -742,6 +924,12 @@ public class TradeManager {
continue; continue;
} }
Component disabledFailure = tradeDisabledFailure(first, second);
if (disabledFailure != null) {
cancel(session, disabledFailure);
continue;
}
List<Component> firstFailures = tradeSafetyFailures(first, server.getTickCount()); List<Component> firstFailures = tradeSafetyFailures(first, server.getTickCount());
if (!firstFailures.isEmpty()) { 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))); cancel(session, Component.literal("Trade cancelled because " + first.getGameProfile().getName() + " is no longer in a safe state to trade: ").append(joinReasons(firstFailures)));
@@ -751,6 +939,18 @@ public class TradeManager {
List<Component> secondFailures = tradeSafetyFailures(second, server.getTickCount()); List<Component> secondFailures = tradeSafetyFailures(second, server.getTickCount());
if (!secondFailures.isEmpty()) { 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))); 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() + "."));
} }
} }
@@ -762,9 +962,27 @@ public class TradeManager {
continue; continue;
} }
Component disabledFailure = tradeDisabledFailure(player);
if (disabledFailure != null) {
closeDebug(session, disabledFailure);
continue;
}
List<Component> failures = tradeSafetyFailures(session, player, server.getTickCount()); List<Component> failures = tradeSafetyFailures(session, player, server.getTickCount());
if (!failures.isEmpty()) { if (!failures.isEmpty()) {
closeDebug(session, Component.literal("Debug trade cancelled because you are no longer in a safe state to trade: ").append(joinReasons(failures))); 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() + "."));
} }
} }
} }
@@ -784,6 +1002,15 @@ public class TradeManager {
continue; 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); Component rangeFailure = target == null ? null : tradeRangeFailure(requester, target);
if (rangeFailure != null) { if (rangeFailure != null) {
clearPendingAcceptance( clearPendingAcceptance(
@@ -1036,6 +1263,11 @@ public class TradeManager {
} }
private boolean startOrDelayDebugTrade(ServerPlayer player) { private boolean startOrDelayDebugTrade(ServerPlayer player) {
Component disabledFailure = tradeDisabledFailure(player);
if (disabledFailure != null) {
player.sendSystemMessage(disabledFailure);
return false;
}
long currentTick = player.server.getTickCount(); long currentTick = player.server.getTickCount();
List<Component> unsafe = tradeSafetyFailures(player, currentTick); List<Component> unsafe = tradeSafetyFailures(player, currentTick);
if (!unsafe.isEmpty()) { if (!unsafe.isEmpty()) {
@@ -1111,6 +1343,165 @@ public class TradeManager {
return trader + "=" + (entries.isEmpty() ? "(nothing)" : String.join("; ", entries)); 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<ItemStack> 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<net.minecraft.world.item.Item> tagKey = TagKey.create(net.minecraft.core.registries.Registries.ITEM, parsed);
Holder<net.minecraft.world.item.Item> holder = stack.getItemHolder();
if (holder.is(tagKey)) {
return new ItemBlockResult(itemKey, "tag #" + tagId + " is blacklisted");
}
}
return null;
}
private Set<String> effectiveBlacklistedItemIds(MinecraftServer server) {
Set<String> values = new HashSet<>(adminData(server).blacklistedItemIds());
values.addAll(normalizedConfigEntries(TradeConfig.adminBlacklistedItemIds()));
return values;
}
private Set<String> effectiveBlacklistedItemTags(MinecraftServer server) {
Set<String> values = new HashSet<>(adminData(server).blacklistedItemTags());
values.addAll(normalizedConfigEntries(TradeConfig.adminBlacklistedItemTags()));
return values;
}
private Set<String> effectiveBlacklistedMods(MinecraftServer server) {
Set<String> values = new HashSet<>(adminData(server).blacklistedMods());
values.addAll(normalizedConfigEntries(TradeConfig.adminBlacklistedMods()));
return values;
}
private Set<String> normalizedConfigEntries(List<String> values) {
Set<String> 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<Boolean> blockedSlots(MinecraftServer server, List<ItemStack> stacks) {
List<Boolean> result = new ArrayList<>(stacks.size());
for (ItemStack stack : stacks) {
result.add(blockedItem(server, stack) != null);
}
return result;
}
private DebugUnsafeState parseDebugUnsafeState(String spec) { private DebugUnsafeState parseDebugUnsafeState(String spec) {
try { try {
return DebugUnsafeState.valueOf(spec.trim().toUpperCase(java.util.Locale.ROOT)); return DebugUnsafeState.valueOf(spec.trim().toUpperCase(java.util.Locale.ROOT));
@@ -1130,12 +1521,18 @@ public class TradeManager {
return TradePreferencesData.get(server); return TradePreferencesData.get(server);
} }
private TradeAdminData adminData(MinecraftServer server) {
return TradeAdminData.get(server);
}
private record TradeRequest(UUID requester, UUID target, long createdTick) {} private record TradeRequest(UUID requester, UUID target, long createdTick) {}
private record DebugTradeRequest(UUID target, long createdTick) {} private record DebugTradeRequest(UUID target, long createdTick) {}
private record ScheduledDebugRequest(UUID target, long triggerTick, boolean autoAccept) {} private record ScheduledDebugRequest(UUID target, long triggerTick, boolean autoAccept) {}
private record ItemBlockResult(String itemId, String reason) {}
private static final class PendingAcceptance { private static final class PendingAcceptance {
private final UUID requester; private final UUID requester;
private final UUID target; private final UUID target;

View File

@@ -1,5 +1,6 @@
package com.trunksbomb.trade.trade; package com.trunksbomb.trade.trade;
import com.trunksbomb.trade.mod.TradeConfig;
import com.trunksbomb.trade.network.TradeClosePayload; import com.trunksbomb.trade.network.TradeClosePayload;
import com.trunksbomb.trade.network.TradeStatePayload; import com.trunksbomb.trade.network.TradeStatePayload;
import java.util.ArrayList; import java.util.ArrayList;
@@ -10,7 +11,6 @@ import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.ItemStack;
import net.neoforged.neoforge.network.PacketDistributor; import net.neoforged.neoforge.network.PacketDistributor;
import com.trunksbomb.trade.mod.TradeConfig;
public class TradeSession { public class TradeSession {
private static final int INVENTORY_SLOT_COUNT = TradeView.INVENTORY_SLOT_COUNT; private static final int INVENTORY_SLOT_COUNT = TradeView.INVENTORY_SLOT_COUNT;
@@ -23,6 +23,8 @@ public class TradeSession {
private final List<ItemStack> secondInventory; private final List<ItemStack> secondInventory;
private final List<TradeEntry> firstOffer = blankOffer(); private final List<TradeEntry> firstOffer = blankOffer();
private final List<TradeEntry> secondOffer = blankOffer(); private final List<TradeEntry> secondOffer = blankOffer();
private final List<Boolean> firstChangedSlots = blankChangedSlots();
private final List<Boolean> secondChangedSlots = blankChangedSlots();
private boolean firstAccepted; private boolean firstAccepted;
private boolean secondAccepted; private boolean secondAccepted;
private TradeStage stage = TradeStage.OFFERING; private TradeStage stage = TradeStage.OFFERING;
@@ -72,6 +74,13 @@ public class TradeSession {
PacketDistributor.sendToPlayer(second, new TradeClosePayload(id, reason)); 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) { public boolean addFromInventory(ServerPlayer player, int inventorySlot, int amount) {
if (stage != TradeStage.OFFERING || inventorySlot < 0 || inventorySlot >= INVENTORY_SLOT_COUNT) { if (stage != TradeStage.OFFERING || inventorySlot < 0 || inventorySlot >= INVENTORY_SLOT_COUNT) {
return false; return false;
@@ -95,7 +104,7 @@ public class TradeSession {
ItemStack merged = entry.stack().copy(); ItemStack merged = entry.stack().copy();
merged.grow(moveAmount); merged.grow(moveAmount);
offer.set(i, new TradeEntry(inventorySlot, merged)); offer.set(i, new TradeEntry(inventorySlot, merged));
clearAccepts(); clearAccepts(false, false, -1);
return true; return true;
} }
} }
@@ -106,7 +115,7 @@ public class TradeSession {
} }
offer.set(freeSlot, new TradeEntry(inventorySlot, sourceStack.copyWithCount(moveAmount))); offer.set(freeSlot, new TradeEntry(inventorySlot, sourceStack.copyWithCount(moveAmount)));
clearAccepts(); clearAccepts(false, false, -1);
return true; return true;
} }
@@ -130,7 +139,7 @@ public class TradeSession {
offer.set(offerSlot, null); offer.set(offerSlot, null);
} }
clearAccepts(); clearAccepts(player == first, player == second, offerSlot);
return true; return true;
} }
@@ -193,6 +202,7 @@ public class TradeSession {
public TradeView viewFor(ServerPlayer player) { public TradeView viewFor(ServerPlayer player) {
boolean isFirst = player == first; boolean isFirst = player == first;
ServerPlayer other = isFirst ? second : first; ServerPlayer other = isFirst ? second : first;
boolean showModifiedWarnings = TradeConfig.showTradeModifiedWarnings();
return new TradeView( return new TradeView(
id, id,
player.getGameProfile().getName(), player.getGameProfile().getName(),
@@ -201,10 +211,16 @@ public class TradeSession {
stage, stage,
isFirst ? firstAccepted : secondAccepted, isFirst ? firstAccepted : secondAccepted,
isFirst ? secondAccepted : firstAccepted, isFirst ? secondAccepted : firstAccepted,
showModifiedWarnings && hasChangedSlots(),
inventoryDisplayFor(player), inventoryDisplayFor(player),
emptyReservedSnapshot(), emptyReservedSnapshot(),
blankInventoryBlockedSlots(),
offerSnapshot(isFirst ? firstOffer : secondOffer), offerSnapshot(isFirst ? firstOffer : secondOffer),
offerSnapshot(isFirst ? secondOffer : firstOffer)); offerSnapshot(isFirst ? secondOffer : firstOffer),
blankChangedSlots(),
blankChangedSlots(),
showModifiedWarnings ? changedSlotSnapshot(isFirst ? firstChangedSlots : secondChangedSlots) : blankChangedSlots(),
showModifiedWarnings ? changedSlotSnapshot(isFirst ? secondChangedSlots : firstChangedSlots) : blankChangedSlots());
} }
public void sendState(ServerPlayer player) { public void sendState(ServerPlayer player) {
@@ -318,9 +334,26 @@ public class TradeSession {
return -1; return -1;
} }
private void clearAccepts() { private void clearAccepts(boolean markFirstChanged, boolean markSecondChanged, int changedSlot) {
firstAccepted = false; firstAccepted = false;
secondAccepted = false; secondAccepted = false;
if (changedSlot >= 0) {
if (markFirstChanged) {
firstChangedSlots.set(changedSlot, true);
}
if (markSecondChanged) {
secondChangedSlots.set(changedSlot, true);
}
}
}
private boolean hasChangedSlots() {
for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
if (firstChangedSlots.get(i) || secondChangedSlots.get(i)) {
return true;
}
}
return false;
} }
private int reservedCount(ServerPlayer player, int inventorySlot) { private int reservedCount(ServerPlayer player, int inventorySlot) {
@@ -377,6 +410,26 @@ public class TradeSession {
return result; return result;
} }
private static List<Boolean> blankInventoryBlockedSlots() {
List<Boolean> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
for (int i = 0; i < INVENTORY_SLOT_COUNT; i++) {
result.add(false);
}
return result;
}
private static List<Boolean> blankChangedSlots() {
List<Boolean> result = new ArrayList<>(OFFER_SLOT_COUNT);
for (int i = 0; i < OFFER_SLOT_COUNT; i++) {
result.add(false);
}
return result;
}
private static List<Boolean> changedSlotSnapshot(List<Boolean> changedSlots) {
return new ArrayList<>(changedSlots);
}
private static List<ItemStack> inventorySnapshot(ServerPlayer player) { private static List<ItemStack> inventorySnapshot(ServerPlayer player) {
List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT); List<ItemStack> result = new ArrayList<>(INVENTORY_SLOT_COUNT);
Inventory inventory = player.getInventory(); Inventory inventory = player.getInventory();

View File

@@ -15,10 +15,16 @@ public record TradeView(
TradeStage stage, TradeStage stage,
boolean selfAccepted, boolean selfAccepted,
boolean otherAccepted, boolean otherAccepted,
boolean itemsChanged,
List<ItemStack> inventory, List<ItemStack> inventory,
List<Integer> reservedCounts, List<Integer> reservedCounts,
List<Boolean> inventoryBlockedSlots,
List<ItemStack> selfOffer, List<ItemStack> selfOffer,
List<ItemStack> otherOffer) { List<ItemStack> otherOffer,
List<Boolean> selfBlockedSlots,
List<Boolean> otherBlockedSlots,
List<Boolean> selfChangedSlots,
List<Boolean> otherChangedSlots) {
public static final int INVENTORY_SLOT_COUNT = 36; public static final int INVENTORY_SLOT_COUNT = 36;
public static final int OFFER_SLOT_COUNT = 36; public static final int OFFER_SLOT_COUNT = 36;
@@ -33,10 +39,16 @@ public record TradeView(
buf.writeEnum(value.stage); buf.writeEnum(value.stage);
buf.writeBoolean(value.selfAccepted); buf.writeBoolean(value.selfAccepted);
buf.writeBoolean(value.otherAccepted); buf.writeBoolean(value.otherAccepted);
buf.writeBoolean(value.itemsChanged);
writeStacks(buf, value.inventory, INVENTORY_SLOT_COUNT); writeStacks(buf, value.inventory, INVENTORY_SLOT_COUNT);
writeInts(buf, value.reservedCounts, 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.selfOffer, OFFER_SLOT_COUNT);
writeStacks(buf, value.otherOffer, 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);
} }
@Override @Override
@@ -49,10 +61,16 @@ public record TradeView(
buf.readEnum(TradeStage.class), buf.readEnum(TradeStage.class),
buf.readBoolean(), buf.readBoolean(),
buf.readBoolean(), buf.readBoolean(),
buf.readBoolean(),
readStacks(buf, INVENTORY_SLOT_COUNT), readStacks(buf, INVENTORY_SLOT_COUNT),
readInts(buf, INVENTORY_SLOT_COUNT), readInts(buf, INVENTORY_SLOT_COUNT),
readBooleans(buf, INVENTORY_SLOT_COUNT),
readStacks(buf, OFFER_SLOT_COUNT), readStacks(buf, OFFER_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));
} }
}; };
@@ -83,4 +101,18 @@ public record TradeView(
} }
return values; return values;
} }
private static void writeBooleans(RegistryFriendlyByteBuf buf, List<Boolean> values, int expectedSize) {
for (int i = 0; i < expectedSize; i++) {
buf.writeBoolean(values.get(i));
}
}
private static List<Boolean> readBooleans(RegistryFriendlyByteBuf buf, int expectedSize) {
List<Boolean> values = new ArrayList<>(expectedSize);
for (int i = 0; i < expectedSize; i++) {
values.add(buf.readBoolean());
}
return values;
}
} }